diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..d4082ca8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +* +!Cargo.toml +!Cargo.lock +!benches/ +!pgcopy/ +!src/ diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 00000000..36c4e729 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,17 @@ +name: Rust +on: + push: + branches: ["main"] +jobs: + build-test-bench: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build + run: cargo build + - name: Test + run: cargo test --quiet --lib + - name: Test Shortdeck + run: cargo test --quiet --lib --features shortdeck + - name: Benchmark + run: cargo bench --quiet --message-format short diff --git a/.gitignore b/.gitignore index ea8c4bf7..de686e52 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,21 @@ /target +/pgcopy +/data +/analysis +/.vscode + +.cursorrules +.DS_Store +.todo +.env +.swp + +*.log +*.png +*.gif +*.sh + +react/.next/ +react/build/ +react/dist/ +react/node_modules/ diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 328a3f21..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "type": "lldb", - "request": "launch", - "name": "Debug executable 'robopoker'", - "cargo": { - "args": ["build", "--bin=robopoker", "--package=robopoker"], - "filter": { - "name": "robopoker", - "kind": "bin" - } - }, - "args": [], - "cwd": "${workspaceFolder}", - "env": { "RUST_BACKTRACE": "1" } - }, - { - "type": "lldb", - "request": "launch", - "name": "Debug unit tests in executable 'robopoker'", - "cargo": { - "args": ["test", "--no-run", "--bin=robopoker", "--package=robopoker"], - "filter": { - "name": "robopoker", - "kind": "bin" - } - }, - "args": [], - "cwd": "${workspaceFolder}" - } - ] -} diff --git a/Cargo.lock b/Cargo.lock index ab1280fb..5e83779f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,43 +1,356 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 + +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags 2.9.0", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-cors" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0346d8c1f762b41b458ed3145eea914966bb9ad20b9be0d6d463b20d45586370" +dependencies = [ + "actix-utils", + "actix-web", + "derive_more", + "futures-util", + "log", + "once_cell", + "smallvec", +] + +[[package]] +name = "actix-http" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d48f96fc3003717aeb9856ca3d02a8c7de502667ad76eeacd830b48d2e91fac4" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "ahash", + "base64", + "bitflags 2.9.0", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "futures-core", + "h2", + "http", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand 0.8.5", + "sha1", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "actix-router" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" +dependencies = [ + "bytestring", + "cfg-if", + "http", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24eda4e2a6e042aa4e55ac438a2ae052d3b5da0ecf83d7411e1a368946925208" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca2549781d8dd6d75c40cf6b6051260a2cc2f3c62343d761a969a0640646894" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b894941f818cfdc7ccc4b9e60fa7e53b5042a2e8567270f9147d5591893373a" +dependencies = [ + "futures-core", + "paste", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9180d76e5cc7ccbc4d60a506f2c727730b154010262df5b910eb17dbe4b8cb38" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "actix-web-codegen", + "ahash", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2", + "time", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn", +] [[package]] name = "addr2line" -version = "0.21.0" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ "gimli", ] [[package]] -name = "adler" -version = "1.0.2" +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "getrandom 0.2.15", + "once_cell", + "version_check", + "zerocopy 0.7.35", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-trait" +version = "0.1.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d556ec1359574147ec0c4fc5eb525f3f23263a592b1a9c07e0a75b427de55c97" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atty" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] [[package]] name = "autocfg" -version = "1.1.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "backtrace" -version = "0.3.69" +version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" dependencies = [ "addr2line", - "cc", "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", + "windows-targets", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -46,21 +359,83 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.4.2" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" [[package]] -name = "bytes" +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fa05ad7d803d413eb8380983b092cbbaf9a85f151b871360e7b00cd7060b37" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "bytestring" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e465647ae23b2823b0753f50decb2d5a86d2bb2cac04788fafd1f80e45378e5f" +dependencies = [ + "bytes", +] + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.0.90" +version = "1.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5" +checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" +dependencies = [ + "jobserver", + "libc", + "shlex", +] [[package]] name = "cfg-if" @@ -68,543 +443,2290 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "bitflags 1.3.2", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "clap" +version = "4.5.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "027bb0d98429ae334a8698531da7077bdf906419543a35a55c2cb1b66437d767" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5589e0cba072e0f3d23791efac0fd8627b49c829c196a492e88168e6a669d863" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + [[package]] name = "colored" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] name = "console" -version = "0.15.8" +version = "0.15.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" dependencies = [ "encode_unicode", - "lazy_static", "libc", - "unicode-width", - "windows-sys 0.52.0", + "once_cell", + "unicode-width 0.2.0", + "windows-sys 0.59.0", ] [[package]] -name = "dialoguer" -version = "0.11.0" +name = "console_error_panic_hook" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" dependencies = [ - "console", - "shell-words", - "tempfile", - "thiserror", - "zeroize", + "cfg-if", + "wasm-bindgen", ] [[package]] -name = "encode_unicode" +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "criterion" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +checksum = "b01d6de93b2b6c65e17c634a26653a29d107b3c98c607c765bf38d041531cd8f" +dependencies = [ + "atty", + "cast", + "clap 2.34.0", + "criterion-plot", + "csv", + "itertools", + "lazy_static", + "num-traits", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_cbor", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2673cc8207403546f45f5fd319a974b1e6983ad1a3ee7e6041650013be041876" +dependencies = [ + "cast", + "itertools", +] [[package]] -name = "errno" -version = "0.3.8" +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d02f3b0da4c6504f86e9cd789d8dbafab48c2321be74e9987593de5a894d93d" +dependencies = [ + "memchr", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "0.99.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3da29a38df43d6f156149c9b43ded5e018ddff2a855cf2cfd62e8cd7d079c69f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcaee3d8e3cfc3fd92428d477bc97fc29ec8716d180c0d74c643bb26166660e0" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11faaf5a5236997af9848be0bef4db95824b1d534ebc64d0f0c6cf3e67bd38dc" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.13.3+wasi-0.2.2", + "windows-targets", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "h2" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + +[[package]] +name = "indexmap" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width 0.2.0", + "web-time", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.170" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" + +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minicov" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27fe9f1cc3c22e1687f9446c2083c4c5fc7f0bcf1c7a86bdbded14985895b4b" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi 0.3.9", + "libc", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" + +[[package]] +name = "oorandom" +version = "11.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "portable-atomic" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" + +[[package]] +name = "postgres-protocol" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ff0abab4a9b844b93ef7b81f1efc0a366062aaef2cd702c76256b5dc075c54" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator", + "hmac", + "md-5", + "memchr", + "rand 0.9.0", + "sha2", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613283563cd90e1dfc3518d548caee47e0e725455ed619881f5cf21f36de4b48" +dependencies = [ + "bytes", + "fallible-iterator", + "postgres-protocol", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy 0.7.35", +] + +[[package]] +name = "proc-macro2" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1f1914ce909e1658d9907913b4b91947430c7d9be598b15a1912935b8c04801" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", + "zerocopy 0.8.22", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.1", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" +dependencies = [ + "bitflags 2.9.0", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "robopoker" +version = "0.1.1" +dependencies = [ + "actix-cors", + "actix-web", + "byteorder", + "bytes", + "clap 4.5.31", + "colored", + "console_error_panic_hook", + "criterion", + "dialoguer", + "env_logger", + "futures", + "getrandom 0.2.15", + "indicatif", + "js-sys", + "log", + "num_cpus", + "petgraph", + "rand 0.8.5", + "rayon", + "serde", + "serde_json", + "simplelog", + "tokio", + "tokio-postgres", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.9.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + +[[package]] +name = "serde" +version = "1.0.218" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half", + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.218" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "simplelog" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" +dependencies = [ + "log", + "termcolor", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" + +[[package]] +name = "socket2" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02e925281e18ffd9d640e234264753c43edc62d64b2d4cf898f1bc5e75f3fc2" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230" +dependencies = [ + "cfg-if", + "fastrand", + "getrandom 0.3.1", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width 0.1.14", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb041120f25f8fbe8fd2dbe4671c7c2ed74d83be2e7a77529bf7e0790ae3f472" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765c97a5b985b7c11d7bc27fa927dc4fe6af3a6dfb021d28deb60d3bf51e76ef" + +[[package]] +name = "time-macros" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8093bc3e81c3bc5f7879de09619d06c9a5a5e45ca44dfeeb7225bae38005c5c" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tinyvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c95d533c83082bb6490e0189acaa0bbeef9084e60471b696ca6988cd0541fb0" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.9.0", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-util" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ - "libc", - "windows-sys 0.52.0", + "once_cell", ] [[package]] -name = "fastrand" -version = "2.0.1" +name = "typenum" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" [[package]] -name = "getrandom" -version = "0.2.12" +name = "unicode-bidi" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] -name = "gimli" -version = "0.28.1" +name = "unicode-ident" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] -name = "hermit-abi" -version = "0.3.9" +name = "unicode-normalization" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] [[package]] -name = "lazy_static" -version = "1.4.0" +name = "unicode-properties" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" [[package]] -name = "libc" -version = "0.2.153" +name = "unicode-width" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] -name = "linux-raw-sys" -version = "0.4.13" +name = "unicode-width" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] -name = "lock_api" -version = "0.4.11" +name = "url" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ - "autocfg", - "scopeguard", + "form_urlencoded", + "idna", + "percent-encoding", ] [[package]] -name = "memchr" -version = "2.7.1" +name = "utf16_iter" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" [[package]] -name = "miniz_oxide" -version = "0.7.2" +name = "utf8_iter" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" -dependencies = [ - "adler", -] +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] -name = "mio" -version = "0.8.11" +name = "utf8parse" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.48.0", -] +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] -name = "num_cpus" -version = "1.16.0" +name = "version_check" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi", - "libc", -] +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "object" -version = "0.32.2" +name = "walkdir" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ - "memchr", + "same-file", + "winapi-util", ] [[package]] -name = "parking_lot" -version = "0.12.1" +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" -dependencies = [ - "lock_api", - "parking_lot_core", -] +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] -name = "parking_lot_core" -version = "0.9.9" +name = "wasi" +version = "0.13.3+wasi-0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets 0.48.5", + "wit-bindgen-rt", ] [[package]] -name = "pin-project-lite" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" - -[[package]] -name = "ppv-lite86" -version = "0.2.17" +name = "wasite" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] -name = "proc-macro2" -version = "1.0.79" +name = "wasm-bindgen" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ - "unicode-ident", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", ] [[package]] -name = "quote" -version = "1.0.35" +name = "wasm-bindgen-backend" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ + "bumpalo", + "log", "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", ] [[package]] -name = "rand" -version = "0.8.5" +name = "wasm-bindgen-futures" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" dependencies = [ - "libc", - "rand_chacha", - "rand_core", + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", ] [[package]] -name = "rand_chacha" -version = "0.3.1" +name = "wasm-bindgen-macro" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ - "ppv-lite86", - "rand_core", + "quote", + "wasm-bindgen-macro-support", ] [[package]] -name = "rand_core" -version = "0.6.4" +name = "wasm-bindgen-macro-support" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ - "getrandom", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", ] [[package]] -name = "redox_syscall" -version = "0.4.1" +name = "wasm-bindgen-shared" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" dependencies = [ - "bitflags 1.3.2", + "unicode-ident", ] [[package]] -name = "robopoker" -version = "0.1.0" +name = "wasm-bindgen-test" +version = "0.3.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66c8d5e33ca3b6d9fa3b4676d774c5778031d27a578c2b007f905acf816152c3" dependencies = [ - "colored", - "dialoguer", - "rand", - "tokio", + "js-sys", + "minicov", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", ] [[package]] -name = "rustc-demangle" -version = "0.1.23" +name = "wasm-bindgen-test-macro" +version = "0.3.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +checksum = "17d5042cc5fa009658f9a7333ef24291b1291a25b6382dd68862a7f3b969f69b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "rustix" -version = "0.38.31" +name = "web-sys" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" dependencies = [ - "bitflags 2.4.2", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.52.0", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "shell-words" -version = "1.1.0" +name = "whoami" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" +dependencies = [ + "redox_syscall", + "wasite", + "web-sys", +] [[package]] -name = "signal-hook-registry" -version = "1.4.1" +name = "winapi" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ - "libc", + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", ] [[package]] -name = "smallvec" -version = "1.13.2" +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] -name = "socket2" -version = "0.5.6" +name = "winapi-util" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] -name = "syn" -version = "2.0.52" +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "tempfile" -version = "3.10.1" +name = "windows-sys" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "cfg-if", - "fastrand", - "rustix", - "windows-sys 0.52.0", + "windows-targets", ] [[package]] -name = "thiserror" -version = "1.0.58" +name = "windows-sys" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "thiserror-impl", + "windows-targets", ] [[package]] -name = "thiserror-impl" -version = "1.0.58" +name = "windows-targets" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] -name = "tokio" -version = "1.36.0" +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" -dependencies = [ - "backtrace", - "bytes", - "libc", - "mio", - "num_cpus", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.48.0", -] +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "tokio-macros" -version = "2.2.0" +name = "windows_aarch64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "unicode-ident" -version = "1.0.12" +name = "windows_i686_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] -name = "unicode-width" -version = "0.1.11" +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +name = "windows_i686_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] -name = "windows-sys" -version = "0.48.0" +name = "windows_x86_64_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] -name = "windows-sys" -version = "0.52.0" +name = "windows_x86_64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.4", -] +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] -name = "windows-targets" -version = "0.48.5" +name = "windows_x86_64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "windows-targets" -version = "0.52.4" +name = "wit-bindgen-rt" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" dependencies = [ - "windows_aarch64_gnullvm 0.52.4", - "windows_aarch64_msvc 0.52.4", - "windows_i686_gnu 0.52.4", - "windows_i686_msvc 0.52.4", - "windows_x86_64_gnu 0.52.4", - "windows_x86_64_gnullvm 0.52.4", - "windows_x86_64_msvc 0.52.4", + "bitflags 2.9.0", ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" +name = "write16" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" [[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.4" +name = "writeable" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" [[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" +name = "yoke" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] [[package]] -name = "windows_aarch64_msvc" -version = "0.52.4" +name = "yoke-derive" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] [[package]] -name = "windows_i686_gnu" -version = "0.48.5" +name = "zerocopy" +version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive 0.7.35", +] [[package]] -name = "windows_i686_gnu" -version = "0.52.4" +name = "zerocopy" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" +checksum = "09612fda0b63f7cb9e0af7e5916fe5a1f8cdcb066829f10f36883207628a4872" +dependencies = [ + "zerocopy-derive 0.8.22", +] [[package]] -name = "windows_i686_msvc" -version = "0.48.5" +name = "zerocopy-derive" +version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "windows_i686_msvc" -version = "0.52.4" +name = "zerocopy-derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79f81d38d7a2ed52d8f034e62c568e111df9bf8aba2f7cf19ddc5bf7bd89d520" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] [[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" +name = "zerofrom-derive" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] [[package]] -name = "windows_x86_64_gnu" -version = "0.52.4" +name = "zeroize" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" +name = "zerovec" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] [[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.4" +name = "zerovec-derive" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" +name = "zstd" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.52.4" +name = "zstd-safe" +version = "7.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" +checksum = "f3051792fbdc2e1e143244dc28c60f73d8470e93f3f9cbd0ead44da5ed802722" +dependencies = [ + "zstd-sys", +] [[package]] -name = "zeroize" -version = "1.7.0" +name = "zstd-sys" +version = "2.0.14+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" +checksum = "8fb060d4926e4ac3a3ad15d864e99ceb5f343c6b34f5bd6d81ae6ed417311be5" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index a1eeba2d..227373f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,67 @@ [package] name = "robopoker" -version = "0.1.0" +version = "0.1.1" +authors = ["Kelechi Ukah "] +description = "Implementations of No-Limit Texas Hold-Em solution." +homepage = "https://github.com/krukah/robopoker" +repository = "https://github.com/krukah/robopoker" +readme = "README.md" +keywords = ["poker", "cards", "cfr", "mcts", "holdem"] +license = "MIT" edition = "2021" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [dependencies] -rand = "0.8" -colored = "2.0" -dialoguer = "0.11.0" -tokio = { version = "1.0", features = ["full"] } +petgraph = "0.6.5" +bytes = "1.0" +log = "0.4.22" +byteorder = "1.5.0" +futures = "0.3" +serde_json = "1.0" +rand = { version = "0.8.5", features = ["small_rng"] } +colored = { version = "2.0", optional = true } +dialoguer = { version = "0.11.0", optional = true } +num_cpus = { version = "1.16.0", optional = true } +rayon = { version = "1.10.0", optional = true } +indicatif = { version = "0.17.8", optional = true } +simplelog = { version = "0.12.2", optional = true } +tokio = { version = "1.0", features = ["full"], optional = true } +tokio-postgres = { version = "0.7", optional = true } +clap = { version = "4.0", features = ["derive"], optional = true } +actix-web = { version = "4.4", optional = true } +actix-cors = { version = "0.6", optional = true } +serde = { version = "1.0", features = ["derive"] } +env_logger = { version = "0.11.6", optional = true } +js-sys = "0.3" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +console_error_panic_hook = "0.1.7" +getrandom = { version = "0.2", features = ["js"] } + +[lib] +crate-type = ["cdylib", "rlib"] + +[dev-dependencies] +criterion = { version = "0.3", features = ["html_reports"] } +wasm-bindgen-test = "0.3" + +[[bench]] +name = "benchmarks" +harness = false + +[features] +default = ["native"] +shortdeck = [] +native = [ + "colored", + "dialoguer", + "num_cpus", + "rayon", + "indicatif", + "simplelog", + "tokio", + "tokio-postgres", + "clap", + "actix-web", + "actix-cors", + "env_logger" +] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..b4b5f701 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# Build stage +FROM rust:1.80 AS builder +WORKDIR /usr/src/robopoker +COPY . . +RUN cargo build --release + +# Final stage +FROM debian:bookworm-slim +RUN apt-get update && \ + apt-get install -y libssl3 ca-certificates && \ + rm -rf /var/lib/apt/lists/* +COPY --from=builder /usr/src/robopoker/target/release/robopoker . +COPY pgcopy/ pgcopy/ +ENTRYPOINT ["/robopoker"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..e0bf8d22 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Kelechi Ukah + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..41e6a389 --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +robopoker +=========================== +[![license](https://img.shields.io/github/license/krukah/robopoker)](LICENSE) +[![build](https://github.com/krukah/robopoker/actions/workflows/rust.yml/badge.svg)](https://github.com/krukah/robopoker/actions/workflows/rust.yml) + +`robopoker` is a Rust library to play, learn, analyze, track, and solve No-Limit Texas Hold'em. + +# Overview + +This started as a simple Rust project before evolving into a state-of-the-art poker solver and analysis tool seeking functional parity with Pluribus1, the first superhuman agent in multiplayer No Limit Texas Hold'em. + + + + + +
+ Training Progress +
+ Monte Carlo Tree Search +
+ Strategy Growth +
+ Equity Distributions +
+ +The guiding philosophy of this crate is to use very precise struct and trait abstractions to represent the rules, mechanics, and strategies of NLHE. Every module is modeled as closely as possible to its real-world analogue, while also utilizing clever representations and transformations to be as memory- and compute-efficient as possible. We lean heavily into idiomatic Rust by using lazy functional patterns, efficient data structure representations, infallible type conversions, thread-safe multi-processing, and strictly safe code. + +The intended use case is a one-time resource-intensive training run that will save information abstractions, k-means clusters, distance metrics, and blueprint profiles to disk for use in later runs or analyses. To generate these datasets under arbitrary parametrization, the program will iterate through the following steps: + +1. For each layer of hierarchical abstraction (`preflop`, `flop`, `turn`, `river`): + - Generate isomorphic hand clusters by exhaustively iterating through strategically equivalent situations + - Initialize k-means centroids using k-means++ seeding over abstract distribution space2 + - Run hierarchical k-means clustering to group hands into strategically similar situations + - Calculate Earth Mover's Distance metrics via optimal transport5 between all cluster pairs + - Save abstraction results and distance metrics to disk + +2. Run iterative Monte Carlo CFR training3: + - Initialize regret tables and strategy profiles + - Sample game trajectories using external sampling MCCFR + - Update regret values and compute counterfactual values + - Accumulate strategy updates with linear weighting + - Periodically checkpoint blueprint strategy to disk + - Continue until convergence criteria met + +3. Perform real-time search during gameplay (in progress): + - Load pre-computed abstractions and blueprint strategy + - Use depth-limited subgame solving with blueprint as prior + - Dynamically build local game trees + - Run targeted Monte Carlo rollouts + - Return optimal actions within time constraints + +# System Requirements + +The abstraction and counterfactual regret minimization algorithms are quite resource intensive. +- Hierarchical k-means requires holding all strategically isomorphic observations at a given street, as well as their projected distributions onto their future streets. +- Monte Carlo CFR requires sampling game trees with full game state information and accumulating regret and policy information + +| Street | Abstraction Size | Metric Size | +|------------|-------------------|-------------| +| Preflop | 4 KB | 301 KB | +| Flop | 32 MB | 175 KB | +| Turn | 347 MB | 175 KB | +| River | 3.02 GB | - | + +# Modules + +## `cards` + +Core functionality for working with standard playing cards and Texas Hold'em rules: + +- **Hand Evaluation**: Nanosecond hand strength calculation using lazy evaluation; fastest open-source hand evaluation algorithm; benchmarks outperform the popular Cactus Kev implementation +- **Equity Calculation**: Fast equity calculations between ranges of hands, supporting both exact enumeration and Monte Carlo simulation +- **Exhaustive Iteration**: Efficient iteration over cards, hands, decks, and private-public observations with lazy bitwise advancing +- **Distribution Analysis**: Tools for analyzing equity distributions and range vs range scenarios +- **Short Deck Support**: Full support for 36-card short deck variant with adjusted hand rankings and iterators +- **Bijective Representations**: Multiple card representations `(u8/u16/u32/u64)` allow for maximally efficient operations and transformations + +## `gameplay` + +A complete poker game engine implementation: + +- **Standard Rules**: Full implementation of No-Limit Texas Hold'em rules and mechanics +- **Complex Showdowns**: Elegant handling and thorough testing of showdown edge cases like side pots, all-ins, dead cards, and multi-way ties +- **Flexible Payout Logic**: Configurable payout structures for different game formats +- **Decider Abstraction**: Generic trait system for implementing different player decision strategies +- **Functional Design**: Clean Node/Edge/Tree implementation for game state representation + +## `clustering` + +Advanced clustering capabilities for poker hand analysis: + +- **Isomorphic Exhaustion**: Plays out *every one of 3.1T* possible situations by respecting symmetries and enforcing permutation invariance4 +- **Earth Mover's Distance (EMD)**: Implementation of EMD metric for comparing hand distributions over equity and hierarchical abstraction clusters +- **Hierarchical K-means**: Multi-level clustering algorithm for creating strategic abstractions from bottom-up distribution clustering +- **Optimal Transport**: High level abstractions for calculating Wasserstein distance between two arbitrary distributions supported over a joint metric space +- **Persistence**: Efficient serialization and deserialization of clustering results to/from disk using Postgres binary formats + +## `mccfr` + +Monte Carlo Counterfactual Regret Minimization solver: + +- **Blueprint Convergence**: Previously demonstrated convergence on Rock-Paper-Scissors as validation +- **External Sampling**: Implementation of external sampling MCCFR variant +- **Dynamic Tree Building**: On-the-fly game tree construction for memory efficiency +- **Linear Strategy Weighting**: Efficient strategy updates using iterative weighting and discount schemes5 +- **Persistence**: Efficient serialization and deserialization of blueprint results to/from disk using Postgres binary formats + +## `analysis` + +Tools for analyzing and querying results yielded from our training pipeline using PostgreSQL. + +- **Data Upload**: Copies Postgres binary files into a database with extensive indexing for efficient lookups. +- **SQL Optimization**: Enables querying all isomorphisms, abstractions, EMD metrics, potential distributions, and blueprint strategies learned during prior training steps. +- **CLI Tool**: A command-line interface to perform basic queries, such as equity and distance calculations. +- **Server Struct**: Actix web instance serving HTTP requests to support an analysis frontend client (coming soon). + +# References + +1. (2019). Superhuman AI for multiplayer poker. [(Science)](https://science.sciencemag.org/content/early/2019/07/10/science.aay2400) +2. (2014). Potential-Aware Imperfect-Recall Abstraction with Earth Mover's Distance in Imperfect-Information Games. [(AAAI)](http://www.cs.cmu.edu/~sandholm/potential-aware_imperfect-recall.aaai14.pdf) +3. (2007). Regret Minimization in Games with Incomplete Information. [(NIPS)](https://papers.nips.cc/paper/3306-regret-minimization-in-games-with-incomplete-information) +4. (2013). A Fast and Optimal Hand Isomorphism Algorithm. [(AAAI)](https://www.cs.cmu.edu/~waugh/publications/isomorphism13.pdf) +5. (2018). Near-linear time approximation algorithms for optimal transport via Sinkhorn iteration. [(NIPS)](https://arxiv.org/abs/1705.09634) +6. (2019). Solving Imperfect-Information Games via Discounted Regret Minimization. [(AAAI)](https://arxiv.org/pdf/1809.04040.pdf) +7. (2013). Action Translation in Extensive-Form Games with Large Action Spaces: Axioms, Paradoxes, and the Pseudo-Harmonic Mapping. [(IJCAI)](http://www.cs.cmu.edu/~sandholm/reverse%20mapping.ijcai13.pdf) +8. (2015). Discretization of Continuous Action Spaces in Extensive-Form Games. [(AAMAS)](http://www.cs.cmu.edu/~sandholm/discretization.aamas15.fromACM.pdf) +9. (2015). Regret-Based Pruning in Extensive-Form Games. [(NIPS)](http://www.cs.cmu.edu/~sandholm/regret-basedPruning.nips15.withAppendix.pdf) +10. (2018). Depth-Limited Solving for Imperfect-Information Games. [(NeurIPS)](https://arxiv.org/pdf/1805.08195.pdf) +11. (2017). Reduced Space and Faster Convergence in Imperfect-Information Games via Pruning. [(ICML)](http://www.cs.cmu.edu/~sandholm/reducedSpace.icml17.pdf) +12. (2017). Safe and Nested Subgame Solving for Imperfect-Information Games. [(NIPS)](https://www.cs.cmu.edu/~noamb/papers/17-NIPS-Safe.pdf) diff --git a/benches/benchmarks.rs b/benches/benchmarks.rs new file mode 100644 index 00000000..3612a0c0 --- /dev/null +++ b/benches/benchmarks.rs @@ -0,0 +1,117 @@ +criterion::criterion_main!(benches); +criterion::criterion_group! { + name = benches; + config = criterion::Criterion::default() + .without_plots() + .noise_threshold(3.0) + .significance_level(0.01) + .sample_size(10) + .measurement_time(std::time::Duration::from_secs(1)); + targets = + sampling_river_evaluation, + sampling_river_equity, + sampling_river_observation, + converting_turn_isomorphism, + exhausting_flop_observations, + exhausting_flop_isomorphisms, + collecting_turn_histogram, + computing_optimal_transport_variation, + computing_optimal_transport_heuristic, + computing_optimal_transport_sinkhorns, +} + +fn sampling_river_evaluation(c: &mut criterion::Criterion) { + c.bench_function("evaluate a 7-card Hand", |b| { + let hand = Hand::from(Observation::from(Street::Rive)); + b.iter(|| Strength::from(Evaluator::from(hand))) + }); +} + +fn sampling_river_observation(c: &mut criterion::Criterion) { + c.bench_function("collect a 7-card River Observation", |b| { + b.iter(|| Observation::from(Street::Rive)) + }); +} + +fn sampling_river_equity(c: &mut criterion::Criterion) { + let observation = Observation::from(Street::Rive); + c.bench_function("calculate River equity", |b| { + b.iter(|| observation.equity()) + }); +} + +fn exhausting_flop_observations(c: &mut criterion::Criterion) { + c.bench_function("exhaust all Flop Observations", |b| { + b.iter(|| ObservationIterator::from(Street::Flop).count()) + }); +} + +fn exhausting_flop_isomorphisms(c: &mut criterion::Criterion) { + c.bench_function("exhaust all Flop Isomorphisms", |b| { + b.iter(|| { + ObservationIterator::from(Street::Flop) + .filter(|o| Isomorphism::is_canonical(o)) + .count() + }) + }); +} + +fn converting_turn_isomorphism(c: &mut criterion::Criterion) { + let observation = Observation::from(Street::Turn); + c.bench_function("convert a Turn Observation to Isomorphism", |b| { + b.iter(|| Isomorphism::from(observation)) + }); +} + +fn collecting_turn_histogram(c: &mut criterion::Criterion) { + let observation = Observation::from(Street::Turn); + c.bench_function("collect a Histogram from a Turn Observation", |b| { + b.iter(|| Histogram::from(observation)) + }); +} + +fn computing_optimal_transport_variation(c: &mut criterion::Criterion) { + let ref h1 = Histogram::from(Observation::from(Street::Turn)); + let ref h2 = Histogram::from(Observation::from(Street::Turn)); + c.bench_function("compute optimal transport (1-dimensional)", |b| { + b.iter(|| Equity::variation(&h1, &h2)) + }); +} + +fn computing_optimal_transport_heuristic(c: &mut criterion::Criterion) { + let (metric, h1, h2, _) = EMD::random().inner(); + c.bench_function("compute optimal transport (greedy)", |b| { + b.iter(|| Heuristic::from((&h1, &h2, &metric)).minimize().cost()) + }); +} + +fn computing_optimal_transport_sinkhorns(c: &mut criterion::Criterion) { + let (metric, h1, h2, _) = EMD::random().inner(); + c.bench_function("compute optimal transport (entropy regularized)", |b| { + b.iter(|| Sinkhorn::from((&h1, &h2, &metric)).minimize().cost()) + }); + /* + TEMPERATURE ITERS TOLERANCE TIME + 0.125 16 0.001 200 + 0.125 16 0.010 135 + 0.125 16 0.100 67 + 8.000 16 0.001 55 + 8.000 16 0.010 55 + 8.000 16 0.100 55 + */ +} + +use robopoker::cards::evaluator::Evaluator; +use robopoker::cards::hand::Hand; +use robopoker::cards::isomorphism::Isomorphism; +use robopoker::cards::observation::Observation; +use robopoker::cards::observations::ObservationIterator; +use robopoker::cards::street::Street; +use robopoker::cards::strength::Strength; +use robopoker::clustering::emd::EMD; +use robopoker::clustering::equity::Equity; +use robopoker::clustering::heuristic::Heuristic; +use robopoker::clustering::histogram::Histogram; +use robopoker::clustering::sinkhorn::Sinkhorn; +use robopoker::transport::coupling::Coupling; +use robopoker::Arbitrary; diff --git a/src/analysis/api.rs b/src/analysis/api.rs new file mode 100644 index 00000000..fe928472 --- /dev/null +++ b/src/analysis/api.rs @@ -0,0 +1,825 @@ +use super::response::Sample; +use crate::cards::isomorphism::Isomorphism; +use crate::cards::observation::Observation; +use crate::cards::street::Street; +use crate::clustering::abstraction::Abstraction; +use crate::clustering::histogram::Histogram; +use crate::clustering::metric::Metric; +use crate::clustering::pair::Pair; +use crate::clustering::sinkhorn::Sinkhorn; +use crate::transport::coupling::Coupling; +use crate::Energy; +use crate::Probability; +use std::collections::BTreeMap; +use std::collections::HashSet; +use std::sync::Arc; +use tokio_postgres::Client; +use tokio_postgres::Error as E; + +pub struct API(Arc); + +impl From> for API { + fn from(client: Arc) -> Self { + Self(client) + } +} + +impl API { + pub async fn new() -> Self { + Self(crate::db().await) + } + + // global lookups + pub async fn obs_to_abs(&self, obs: Observation) -> Result { + let iso = i64::from(Isomorphism::from(obs)); + const SQL: &'static str = r#" + SELECT abs + FROM isomorphism + WHERE obs = $1 + "#; + Ok(self + .0 + .query_one(SQL, &[&iso]) + .await? + .get::<_, i64>(0) + .into()) + } + pub async fn metric(&self, street: Street) -> Result { + let street = street as i16; + const SQL: &'static str = r#" + SELECT + a1.abs # a2.abs AS xor, + m.dx AS dx + FROM abstraction a1 + JOIN abstraction a2 + ON a1.street = a2.street + JOIN metric m + ON (a1.abs # a2.abs) = m.xor + WHERE + a1.street = $1 AND + a1.abs != a2.abs; + "#; + Ok(self + .0 + .query(SQL, &[&street]) + .await? + .iter() + .map(|row| (row.get::<_, i64>(0), row.get::<_, Energy>(1))) + .map(|(xor, distance)| (Pair::from(xor), distance)) + .collect::>() + .into()) + } + pub async fn basis(&self, street: Street) -> Result, E> { + let street = street as i16; + const SQL: &'static str = r#" + SELECT a2.abs + FROM abstraction a2 + JOIN abstraction a1 ON a2.street = a1.street + WHERE a1.abs = $1; + "#; + Ok(self + .0 + .query(SQL, &[&street]) + .await? + .iter() + .map(|row| row.get::<_, i64>(0)) + .map(Abstraction::from) + .collect()) + } + + // equity calculations + pub async fn abs_equity(&self, abs: Abstraction) -> Result { + let iso = i64::from(abs); + const SQL: &'static str = r#" + SELECT equity + FROM abstraction + WHERE abs = $1 + "#; + Ok(self + .0 + .query_one(SQL, &[&iso]) + .await? + .get::<_, f32>(0) + .into()) + } + pub async fn obs_equity(&self, obs: Observation) -> Result { + let iso = i64::from(Isomorphism::from(obs)); + let sql = if obs.street() == Street::Rive { + r#" + SELECT equity + FROM isomorphism + WHERE obs = $1 + "# + } else { + r#" + SELECT SUM(t.dx * a.equity) + FROM transitions t + JOIN isomorphism e ON e.abs = t.prev + JOIN abstraction a ON a.abs = t.next + WHERE e.obs = $1 + "# + }; + Ok(self + .0 + .query_one(sql, &[&iso]) + .await? + .get::<_, f32>(0) + .into()) + } + + // distance calculations + pub async fn abs_distance(&self, abs1: Abstraction, abs2: Abstraction) -> Result { + if abs1.street() != abs2.street() { + return Err(E::__private_api_timeout()); + } + if abs1 == abs2 { + return Ok(0 as Energy); + } + let xor = i64::from(Pair::from((&abs1, &abs2))); + const SQL: &'static str = r#" + SELECT m.dx + FROM metric m + WHERE $1 = m.xor; + "#; + Ok(self.0.query_one(SQL, &[&xor]).await?.get::<_, Energy>(0)) + } + pub async fn obs_distance(&self, obs1: Observation, obs2: Observation) -> Result { + if obs1.street() != obs2.street() { + return Err(E::__private_api_timeout()); + } + let (ref hx, ref hy, ref metric) = tokio::try_join!( + self.obs_histogram(obs1), + self.obs_histogram(obs2), + self.metric(obs1.street().next()) + )?; + Ok(Sinkhorn::from((hx, hy, metric)).minimize().cost()) + } + + // population lookups + pub async fn abs_population(&self, abs: Abstraction) -> Result { + let abs = i64::from(abs); + const SQL: &'static str = r#" + SELECT population + FROM abstraction + WHERE abs = $1 + "#; + Ok(self.0.query_one(SQL, &[&abs]).await?.get::<_, i32>(0) as usize) + } + pub async fn obs_population(&self, obs: Observation) -> Result { + let iso = i64::from(Isomorphism::from(obs)); + const SQL: &'static str = r#" + SELECT population + FROM abstraction + JOIN isomorphism ON isomorphism.abs = abstraction.abs + WHERE obs = $1 + "#; + Ok(self.0.query_one(SQL, &[&iso]).await?.get::<_, i64>(0) as usize) + } + + // centrality (mean distance) lookups + pub async fn abs_centrality(&self, abs: Abstraction) -> Result { + let abs = i64::from(abs); + const SQL: &'static str = r#" + SELECT centrality + FROM abstraction + WHERE abs = $1 + "#; + Ok(self + .0 + .query_one(SQL, &[&abs]) + .await? + .get::<_, f32>(0) + .into()) + } + pub async fn obs_centrality(&self, obs: Observation) -> Result { + let iso = i64::from(Isomorphism::from(obs)); + const SQL: &'static str = r#" + SELECT centrality + FROM abstraction + JOIN isomorphism ON isomorphism.abs = abstraction.abs + WHERE obs = $1 + "#; + Ok(self + .0 + .query_one(SQL, &[&iso]) + .await? + .get::<_, f32>(0) + .into()) + } + + // histogram aggregation via join + pub async fn abs_histogram(&self, abs: Abstraction) -> Result { + let idx = i64::from(abs); + let mass = abs.street().n_children() as f32; + const SQL: &'static str = r#" + SELECT next, dx + FROM transitions + WHERE prev = $1 + "#; + Ok(self + .0 + .query(SQL, &[&idx]) + .await? + .iter() + .map(|row| (row.get::<_, i64>(0), row.get::<_, Energy>(1))) + .map(|(next, dx)| (next, (dx * mass).round() as usize)) + .map(|(next, dx)| (Abstraction::from(next), dx)) + .fold(Histogram::default(), |mut h, (next, dx)| { + h.set(next, dx); + h + })) + } + pub async fn obs_histogram(&self, obs: Observation) -> Result { + // Kd8s~6dJsAc + let idx = i64::from(Isomorphism::from(obs)); + let mass = obs.street().n_children() as f32; + const SQL: &'static str = r#" + SELECT next, dx + FROM transitions + JOIN isomorphism ON isomorphism.abs = transitions.prev + WHERE isomorphism.obs = $1 + "#; + Ok(self + .0 + .query(SQL, &[&idx]) + .await? + .iter() + .map(|row| (row.get::<_, i64>(0), row.get::<_, Energy>(1))) + .map(|(next, dx)| (next, (dx * mass).round() as usize)) + .map(|(next, dx)| (Abstraction::from(next), dx)) + .fold(Histogram::default(), |mut h, (next, dx)| { + h.set(next, dx); + h + })) + } + + // observation similarity lookups + pub async fn obs_similar(&self, obs: Observation) -> Result, E> { + let iso = i64::from(Isomorphism::from(obs)); + const SQL: &'static str = r#" + WITH target AS ( + SELECT abs, population + FROM isomorphism e + JOIN abstraction a ON e.abs = a.abs + WHERE obs = $1 + ) + SELECT e.obs + FROM isomorphism e + JOIN target t ON e.abs = t.abs + WHERE e.obs != $1 + AND e.position < LEAST(5, t.population) -- Sample from available positions + AND e.position >= FLOOR(RANDOM() * GREATEST(t.population - 5, 1)) -- Random starting point + LIMIT 5; + "#; + Ok(self + .0 + .query(SQL, &[&iso]) + .await? + .iter() + .map(|row| row.get::<_, i64>(0)) + .map(Observation::from) + .collect()) + } + pub async fn abs_similar(&self, abs: Abstraction) -> Result, E> { + let abs = i64::from(abs); + const SQL: &'static str = r#" + WITH target AS ( + SELECT population FROM abstraction WHERE abs = $1 + ) + SELECT obs + FROM isomorphism e, target t + WHERE abs = $1 + AND position < LEAST(5, t.population) -- Sample from available positions + AND position >= FLOOR(RANDOM() * GREATEST(t.population - 5, 1)) -- Random starting point + LIMIT 5; + "#; + Ok(self + .0 + .query(SQL, &[&abs]) + .await? + .iter() + .map(|row| row.get::<_, i64>(0)) + .map(Observation::from) + .collect()) + } + pub async fn replace_obs(&self, obs: Observation) -> Result { + const SQL: &'static str = r#" + -- OBS SWAP + WITH sample AS ( + SELECT + e.abs, + a.population, + FLOOR(RANDOM() * a.population)::INTEGER as i + FROM isomorphism e + JOIN abstraction a ON e.abs = a.abs + WHERE e.obs = $1 + ) + SELECT e.obs + FROM sample t + JOIN isomorphism e ON e.abs = t.abs + AND e.position = t.i + LIMIT 1; + "#; + // + let iso = i64::from(Isomorphism::from(obs)); + // + let row = self.0.query_one(SQL, &[&iso]).await?; + Ok(Observation::from(row.get::<_, i64>(0))) + } + + // proximity lookups + pub async fn abs_nearby(&self, abs: Abstraction) -> Result, E> { + let abs = i64::from(abs); + const SQL: &'static str = r#" + SELECT a1.abs, m.dx + FROM abstraction a1 + JOIN abstraction a2 ON a1.street = a2.street + JOIN metric m ON (a1.abs # $1) = m.xor + WHERE + a2.abs = $1 AND + a1.abs != $1 + ORDER BY m.dx ASC + LIMIT 5; + "#; + Ok(self + .0 + .query(SQL, &[&abs]) + .await? + .iter() + .map(|row| (row.get::<_, i64>(0), row.get::<_, Energy>(1))) + .map(|(abs, distance)| (Abstraction::from(abs), distance)) + .collect()) + } + pub async fn obs_nearby(&self, obs: Observation) -> Result, E> { + let iso = i64::from(Isomorphism::from(obs)); + const SQL: &'static str = r#" + -- OBS NEARBY + SELECT a.abs, m.dx + FROM isomorphism e + JOIN abstraction a ON e.abs = a.abs + JOIN metric m ON (a.abs # e.abs) = m.xor + WHERE + e.obs = $1 AND + a.abs != e.abs + ORDER BY m.dx ASC + LIMIT 5; + "#; + Ok(self + .0 + .query(SQL, &[&iso]) + .await? + .iter() + .map(|row| (row.get::<_, i64>(0), row.get::<_, Energy>(1))) + .map(|(abs, distance)| (Abstraction::from(abs), distance)) + .collect()) + } +} + +// exploration panel +impl API { + pub async fn exp_wrt_str(&self, str: Street) -> Result { + self.exp_wrt_obs(Observation::from(str)).await + } + pub async fn exp_wrt_obs(&self, obs: Observation) -> Result { + const SQL: &'static str = r#" + -- EXP WRT OBS + SELECT + e.obs, + a.abs, + a.equity::REAL as equity, + a.population::REAL / $2 as density, + a.centrality::REAL as centrality + FROM isomorphism e + JOIN abstraction a ON e.abs = a.abs + WHERE e.obs = $1; + "#; + // + let n = obs.street().n_observations() as f32; + let iso = i64::from(Isomorphism::from(obs)); + // + let row = self.0.query_one(SQL, &[&iso, &n]).await?; + Ok(Sample::from(row)) + } + pub async fn exp_wrt_abs(&self, abs: Abstraction) -> Result { + const SQL: &'static str = r#" + -- EXP WRT ABS + WITH sample AS ( + SELECT + a.abs, + a.population, + a.equity, + a.centrality, + FLOOR(RANDOM() * a.population)::INTEGER as i + FROM abstraction a + WHERE a.abs = $1 + ) + SELECT + e.obs, + s.abs, + s.equity::REAL as equity, + s.population::REAL / $2 as density, + s.centrality::REAL as centrality + FROM sample s + JOIN isomorphism e ON e.abs = s.abs + AND e.position = s.i + LIMIT 1; + "#; + // + let n = abs.street().n_isomorphisms() as f32; + let abs = i64::from(abs); + // + let row = self.0.query_one(SQL, &[&abs, &n]).await?; + Ok(Sample::from(row)) + } +} + +// neighborhood lookups +impl API { + pub async fn nbr_any_wrt_abs(&self, wrt: Abstraction) -> Result { + // uniform over abstraction space + use rand::seq::SliceRandom; + let ref mut rng = rand::thread_rng(); + let abs = Abstraction::all(wrt.street()) + .into_iter() + .filter(|&x| x != wrt) + .collect::>() + .choose(rng) + .copied() + .unwrap(); + self.nbr_abs_wrt_abs(wrt, abs).await + } + pub async fn nbr_abs_wrt_abs(&self, wrt: Abstraction, abs: Abstraction) -> Result { + const SQL: &'static str = r#" + -- NBR ABS WRT ABS + WITH sample AS ( + SELECT + r.abs as abs, + r.population as population, + r.equity as equity, + FLOOR(RANDOM() * r.population)::INTEGER as i, + COALESCE(m.dx, 0) as distance + FROM abstraction r + LEFT JOIN metric m ON m.xor = ($1::BIGINT # $3::BIGINT) + WHERE r.abs = $1 + ), + random_isomorphism AS ( + SELECT e.obs, e.abs, s.equity, s.population, s.distance + FROM sample s + JOIN isomorphism e ON e.abs = s.abs AND e.position = s.i + WHERE e.abs = $1 + LIMIT 1 + ) + SELECT + obs, + abs, + equity::REAL as equity, + population::REAL / $2 as density, + distance::REAL as distance + FROM random_isomorphism; + "#; + // + let n = wrt.street().n_isomorphisms() as f32; + let abs = i64::from(abs); + let wrt = i64::from(wrt); + // + let row = self.0.query_one(SQL, &[&abs, &n, &wrt]).await?; + Ok(Sample::from(row)) + } + pub async fn nbr_obs_wrt_abs(&self, wrt: Abstraction, obs: Observation) -> Result { + const SQL: &'static str = r#" + -- NBR OBS WRT ABS + WITH given AS ( + SELECT + (obs), + (abs), + (abs # $3) as xor + FROM isomorphism + WHERE obs = $1 + ) + SELECT + g.obs, + g.abs, + a.equity::REAL as equity, + a.population::REAL / $2 as density, + COALESCE(m.dx, 0)::REAL as distance + FROM given g + JOIN metric m ON m.xor = g.xor + JOIN abstraction a ON a.abs = g.abs + LIMIT 1; + "#; + // + let n = wrt.street().n_isomorphisms() as f32; + let iso = i64::from(Isomorphism::from(obs)); + let wrt = i64::from(wrt); + // + let row = self.0.query_one(SQL, &[&iso, &n, &wrt]).await?; + Ok(Sample::from(row)) + } + + pub async fn kfn_wrt_abs(&self, wrt: Abstraction) -> Result, E> { + const SQL: &'static str = r#" + -- KNN WRT ABS + WITH nearest AS ( + SELECT + a.abs as abs, + a.population as population, + m.dx as distance, + FLOOR(RANDOM() * population)::INTEGER as sample + FROM abstraction a + JOIN metric m ON m.xor = (a.abs # $1) + WHERE a.street = $2 + AND a.abs != $1 + ORDER BY m.dx DESC + LIMIT 5 + ) + SELECT + e.obs, + n.abs, + a.equity::REAL as equity, + a.population::REAL / $3 as density, + n.distance::REAL as distance + FROM nearest n + JOIN abstraction a ON a.abs = n.abs + JOIN isomorphism e ON e.abs = n.abs + AND e.position = n.sample + ORDER BY n.distance DESC; + "#; + // + let n = wrt.street().n_isomorphisms() as f32; + let s = wrt.street() as i16; + let wrt = i64::from(wrt); + // + let rows = self.0.query(SQL, &[&wrt, &s, &n]).await?; + Ok(rows.into_iter().map(Sample::from).collect()) + } + pub async fn knn_wrt_abs(&self, wrt: Abstraction) -> Result, E> { + const SQL: &'static str = r#" + -- KNN WRT ABS + WITH nearest AS ( + SELECT + a.abs as abs, + a.population as population, + m.dx as distance, + FLOOR(RANDOM() * population)::INTEGER as sample + FROM abstraction a + JOIN metric m ON m.xor = (a.abs # $1) + WHERE a.street = $2 + AND a.abs != $1 + ORDER BY m.dx ASC + LIMIT 5 + ) + SELECT + e.obs, + n.abs, + a.equity::REAL as equity, + a.population::REAL / $3 as density, + n.distance::REAL as distance + FROM nearest n + JOIN abstraction a ON a.abs = n.abs + JOIN isomorphism e ON e.abs = n.abs + AND e.position = n.sample + ORDER BY n.distance ASC; + "#; + // + let n = wrt.street().n_isomorphisms() as f32; + let s = wrt.street() as i16; + let wrt = i64::from(wrt); + // + let rows = self.0.query(SQL, &[&wrt, &s, &n]).await?; + Ok(rows.into_iter().map(Sample::from).collect()) + } + pub async fn kgn_wrt_abs( + &self, + wrt: Abstraction, + nbr: Vec, + ) -> Result, E> { + const SQL: &'static str = r#" + -- KGN WRT ABS + WITH input(obs, ord) AS ( + SELECT unnest($3::BIGINT[]) AS obs, + generate_series(1, array_length($3,1)) AS ord + ) + SELECT + e.obs AS obs, + e.abs AS abs, + a.equity::REAL AS equity, + a.population::REAL / $1 AS density, + m.dx::REAL AS distance + FROM input i + JOIN isomorphism e ON e.obs = i.obs + JOIN abstraction a ON e.abs = a.abs + JOIN metric m ON m.xor = (a.abs # $2) + ORDER BY i.ord + LIMIT 5; + "#; + let isos = nbr + .into_iter() + .map(Isomorphism::from) + .map(i64::from) + .collect::>(); + let n = wrt.street().n_isomorphisms() as f32; + let wrt = i64::from(wrt); + // + let rows = self.0.query(SQL, &[&n, &wrt, &&isos]).await?; + Ok(rows.into_iter().map(Sample::from).collect()) + } +} + +// distribution lookups +impl API { + pub async fn hst_wrt_obs(&self, obs: Observation) -> Result, E> { + if obs.street() == Street::Rive { + self.hst_wrt_obs_on_river(obs).await + } else { + self.hst_wrt_obs_on_other(obs).await + } + } + pub async fn hst_wrt_abs(&self, abs: Abstraction) -> Result, E> { + if abs.street() == Street::Rive { + self.hst_wrt_abs_on_river(abs).await + } else { + self.hst_wrt_abs_on_other(abs).await + } + } + + async fn hst_wrt_obs_on_river(&self, obs: Observation) -> Result, E> { + const SQL: &'static str = r#" + -- RIVER OBS DISTRIBUTION + WITH sample AS ( + SELECT + e.obs, + e.abs, + a.equity, + a.population, + a.centrality, + FLOOR(RANDOM() * a.population)::INTEGER as position + FROM isomorphism e + JOIN abstraction a ON e.abs = a.abs + WHERE e.abs = (SELECT abs FROM isomorphism WHERE obs = $2) + LIMIT 5 + ) + SELECT + s.obs as obs, + s.abs as abs, + s.equity::REAL as equity, + s.population::REAL / $1 as density, + s.centrality::REAL as distance + FROM sample s; + "#; + let n = Street::Rive.n_isomorphisms() as f32; + let iso = i64::from(Isomorphism::from(obs)); + let rows = self.0.query(SQL, &[&n, &iso]).await?; + Ok(rows.into_iter().map(Sample::from).collect()) + } + + async fn hst_wrt_obs_on_other(&self, obs: Observation) -> Result, E> { + const SQL: &'static str = r#" + -- OTHER OBS DISTRIBUTION + SELECT + e.obs, e.abs, a.equity + FROM isomorphism e + JOIN abstraction a ON e.abs = a.abs + WHERE e.obs = ANY($1); + "#; + let n = obs.street().n_children(); + let children = obs + .children() + .map(Isomorphism::from) + .map(Observation::from) + .collect::>(); + let distinct = children + .iter() + .copied() + .map(i64::from) + .fold(HashSet::::new(), |mut set, x| { + set.insert(x); + set + }) + .into_iter() + .collect::>(); + let rows = self.0.query(SQL, &[&distinct]).await?; + let rows = rows + .into_iter() + .map(|row| { + ( + Observation::from(row.get::<_, i64>(0)), + Abstraction::from(row.get::<_, i64>(1)), + Probability::from(row.get::<_, f32>(2)), + ) + }) + .map(|(obs, abs, equity)| (obs, (abs, equity))) + .collect::>(); + let hist = children + .iter() + .map(|child| (child, rows.get(child).expect("lookup in db"))) + .fold(BTreeMap::<_, _>::new(), |mut btree, (obs, (abs, eqy))| { + btree.entry(abs).or_insert((obs, eqy, 0)).2 += 1; + btree + }) + .into_iter() + .map(|(abs, (obs, eqy, pop))| Sample { + obs: obs.equivalent(), + abs: abs.to_string(), + equity: eqy.clone(), + density: pop as Probability / n as Probability, + distance: 0., + }) + .collect::>(); + Ok(hist) + } + + async fn hst_wrt_abs_on_river(&self, abs: Abstraction) -> Result, E> { + const SQL: &'static str = r#" + -- RIVER ABS DISTRIBUTION + WITH sample AS ( + SELECT + a.abs, + a.population, + a.equity, + a.centrality, + FLOOR(RANDOM() * a.population)::INTEGER as position + FROM abstraction a + WHERE a.abs = $2 + LIMIT 5 + ) + SELECT + e.obs as obs, + e.abs as abs, + s.equity::REAL as equity, + s.population::REAL / $1 as density, + s.centrality::REAL as distance + FROM sample s + JOIN isomorphism e ON e.abs = s.abs + AND e.position = s.position; + "#; + // + let ref n = Street::Rive.n_isomorphisms() as f32; + let ref abs = i64::from(abs); + // + let rows = self.0.query(SQL, &[n, abs]).await?; + Ok(rows.into_iter().map(Sample::from).collect()) + } + async fn hst_wrt_abs_on_other(&self, abs: Abstraction) -> Result, E> { + const SQL: &'static str = r#" + -- OTHER ABS DISTRIBUTION + WITH histogram AS ( + SELECT + p.abs as abs, + g.dx as probability, + p.population as population, + p.equity as equity, + p.centrality as centrality, + FLOOR(RANDOM() * p.population)::INTEGER as i + FROM transitions g + JOIN abstraction p ON p.abs = g.next + WHERE g.prev = $1 + LIMIT 64 + ) + SELECT + e.obs as obs, + t.abs as abs, + t.equity::REAL as equity, + t.probability as density, + t.centrality::REAL as distance + FROM histogram t + JOIN isomorphism e ON e.abs = t.abs + AND e.position = t.i + ORDER BY t.probability DESC; + "#; + // + let ref abs = i64::from(abs); + // + let rows = self.0.query(SQL, &[abs]).await?; + Ok(rows.into_iter().map(Sample::from).collect()) + } +} + +use crate::analysis::response::Decision; +use crate::mccfr::bucket::Bucket; +use crate::mccfr::recall::Recall; + +// blueprint lookups +impl API { + pub async fn policy(&self, recall: Recall) -> Result, E> { + const SQL: &'static str = r#" + -- policy is indexed by present, past, future + -- and it returns a vector of decision probabilities + -- over the set of "choices" we can continue toward + + SELECT edge, policy + FROM blueprint + WHERE past = $1 + AND present = $2 + AND future = $3 + "#; + let game = recall.head(); + let observation = game.sweat(); + let abstraction = self.obs_to_abs(observation).await?; + let Bucket(history, present, choices) = recall.bucket(abstraction); + let ref history = i64::from(history); + let ref present = i64::from(present); + let ref choices = i64::from(choices); + let rows = self.0.query(SQL, &[history, present, choices]).await?; + Ok(rows.into_iter().map(Decision::from).collect()) + } +} diff --git a/src/analysis/cli.rs b/src/analysis/cli.rs new file mode 100644 index 00000000..7cce9ae1 --- /dev/null +++ b/src/analysis/cli.rs @@ -0,0 +1,180 @@ +use super::api::API; +use super::query::Query; +use crate::cards::hand::Hand; +use crate::cards::observation::Observation; +use crate::cards::strength::Strength; +use crate::clustering::abstraction::Abstraction; +use clap::Parser; +use std::io::Write; + +pub struct CLI(API); + +impl From for CLI { + fn from(api: API) -> Self { + Self(api) + } +} + +impl CLI { + pub async fn run() -> () { + log::info!("entering analysis"); + let cli = Self(API::from(crate::db().await)); + loop { + print!("> "); + let ref mut input = String::new(); + std::io::stdout().flush().unwrap(); + std::io::stdin().read_line(input).unwrap(); + match input.trim() { + "quit" => break, + "exit" => break, + _ => match cli.handle(input).await { + Err(e) => eprintln!("{}", e), + Ok(_) => continue, + }, + } + } + } + + async fn handle(&self, input: &str) -> Result<(), Box> { + match Query::try_parse_from(std::iter::once("> ").chain(input.split_whitespace()))? { + Query::Abstraction { target } => { + if let Ok(obs) = Observation::try_from(target.as_str()) { + return Ok(println!("{}", self.0.obs_to_abs(obs).await?)); + } + Err("invalid abstraction target".into()) + } + + Query::Distance { target1, target2 } => { + if let (Ok(o1), Ok(o2)) = ( + Observation::try_from(target1.as_str()), + Observation::try_from(target2.as_str()), + ) { + return Ok(println!("{:.4}", self.0.obs_distance(o1, o2).await?)); + } + if let (Ok(a1), Ok(a2)) = ( + Abstraction::try_from(target1.as_str()), + Abstraction::try_from(target2.as_str()), + ) { + return Ok(println!("{:.4}", self.0.abs_distance(a1, a2).await?)); + } + Err("invalid distance targets".into()) + } + + Query::Equity { target } => { + if let Ok(obs) = Observation::try_from(target.as_str()) { + return Ok(println!("{:.4}", self.0.obs_equity(obs).await?)); + } + if let Ok(abs) = Abstraction::try_from(target.as_str()) { + return Ok(println!("{:.4}", self.0.abs_equity(abs).await?)); + } + Err("invalid equity target".into()) + } + + Query::Population { target } => { + if let Ok(obs) = Observation::try_from(target.as_str()) { + return Ok(println!("{}", self.0.obs_population(obs).await?)); + } + if let Ok(abs) = Abstraction::try_from(target.as_str()) { + return Ok(println!("{}", self.0.abs_population(abs).await?)); + } + Err("invalid population target".into()) + } + + Query::Centrality { target } => { + if let Ok(obs) = Observation::try_from(target.as_str()) { + return Ok(println!("{:.4}", self.0.obs_centrality(obs).await?)); + } + if let Ok(abs) = Abstraction::try_from(target.as_str()) { + return Ok(println!("{:.4}", self.0.abs_centrality(abs).await?)); + } + Err("invalid centrality target".into()) + } + + Query::Similar { target } => { + if let Ok(obs) = Observation::try_from(target.as_str()) { + let members = self + .0 + .obs_similar(obs) + .await? + .iter() + .map(|obs| (obs, Strength::from(Hand::from(*obs)))) + .map(|(o, s)| format!(" - {:<18} {}", o, s)) + .collect::>() + .join("\n"); + return Ok(println!("{}", members)); + } + if let Ok(abs) = Abstraction::try_from(target.as_str()) { + let members = self + .0 + .abs_similar(abs) + .await? + .iter() + .map(|obs| (obs, Strength::from(Hand::from(*obs)))) + .map(|(o, s)| format!(" - {:<18} {}", o, s)) + .collect::>() + .join("\n"); + return Ok(println!("{}", members)); + } + Err("invalid similarity target".into()) + } + + Query::Nearby { target } => { + if let Ok(obs) = Observation::try_from(target.as_str()) { + let neighborhood = self + .0 + .obs_nearby(obs) + .await? + .iter() + .enumerate() + .map(|(i, (abs, dist))| format!("{:>2}. {} ({:.4})", i + 1, abs, dist)) + .collect::>() + .join("\n"); + return Ok(println!("{}", neighborhood)); + } + if let Ok(abs) = Abstraction::try_from(target.as_str()) { + let neighborhood = self + .0 + .abs_nearby(abs) + .await? + .iter() + .enumerate() + .map(|(i, (abs, dist))| format!("{:>2}. {} ({:.4})", i + 1, abs, dist)) + .collect::>() + .join("\n"); + return Ok(println!("{}", neighborhood)); + } + Err("invalid neighborhood target".into()) + } + + Query::Composition { target } => { + if let Ok(obs) = Observation::try_from(target.as_str()) { + let distribution = self + .0 + .obs_histogram(obs) + .await? + .distribution() + .iter() + .enumerate() + .map(|(i, (abs, dist))| format!("{:>2}. {} ({:.4})", i + 1, abs, dist)) + .collect::>() + .join("\n"); + return Ok(println!("{}", distribution)); + } + if let Ok(abs) = Abstraction::try_from(target.as_str()) { + let distribution = self + .0 + .abs_histogram(abs) + .await? + .distribution() + .iter() + .enumerate() + .map(|(i, (abs, dist))| format!("{:>2}. {} ({:.4})", i + 1, abs, dist)) + .collect::>() + .join("\n"); + return Ok(println!("{}", distribution)); + } + Err("invalid histogram target".into()) + } + } + } +} diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs new file mode 100644 index 00000000..9166c004 --- /dev/null +++ b/src/analysis/mod.rs @@ -0,0 +1,6 @@ +pub mod api; +pub mod cli; +pub mod query; +pub mod request; +pub mod response; +pub mod server; diff --git a/src/analysis/query.rs b/src/analysis/query.rs new file mode 100644 index 00000000..34d28f90 --- /dev/null +++ b/src/analysis/query.rs @@ -0,0 +1,79 @@ +use clap::Parser; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +pub enum Query { + #[command( + about = "Find the abstractions of any given observation", + alias = "abs" + )] + Abstraction { + #[arg(required = true)] + target: String, + }, + + #[command( + about = "Find the distance between two targets (obs~obs or abs~abs)", + alias = "dst" + )] + Distance { + #[arg(required = true)] + target1: String, + #[arg(required = true)] + target2: String, + }, + + #[command( + about = "Find observations belonging to the same cluster of any given observation or abstraction", + alias = "sim" + )] + Similar { + #[arg(required = true)] + target: String, + }, + + #[command( + about = "Find abstractions in the neighborhood of any given observation or abstraction", + alias = "nbr" + )] + Nearby { + #[arg(required = true)] + target: String, + }, + + #[command( + about = "Find the equity of any given observation or abstraction", + alias = "eqt" + )] + Equity { + #[arg(required = true)] + target: String, + }, + + #[command( + about = "Find the population of any given observation or abstraction", + alias = "pop" + )] + Population { + #[arg(required = true)] + target: String, + }, + + #[command( + about = "Find the centrality of any given observation or abstraction", + alias = "ctr" + )] + Centrality { + #[arg(required = true)] + target: String, + }, + + #[command( + about = "Find the histogram of any given observation or abstraction", + alias = "hst" + )] + Composition { + #[arg(required = true)] + target: String, + }, +} diff --git a/src/analysis/request.rs b/src/analysis/request.rs new file mode 100644 index 00000000..959714a2 --- /dev/null +++ b/src/analysis/request.rs @@ -0,0 +1,56 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct SetStreets { + pub street: String, +} + +#[derive(Deserialize)] +pub struct ReplaceObs { + pub obs: String, +} + +#[derive(Deserialize)] +pub struct RowWrtObs { + pub obs: String, +} + +#[derive(Deserialize)] +pub struct ReplaceAbs { + pub wrt: String, +} + +#[derive(Deserialize)] +pub struct ReplaceRow { + pub wrt: String, + pub obs: String, +} + +#[derive(Deserialize)] +pub struct ReplaceOne { + pub wrt: String, + pub abs: String, +} + +#[derive(Deserialize)] +pub struct ReplaceAll { + pub wrt: String, + pub neighbors: Vec, +} + +#[derive(Deserialize)] +pub struct ObsHist { + pub obs: String, +} + +#[derive(Deserialize)] +pub struct AbsHist { + pub abs: String, +} + +#[derive(Deserialize)] +pub struct GetPolicy { + pub hero: String, + pub seen: String, + pub path: Vec, +} diff --git a/src/analysis/response.rs b/src/analysis/response.rs new file mode 100644 index 00000000..c8e589b8 --- /dev/null +++ b/src/analysis/response.rs @@ -0,0 +1,41 @@ +use crate::cards::observation::Observation; +use crate::clustering::abstraction::Abstraction; +use crate::mccfr::edge::Edge; +use crate::Probability; +use serde::Serialize; + +#[derive(Serialize)] +pub struct Sample { + pub obs: String, + pub abs: String, + pub equity: f32, + pub density: f32, + pub distance: f32, +} + +#[derive(Serialize)] +pub struct Decision { + pub edge: String, + pub prob: Probability, +} + +impl From for Sample { + fn from(row: tokio_postgres::Row) -> Self { + Self { + obs: Observation::from(row.get::<_, i64>(0)).equivalent(), + abs: Abstraction::from(row.get::<_, i64>(1)).to_string(), + equity: row.get::<_, f32>(2).into(), + density: row.get::<_, f32>(3).into(), + distance: row.get::<_, f32>(4).into(), + } + } +} + +impl From for Decision { + fn from(row: tokio_postgres::Row) -> Self { + Self { + edge: Edge::from(row.get::<_, i64>("edge") as u64).to_string(), + prob: Probability::from(row.get::<_, f32>("policy")), + } + } +} diff --git a/src/analysis/server.rs b/src/analysis/server.rs new file mode 100644 index 00000000..999ebb9e --- /dev/null +++ b/src/analysis/server.rs @@ -0,0 +1,216 @@ +use super::api::API; +use super::request::AbsHist; +use super::request::GetPolicy; +use super::request::ObsHist; +use super::request::ReplaceAbs; +use super::request::ReplaceAll; +use super::request::ReplaceObs; +use super::request::ReplaceOne; +use super::request::ReplaceRow; +use super::request::RowWrtObs; +use super::request::SetStreets; +use crate::cards::observation::Observation; +use crate::cards::street::Street; +use crate::clustering::abstraction::Abstraction; +use crate::gameplay::action::Action; +use crate::gameplay::ply::Turn; +use crate::mccfr::recall::Recall; +use actix_cors::Cors; +use actix_web::middleware::Logger; +use actix_web::web; +use actix_web::App; +use actix_web::HttpResponse; +use actix_web::HttpServer; +use actix_web::Responder; + +pub struct Server; + +impl Server { + pub async fn run() -> Result<(), std::io::Error> { + let api = web::Data::new(API::from(crate::db().await)); + log::info!("starting HTTP server"); + HttpServer::new(move || { + App::new() + .wrap(Logger::new("%r %s %Ts")) + .wrap( + Cors::default() + .allow_any_origin() + .allow_any_method() + .allow_any_header(), + ) + .app_data(api.clone()) + .route("/replace-obs", web::post().to(replace_obs)) + .route("/nbr-any-abs", web::post().to(nbr_any_wrt_abs)) + .route("/nbr-obs-abs", web::post().to(nbr_obs_wrt_abs)) + .route("/nbr-abs-abs", web::post().to(nbr_abs_wrt_abs)) + .route("/nbr-kfn-abs", web::post().to(kfn_wrt_abs)) + .route("/nbr-knn-abs", web::post().to(knn_wrt_abs)) + .route("/nbr-kgn-abs", web::post().to(kgn_wrt_abs)) + .route("/exp-wrt-str", web::post().to(exp_wrt_str)) + .route("/exp-wrt-abs", web::post().to(exp_wrt_abs)) + .route("/exp-wrt-obs", web::post().to(exp_wrt_obs)) + .route("/hst-wrt-abs", web::post().to(hst_wrt_abs)) + .route("/hst-wrt-obs", web::post().to(hst_wrt_obs)) + .route("/policy-lookup", web::post().to(lookup_policy)) + }) + .workers(6) + .bind("127.0.0.1:8888")? + .run() + .await + } +} + +// Route handlers + +async fn replace_obs(api: web::Data, req: web::Json) -> impl Responder { + match Observation::try_from(req.obs.as_str()) { + Err(_) => HttpResponse::BadRequest().body("invalid observation format"), + Ok(obs) => match api.replace_obs(obs).await { + Err(e) => HttpResponse::InternalServerError().body(e.to_string()), + Ok(new) => HttpResponse::Ok().json(new.equivalent()), + }, + } +} + +async fn exp_wrt_str(api: web::Data, req: web::Json) -> impl Responder { + match Street::try_from(req.street.as_str()) { + Err(_) => HttpResponse::BadRequest().body("invalid street format"), + Ok(street) => match api.exp_wrt_str(street).await { + Err(e) => HttpResponse::InternalServerError().body(e.to_string()), + Ok(row) => HttpResponse::Ok().json(row), + }, + } +} +async fn exp_wrt_abs(api: web::Data, req: web::Json) -> impl Responder { + match Abstraction::try_from(req.wrt.as_str()) { + Err(_) => HttpResponse::BadRequest().body("invalid abstraction format"), + Ok(abs) => match api.exp_wrt_abs(abs).await { + Err(e) => HttpResponse::InternalServerError().body(e.to_string()), + Ok(row) => HttpResponse::Ok().json(row), + }, + } +} +async fn exp_wrt_obs(api: web::Data, req: web::Json) -> impl Responder { + match Observation::try_from(req.obs.as_str()) { + Err(_) => HttpResponse::BadRequest().body("invalid observation format"), + Ok(obs) => match api.exp_wrt_obs(obs).await { + Err(e) => HttpResponse::InternalServerError().body(e.to_string()), + Ok(row) => HttpResponse::Ok().json(row), + }, + } +} + +async fn nbr_any_wrt_abs(api: web::Data, req: web::Json) -> impl Responder { + match Abstraction::try_from(req.wrt.as_str()) { + Err(_) => HttpResponse::BadRequest().body("invalid abstraction format"), + Ok(abs) => match api.nbr_any_wrt_abs(abs).await { + Err(e) => HttpResponse::InternalServerError().body(e.to_string()), + Ok(row) => HttpResponse::Ok().json(row), + }, + } +} +async fn nbr_abs_wrt_abs(api: web::Data, req: web::Json) -> impl Responder { + match ( + Abstraction::try_from(req.wrt.as_str()), + Abstraction::try_from(req.abs.as_str()), + ) { + (Err(_), _) => HttpResponse::BadRequest().body("invalid abstraction format"), + (_, Err(_)) => HttpResponse::BadRequest().body("invalid abstraction format"), + (Ok(wrt), Ok(abs)) => match api.nbr_abs_wrt_abs(wrt, abs).await { + Err(e) => HttpResponse::InternalServerError().body(e.to_string()), + Ok(row) => HttpResponse::Ok().json(row), + }, + } +} +async fn nbr_obs_wrt_abs(api: web::Data, req: web::Json) -> impl Responder { + match ( + Abstraction::try_from(req.wrt.as_str()), + Observation::try_from(req.obs.as_str()), + ) { + (Err(_), _) => HttpResponse::BadRequest().body("invalid abstraction format"), + (_, Err(_)) => HttpResponse::BadRequest().body("invalid observation format"), + (Ok(abs), Ok(obs)) => match api.nbr_obs_wrt_abs(abs, obs).await { + Err(e) => HttpResponse::InternalServerError().body(e.to_string()), + Ok(rows) => HttpResponse::Ok().json(rows), + }, + } +} + +async fn kfn_wrt_abs(api: web::Data, req: web::Json) -> impl Responder { + match Abstraction::try_from(req.wrt.as_str()) { + Err(_) => HttpResponse::BadRequest().body("invalid abstraction format"), + Ok(abs) => match api.kfn_wrt_abs(abs).await { + Err(e) => HttpResponse::InternalServerError().body(e.to_string()), + Ok(rows) => HttpResponse::Ok().json(rows), + }, + } +} +async fn knn_wrt_abs(api: web::Data, req: web::Json) -> impl Responder { + match Abstraction::try_from(req.wrt.as_str()) { + Err(_) => HttpResponse::BadRequest().body("invalid abstraction format"), + Ok(abs) => match api.knn_wrt_abs(abs).await { + Err(e) => HttpResponse::InternalServerError().body(e.to_string()), + Ok(rows) => HttpResponse::Ok().json(rows), + }, + } +} +async fn kgn_wrt_abs(api: web::Data, req: web::Json) -> impl Responder { + match Abstraction::try_from(req.wrt.as_str()) { + Err(_) => HttpResponse::BadRequest().body("invalid abstraction format"), + Ok(wrt) => { + let obs = req + .neighbors + .iter() + .map(|string| string.as_str()) + .map(Observation::try_from) + .filter_map(|result| result.ok()) + .filter(|o| o.street() == wrt.street()) + .chain((0..).map(|_| Observation::from(wrt.street()))) + .take(5) + .collect::>(); + match api.kgn_wrt_abs(wrt, obs).await { + Err(e) => HttpResponse::InternalServerError().body(e.to_string()), + Ok(rows) => HttpResponse::Ok().json(rows), + } + } + } +} + +async fn hst_wrt_abs(api: web::Data, req: web::Json) -> impl Responder { + match Abstraction::try_from(req.abs.as_str()) { + Err(_) => HttpResponse::BadRequest().body("invalid abstraction format"), + Ok(abs) => match api.hst_wrt_abs(abs).await { + Err(e) => HttpResponse::InternalServerError().body(e.to_string()), + Ok(rows) => HttpResponse::Ok().json(rows), + }, + } +} + +async fn hst_wrt_obs(api: web::Data, req: web::Json) -> impl Responder { + match Observation::try_from(req.obs.as_str()) { + Err(_) => HttpResponse::BadRequest().body("invalid observation format"), + Ok(obs) => match api.hst_wrt_obs(obs).await { + Err(e) => HttpResponse::InternalServerError().body(e.to_string()), + Ok(rows) => HttpResponse::Ok().json(rows), + }, + } +} + +async fn lookup_policy(api: web::Data, req: web::Json) -> impl Responder { + let hero = Turn::try_from(req.hero.as_str()); + let seen = Observation::try_from(req.seen.as_str()); + let path = req + .path + .iter() + .map(|s| Action::try_from(s.as_str())) + .collect::, _>>(); + match (hero, seen, path) { + (Ok(hero), Ok(seen), Ok(path)) => { + match api.policy(Recall::from((hero, seen, path))).await { + Err(e) => HttpResponse::InternalServerError().body(e.to_string()), + Ok(rows) => HttpResponse::Ok().json(rows), + } + } + _ => HttpResponse::BadRequest().body("invalid recall format"), + } +} diff --git a/src/cards/board.rs b/src/cards/board.rs index 0e916954..c2f16f6c 100644 --- a/src/cards/board.rs +++ b/src/cards/board.rs @@ -1,51 +1,55 @@ -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Street { - Pre, - Flop, - Turn, - River, - Showdown, -} - -impl Display for Street { - fn fmt(&self, f: &mut Formatter) -> Result { - match self { - Street::Pre => write!(f, "Pre Flop"), - Street::Flop => write!(f, "Flop"), - Street::Turn => write!(f, "Turn"), - Street::River => write!(f, "River"), - Street::Showdown => write!(f, "Showdown"), - } - } -} +use super::card::Card; +use super::hand::Hand; +use super::street::Street; -#[derive(Debug, Clone)] -pub struct Board { - pub cards: Vec, // presize - pub street: Street, -} +#[derive(Debug, Clone, Copy)] +pub struct Board(Hand); impl Board { - pub fn new() -> Board { - Board { - cards: Vec::with_capacity(5), - street: Street::Pre, - } + /// create an empty board + pub fn empty() -> Self { + Self(Hand::empty()) } - - pub fn push(&mut self, card: Card) { - self.cards.push(card); + /// add a card to the board + pub fn add(&mut self, hand: Hand) { + self.0 = Hand::add(self.0, hand); + } + /// clear the board + pub fn clear(&mut self) { + self.0 = Hand::empty(); + } + /// what street is this board on? + pub fn street(&self) -> Street { + Street::from(self.0.size()) } } -impl Display for Board { - fn fmt(&self, f: &mut Formatter) -> Result { - for card in &self.cards { - write!(f, "{} ", card)?; - } - Ok(()) +/// Board isomorphism +/// Board -> Hand is infallible +/// Hand -> Board should select at 0, 3, 4, 5 cards +impl From for Board { + fn from(hand: Hand) -> Self { + Self(hand) + } +} +impl From for Hand { + fn from(board: Board) -> Self { + assert!(board.0.size() != 1); + assert!(board.0.size() != 2); + assert!(board.0.size() <= 5); + board.0 + } +} +impl std::fmt::Display for Board { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + f, + "{}", + Vec::::from(self.0) + .into_iter() + .map(|c| format!("{}", c)) + .collect::>() + .join(" ") + ) } } - -use super::card::Card; -use std::fmt::{Display, Formatter, Result}; diff --git a/src/cards/card.rs b/src/cards/card.rs index 292b1352..e5225694 100644 --- a/src/cards/card.rs +++ b/src/cards/card.rs @@ -1,15 +1,24 @@ -#[derive(Debug, Clone, Copy)] -pub struct Card { - rank: Rank, - suit: Suit, -} +use super::rank::Rank; +use super::suit::Suit; + +/// Card represents a playing card +/// it is a tuple of Rank and Suit +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub struct Card(u8); impl Card { pub fn rank(&self) -> Rank { - self.rank + Rank::from(self.0 / 4) } pub fn suit(&self) -> Suit { - self.suit + Suit::from(self.0 % 4) + } +} + +/// (Rank, Suit) isomorphism +impl From<(Rank, Suit)> for Card { + fn from((r, s): (Rank, Suit)) -> Self { + Self(u8::from(r) * 4 + u8::from(s)) } } @@ -20,39 +29,37 @@ impl Card { /// 0b00100111 impl From for u8 { fn from(c: Card) -> u8 { - u8::from(c.suit) + u8::from(c.rank) * 4 + c.0 } } impl From for Card { fn from(n: u8) -> Self { - Self { - rank: Rank::from(n / 4), - suit: Suit::from(n % 4), - } + Self(n) } } /// u32 isomorphism -/// a Card is bitwise OR. Suit and Rank are bitmasks of the 17 LSBs +/// a Card is bitwise OR. Suit and Rank are bitmasks of the 17 LSBs (so close to u16, alas) /// Ts /// xxxxxxxxxxxxxxx cdhs AKQJT98765432 /// 000000000000000 0010 0000100000000 impl From for u32 { fn from(c: Card) -> u32 { - u32::from(c.suit) | u32::from(c.rank) + let suit = (1 << 13) << u8::from(c.suit()); + let rank = u16::from(c.rank()) as u32; + rank | suit } } impl From for Card { fn from(n: u32) -> Self { - Self { - rank: Rank::from(n), - suit: Suit::from(n), - } + let rank = Rank::from(n as u16); + let suit = Suit::from((n >> 13).trailing_zeros() as u8); + Self::from((rank, suit)) } } -/// u64 isomorphism -/// each card is just one bit turned on +/// u64 representation +/// each card is just one bit turned on. this is a one-way morphism /// Ts /// xxxxxxxxxxxx 0000000000001000000000000000000000000000000000000000 impl From for u64 { @@ -60,20 +67,50 @@ impl From for u64 { 1 << u8::from(c) } } -impl From for Card { - fn from(n: u64) -> Self { - Self { - rank: Rank::from((n.trailing_zeros() / 4) as u8), - suit: Suit::from((n.trailing_zeros() % 4) as u8), - } + +impl std::fmt::Display for Card { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}{}", self.rank(), self.suit()) } } -impl Display for Card { - fn fmt(&self, f: &mut Formatter) -> Result { - write!(f, "{}{}", self.rank, self.suit) +/// str isomorphism +impl TryFrom<&str> for Card { + type Error = String; + fn try_from(s: &str) -> Result { + match s.trim().len() { + 2 => { + let rank = Rank::try_from(&s.trim()[0..1])?; + let suit = Suit::try_from(&s.trim()[1..2])?; + Ok(Card::from((rank, suit))) + } + _ => Err("2 characters".into()), + } } } -use super::{rank::Rank, suit::Suit}; -use std::fmt::{Display, Formatter, Result}; +#[cfg(test)] +mod tests { + use super::*; + use crate::cards::deck::Deck; + + #[test] + fn bijective_rank_suit() { + let card = Deck::new().draw(); + let suit = card.suit(); + let rank = card.rank(); + assert!(card == Card::from((rank, suit))); + } + + #[test] + fn bijective_u8() { + let card = Deck::new().draw(); + assert!(card == Card::from(u8::from(card))); + } + + #[test] + fn bijective_u32() { + let card = Deck::new().draw(); + assert!(card == Card::from(u32::from(card))); + } +} diff --git a/src/cards/deck.rs b/src/cards/deck.rs index a6a75ee2..fefc2b81 100644 --- a/src/cards/deck.rs +++ b/src/cards/deck.rs @@ -1,47 +1,64 @@ -#[derive(Debug, Clone)] -pub struct Deck { - cards: Vec, // presize -} +use super::card::Card; +use super::hand::Hand; +use super::hole::Hole; +use super::street::Street; +use rand::Rng; +/// Deck extends much of Hand functionality, with ability to remove cards from itself. Random selection via ::draw(), or sequential via ::flip(). +#[derive(Debug, Clone, Copy)] +pub struct Deck(Hand); impl Deck { pub fn new() -> Self { - let mut this = Self { - cards: (0u8..52).map(|n| Card::from(n)).collect(), - }; - this.shuffle(); - this + Self(Hand::from(Hand::mask())) + } + + pub fn contains(&self, card: &Card) -> bool { + self.0.contains(card) + } + + /// remove a random card from the deck. + /// different from Hand::draw() since that removes + /// highest card deterministically + pub fn draw(&mut self) -> Card { + let ref mut rng = rand::thread_rng(); + let n = self.0.size(); + let i = rng.gen_range(0..n as u8); + let mut ones = 0u8; + let mut deck = u64::from(self.0); + let mut card = u64::from(self.0).trailing_zeros() as u8; + while ones < i { + card = deck.trailing_zeros() as u8; + deck = deck & (deck - 1); + ones = ones + 1; + } + let card = Card::from(card); + self.0.remove(card); + card } - pub fn draw(&mut self) -> Option { - self.cards.pop() + /// only needed for Flop, but the creation of a Hand is well-generalized + pub fn deal(&mut self, street: Street) -> Hand { + (0..street.n_revealed()) + .map(|_| self.draw()) + .fold(Hand::empty(), |h, c| Hand::add(h, Hand::from(c))) } - fn shuffle(&mut self) { - self.cards.shuffle(&mut thread_rng()); + /// remove two cards from the deck + /// to deal as a Hole + pub fn hole(&mut self) -> Hole { + let a = self.draw(); + let b = self.draw(); + Hole::from((a, b)) } } -// u64 isomorphism -impl From for Deck { - fn from(n: u64) -> Self { - Self { - cards: (0u8..52) - .filter(|i| (1 << i) & n != 0) - .map(|i| Card::from(i)) - .collect(), - } +impl From for Hand { + fn from(deck: Deck) -> Self { + deck.0 } } -impl From for u64 { - #[rustfmt::skip] - fn from(deck: Deck) -> u64 { - deck.cards - .into_iter() - .map(|c| u64::from(c)) - .fold(0 , | a , b | a | b ) +impl From for Deck { + fn from(hand: Hand) -> Self { + Self(hand) } } - -use super::card::Card; -use rand::seq::SliceRandom; -use rand::thread_rng; diff --git a/src/cards/evaluator.rs b/src/cards/evaluator.rs new file mode 100644 index 00000000..252aae08 --- /dev/null +++ b/src/cards/evaluator.rs @@ -0,0 +1,365 @@ +use super::hand::Hand; +use super::kicks::Kickers; +use super::rank::Rank; +use super::ranking::Ranking; +use super::suit::Suit; + +#[cfg(not(feature = "shortdeck"))] +const WHEEL: u16 = 0b_1000000001111; +#[cfg(not(feature = "shortdeck"))] +const LOWEST_STRAIGHT_RANK: Rank = Rank::Five; + +#[cfg(feature = "shortdeck")] +const LOWEST_STRAIGHT_RANK: Rank = Rank::Nine; +#[cfg(feature = "shortdeck")] +const WHEEL: u16 = 0b_1000011110000; + +/// A lazy evaluator for a hand's strength. +/// +/// Using a compact representation of the Hand, we search for +/// the highest Value hand using bitwise operations. I should +/// benchmark this and compare to a massive HashMap lookup implementation. +pub struct Evaluator(Hand); +impl From for Evaluator { + fn from(h: Hand) -> Self { + Self(h) + } +} + +impl Evaluator { + pub fn find_ranking(&self) -> Ranking { + None.or_else(|| self.find_flush()) + .or_else(|| self.find_4_oak()) + .or_else(|| self.find_3_oak_2_oak()) + .or_else(|| self.find_straight()) + .or_else(|| self.find_3_oak()) + .or_else(|| self.find_2_oak_2_oak()) + .or_else(|| self.find_2_oak()) + .or_else(|| self.find_1_oak()) + .expect("at least one card in Hand") + } + pub fn find_kickers(&self, value: Ranking) -> Kickers { + match value.n_kickers() { + 0 => Kickers::from(0), + n => { + let hand = u16::from(self.0); + let mask = match value { + Ranking::HighCard(hi) + | Ranking::OnePair(hi) + | Ranking::FourOAK(hi) + | Ranking::ThreeOAK(hi) => !(u16::from(hi)), + Ranking::TwoPair(hi, lo) => !(u16::from(hi) | u16::from(lo)), + _ => unreachable!(), + }; + let mut rank = hand & mask; + while n < rank.count_ones() as usize { + let last = rank.trailing_zeros(); + let flip = 1 << last; + let skip = !flip; + rank = rank & skip; + } + Kickers::from(rank) + } + } + } + + /// + + fn find_1_oak(&self) -> Option { + self.find_rank_of_n_oak(1).map(Ranking::HighCard) + } + fn find_2_oak(&self) -> Option { + self.find_rank_of_n_oak(2).map(Ranking::OnePair) // unreachable + } + fn find_3_oak(&self) -> Option { + self.find_rank_of_n_oak(3).map(Ranking::ThreeOAK) + } + fn find_4_oak(&self) -> Option { + self.find_rank_of_n_oak(4).map(Ranking::FourOAK) + } + fn find_2_oak_2_oak(&self) -> Option { + self.find_rank_of_n_oak(2).and_then(|hi| { + self.find_rank_of_n_oak_skip(2, Some(hi)) + .map(|lo| Ranking::TwoPair(hi, lo)) + .or_else(|| Some(Ranking::OnePair(hi))) // this makes OnePair unreachable + }) + } + fn find_3_oak_2_oak(&self) -> Option { + self.find_rank_of_n_oak(3).and_then(|triple| { + self.find_rank_of_n_oak_skip(2, Some(triple)) + .map(|paired| Ranking::FullHouse(triple, paired)) + }) + } + fn find_straight(&self) -> Option { + self.find_rank_of_straight(self.0).map(Ranking::Straight) + } + fn find_flush(&self) -> Option { + self.find_suit_of_flush().and_then(|suit| { + self.find_rank_of_straight_flush(suit) + .map(Ranking::StraightFlush) + .or_else(|| { + let bits = u16::from(self.0.of(&suit)); + let rank = Rank::from(bits); + Some(Ranking::Flush(rank)) + }) + }) + } + + fn find_rank_of_straight(&self, hand: Hand) -> Option { + let wheel = WHEEL; + let ranks = u16::from(hand); + let mut bits = ranks; + bits &= bits << 1; + bits &= bits << 1; + bits &= bits << 1; + bits &= bits << 1; + if bits > 0 { + Some(Rank::from(bits)) + } else if wheel == (wheel & ranks) { + Some(LOWEST_STRAIGHT_RANK) + } else { + None + } + } + fn find_rank_of_straight_flush(&self, suit: Suit) -> Option { + let hand = self.0.of(&suit); + self.find_rank_of_straight(hand) + } + fn find_suit_of_flush(&self) -> Option { + Suit::all() + .map(|s| u64::from(s)) + .map(|u| u64::from(self.0) & u) + .map(|n| n.count_ones() as u8) + .iter() + .position(|&n| n >= 5) + .map(|i| Suit::from(i as u8)) + } + fn find_rank_of_n_oak(&self, n: usize) -> Option { + self.find_rank_of_n_oak_skip(n, None) + } + fn find_rank_of_n_oak_skip(&self, n: usize, skip: Option) -> Option { + let mut high = u64::from(Rank::Ace) << 4; + while high > 0 { + high >>= 4; + if let Some(skip) = skip { + let skip = u64::from(skip); + let skip = high & skip; + let skip = skip != 0; + if skip { + continue; + } + } + let mine = u64::from(self.0); + let mine = high & mine; + let mine = mine.count_ones() >= n as u32; + if mine { + return Some(Rank::lo(high)); + } + } + None + } +} + +#[cfg(test)] +#[cfg(not(feature = "shortdeck"))] +mod tests { + use super::*; + use crate::cards::hand::Hand; + + #[rustfmt::skip] + #[test] + fn high_card() { + let eval = Evaluator::from(Hand::try_from("As Kh Qd Jc 9s").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::HighCard(Rank::Ace)); + assert_eq!(kickers, Kickers::from(vec![Rank::King, Rank::Queen, Rank::Jack, Rank::Nine])); + } + + #[rustfmt::skip] + #[test] + fn one_pair() { + let eval = Evaluator::from(Hand::try_from("As Ah Kd Qc Js").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::OnePair(Rank::Ace)); + assert_eq!(kickers, Kickers::from(vec![Rank::King, Rank::Queen, Rank::Jack])); + } + + #[test] + fn two_pair() { + let eval = Evaluator::from(Hand::try_from("As Ah Kd Kc Qs").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::TwoPair(Rank::Ace, Rank::King)); + assert_eq!(kickers, Kickers::from(vec![Rank::Queen])); + } + + #[test] + fn three_oak() { + let eval = Evaluator::from(Hand::try_from("As Ah Ad Kc Qs").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::ThreeOAK(Rank::Ace)); + assert_eq!(kickers, Kickers::from(vec![Rank::King, Rank::Queen])); + } + + #[test] + fn straight() { + let eval = Evaluator::from(Hand::try_from("Ts Jh Qd Kc As").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::Straight(Rank::Ace)); + assert_eq!(kickers, Kickers::from(vec![])); + } + + #[test] + fn flush() { + let eval = Evaluator::from(Hand::try_from("As Ks Qs Js 9s").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::Flush(Rank::Ace)); + assert_eq!(kickers, Kickers::from(vec![])); + } + + #[test] + fn full_house() { + let eval = Evaluator::from(Hand::try_from("2s 2h 2d 3c 3s").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::FullHouse(Rank::Two, Rank::Three)); + assert_eq!(kickers, Kickers::from(vec![])); + } + + #[test] + fn four_oak() { + let eval = Evaluator::from(Hand::try_from("As Ah Ad Ac Ks").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::FourOAK(Rank::Ace)); + assert_eq!(kickers, Kickers::from(vec![Rank::King])); + } + + #[test] + fn straight_flush() { + let eval = Evaluator::from(Hand::try_from("Ts Js Qs Ks As").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::StraightFlush(Rank::Ace)); + assert_eq!(kickers, Kickers::from(vec![])); + } + + #[test] + fn wheel_straight() { + let eval = Evaluator::from(Hand::try_from("As 2h 3d 4c 5s").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::Straight(Rank::Five)); + assert_eq!(kickers, Kickers::from(vec![])); + } + + #[test] + fn wheel_straight_flush() { + let eval = Evaluator::from(Hand::try_from("As 2s 3s 4s 5s").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::StraightFlush(Rank::Five)); + assert_eq!(kickers, Kickers::from(vec![])); + } + + #[test] + fn seven_card_hand() { + let eval = Evaluator::from(Hand::try_from("As Ah Kd Kc Qs Jh 9d").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::TwoPair(Rank::Ace, Rank::King)); + assert_eq!(kickers, Kickers::from(vec![Rank::Queen])); + } + + #[test] + fn flush_over_straight() { + let eval = Evaluator::from(Hand::try_from("4h 6h 7h 8h 9h Ts").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::Flush(Rank::Nine)); + assert_eq!(kickers, Kickers::from(vec![])); + } + + #[test] + fn full_house_over_flush() { + let eval = Evaluator::from(Hand::try_from("Kh Ah Ad As Ks Qs Js").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::FullHouse(Rank::Ace, Rank::King)); + assert_eq!(kickers, Kickers::from(vec![])); + } + + #[test] + fn four_oak_over_full_house() { + let eval = Evaluator::from(Hand::try_from("As Ah Ad Ac Ks Kh Qd").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::FourOAK(Rank::Ace)); + assert_eq!(kickers, Kickers::from(vec![Rank::King])); + } + + #[test] + fn straight_flush_over_four_oak() { + let eval = Evaluator::from(Hand::try_from("Ts Js Qs Ks As Ah Ad Ac").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::StraightFlush(Rank::Ace)); + assert_eq!(kickers, Kickers::from(vec![])); + } + + #[test] + fn low_straight() { + let eval = Evaluator::from(Hand::try_from("As 2s 3h 4d 5c 6s").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::Straight(Rank::Six)); + assert_eq!(kickers, Kickers::from(vec![])); + } + + #[test] + fn three_pair() { + let eval = Evaluator::from(Hand::try_from("As Ah Kd Kc Qs Qh Jd").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::TwoPair(Rank::Ace, Rank::King)); + assert_eq!(kickers, Kickers::from(vec![Rank::Queen])); + } + + #[test] + fn two_three_oak() { + let eval = Evaluator::from(Hand::try_from("As Ah Ad Kc Ks Kh Qd").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::FullHouse(Rank::Ace, Rank::King)); + assert_eq!(kickers, Kickers::from(vec![])); + } +} + +#[cfg(test)] +#[cfg(feature = "shortdeck")] +mod tests { + use super::*; + use crate::cards::hand::Hand; + #[test] + fn shortdeck_wheel_straight() { + let eval = Evaluator::from(Hand::try_from("6s 7h 8d 9c As").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::Straight(Rank::Nine)); + assert_eq!(kickers, Kickers::from(vec![])); + } + + #[test] + fn wheel_straight_flush() { + let eval = Evaluator::from(Hand::try_from("As 6s 7s 8s 9s").unwrap()); + let ranking = eval.find_ranking(); + let kickers = eval.find_kickers(ranking); + assert_eq!(ranking, Ranking::StraightFlush(Rank::Nine)); + assert_eq!(kickers, Kickers::from(vec![])); + } +} diff --git a/src/cards/hand.rs b/src/cards/hand.rs new file mode 100644 index 00000000..0f6baa07 --- /dev/null +++ b/src/cards/hand.rs @@ -0,0 +1,227 @@ +use super::card::Card; +use super::rank::Rank; +use super::suit::Suit; +use crate::Arbitrary; + +/// Hand represents an unordered set of Cards. only in the limit, it is more memory efficient than Vec, ... but also, an advantage even for small N is that we avoid heap allocation. nice to use a single word for the full Hand independent of size stored as a u64, but only needs LSB bitstring of 52 bits. Each bit represents a unique card in the (unordered) set. +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub struct Hand(u64); + +impl Hand { + pub fn empty() -> Self { + Self(0) + } + + pub fn add(lhs: Self, rhs: Self) -> Self { + assert!((lhs.0 & rhs.0) == 0); + Self(lhs.0 | rhs.0) + } + + pub fn complement(&self) -> Self { + Self(self.0 ^ Self::mask()) + } + pub fn size(&self) -> usize { + self.0.count_ones() as usize + } + pub fn of(&self, suit: &Suit) -> Hand { + let this = u64::from(*self); + let mask = u64::from(*suit); + Self::from(this & mask) + } + pub fn min_rank(&self) -> Option { + match self.size() { + 0 => None, + _ => Some(Rank::lo(self.0)), + } + } + pub fn max_rank(&self) -> Option { + match self.size() { + 0 => None, + _ => Some(Rank::hi(self.0)), + } + } + pub fn remove(&mut self, card: Card) { + let card = u8::from(card); + let mask = !(1 << card); + self.0 &= mask; + } + + pub fn contains(&self, card: &Card) -> bool { + self.0 & (1 << u8::from(*card)) != 0 + } + + #[cfg(not(feature = "shortdeck"))] + pub const fn mask() -> u64 { + 0x000FFFFFFFFFFFFF + } + #[cfg(feature = "shortdeck")] + pub const fn mask() -> u64 { + 0x000FFFFFFFFF0000 + } +} + +/// we can empty a hand from high to low +/// by removing the highest card until the hand is empty +impl Iterator for Hand { + type Item = Card; + fn next(&mut self) -> Option { + match self.size() { + 0 => None, + _ => { + let card = self.0.trailing_zeros() as u8; + let card = Card::from(card); + self.remove(card); + Some(card) + } + } + } +} + +/// u64 isomorphism +/// we SUM/OR the cards to get the bitstring +/// [2c, Ts, Jc, Js] +/// xxxxxxxxxxxx 0000000010011000000000000000000000000000000000000001 +impl From for Hand { + fn from(n: u64) -> Self { + Self(n & Self::mask()) + } +} +impl From for u64 { + fn from(h: Hand) -> Self { + h.0 + } +} + +/// Vec isomorphism (up to Vec permutation, this always comes out sorted) +/// we SUM/OR the cards to get the bitstring +/// [2c, Ts, Jc, Js] +/// xxxxxxxxxxxx 0000000010011000000000000000000000000000000000000001 +impl From for Vec { + fn from(h: Hand) -> Self { + let mut value = h.0; + let mut index = 0u8; + let mut cards = Vec::new(); + while value > 0 { + if value & 1 == 1 { + cards.push(Card::from(index)); + } + value = value >> 1; + index = index + 1; + } + cards + } +} +impl From> for Hand { + fn from(cards: Vec) -> Self { + Self( + cards + .into_iter() + .map(|c| u64::from(c)) + .fold(0u64, |a, b| a | b), + ) + } +} + +/// one-way conversion to u16 Rank masks +/// zero-allocation, zero iteration. just shredding bits +impl From for u16 { + fn from(h: Hand) -> Self { + let mut x = u64::from(h); + x |= x >> 1; + x |= x >> 2; + x &= 0x1111111111111; + let mut y = u64::default(); + y |= (x >> 00) & 0x0001; + y |= (x >> 03) & 0x0002; + y |= (x >> 06) & 0x0004; + y |= (x >> 09) & 0x0008; + y |= (x >> 12) & 0x0010; + y |= (x >> 15) & 0x0020; + y |= (x >> 18) & 0x0040; + y |= (x >> 21) & 0x0080; + y |= (x >> 24) & 0x0100; + y |= (x >> 27) & 0x0200; + y |= (x >> 30) & 0x0400; + y |= (x >> 33) & 0x0800; + y |= (x >> 36) & 0x1000; + y as u16 + } +} + +/// one-way conversion from Card +impl From for Hand { + fn from(card: Card) -> Self { + Self(1u64 << u8::from(card)) + } +} + +/// str isomorphism +/// this follows from Vec isomorphism +impl TryFrom<&str> for Hand { + type Error = String; + fn try_from(s: &str) -> Result { + Ok(s.split_whitespace() + .map(|token| { + token + .chars() + .collect::>() + .chunks(2) + .map(|chunk| chunk.iter().collect::()) + .map(|owned| Card::try_from(owned.as_str())) + .collect::, _>>() + }) + .flatten() + .flatten() + .collect::>() + .into()) + } +} + +impl std::fmt::Display for Hand { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + for card in Vec::::from(*self) { + write!(f, "{}", card)?; + } + Ok(()) + } +} + +impl Arbitrary for Hand { + fn random() -> Self { + let ref mut rng = rand::thread_rng(); + let cards = rand::Rng::gen::(rng); + let cards = cards & Self::mask(); + Self(cards) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bijective_u64() { + let hand = Hand::random(); + assert_eq!(hand, Hand::from(u64::from(hand))); + } + + #[test] + fn card_iteration() { + let mut iter = Hand::try_from("Jc Ts 2c Js").unwrap().into_iter(); + assert_eq!(iter.next(), Some(Card::try_from("2c").unwrap())); + assert_eq!(iter.next(), Some(Card::try_from("Ts").unwrap())); + assert_eq!(iter.next(), Some(Card::try_from("Jc").unwrap())); + assert_eq!(iter.next(), Some(Card::try_from("Js").unwrap())); + assert_eq!(iter.next(), None); + } + + #[test] + #[cfg(not(feature = "shortdeck"))] + fn ranks_in_suit() { + let hand = Hand::try_from("2c 3d 4h 5s 6c 7d 8h 9s Tc Jd Qh Ks Ac").unwrap(); + assert_eq!(u16::from(hand.of(&Suit::C)), 0b000_1000100010001); // C (2c, 6c, Tc, Ac) + assert_eq!(u16::from(hand.of(&Suit::D)), 0b000_0001000100010); // D (3d, 7d, Jd) + assert_eq!(u16::from(hand.of(&Suit::H)), 0b000_0010001000100); // H (4h, 8h, Qh) + assert_eq!(u16::from(hand.of(&Suit::S)), 0b000_0100010001000); // S (5s, 9s, Ks) + } +} diff --git a/src/cards/hands.rs b/src/cards/hands.rs new file mode 100644 index 00000000..ace859e0 --- /dev/null +++ b/src/cards/hands.rs @@ -0,0 +1,195 @@ +use super::hand::Hand; + +/// HandIterator allows you to block certain cards and iterate over all possible hands of length n +/// n can be: +/// - inferred from length of initial cards +/// - specified directly by From for HandIterator +/// it is a struct that holds a u64 (and mask) and iterates over all possible hands under that mask +/// it is memory efficient because it does not store all possible hands +/// it is deterministic because it always iterates in the same order +/// it is fast because it uses bitwise operations +/// it is flexible because it can be used to iterate over any subset of cards +pub struct HandIterator { + next: u64, + mask: u64, +} + +impl HandIterator { + /// returns the size of the iterator + /// by some cheap combinatorial calculations + pub fn combinations(&self) -> usize { + let n = 52 - Hand::from(self.mask).size(); // count_ones() + let k = Hand::from(self.next).size(); // count_ones() + (0..k).fold(1, |x, i| x * (n - i) / (i + 1)) + } + /// an empty Hand cannot be advanced + /// also a Hand that overlaps into the Hand::mask() cannot be advanced + fn exhausted(&self) -> bool { + if self.next == 0 { + true + } else { + (64 - 52) > self.next.leading_zeros() + // // ALTERNATE IMPL: mask at return, iterate as-is + // (64 - 52) > self.next.leading_zeros() - self.mask.count_ones() + // // CURRENT IMPL: mask at iteration, return as-is + } + } + /// little bit of bit twiddling to get the next permutation + /// https://graphics.stanford.edu/~seander/bithacks.html#NextBitPermutation + fn permute(&self) -> u64 { + let x = /* 000_100 */ self.next; + let a = /* 000_111 <- 000_100 || 000_110 */ x | (x - 1); + let b = /* 001_000 <- */ a + 1; + let c = /* 111_000 <- */ ! a; + let d = /* 001_000 <- 111_000 && 001_000 */ c & b; + let e = /* 000_111 <- */ d - 1; + let f = /* << xxx */ 1 + x.trailing_zeros(); + let g = /* 000_000 <- */ e >> f; + let h = /* 001_000 <- 001_000 || 000_000 */ b | g; + h + } + + fn look(&self) -> Hand { + Hand::from(self.next) + // // ALTERNATE IMPL: mask at return, iterate as-is + // let mut returned_bits = 0; + // let mut shifting_bits = self.next; + // let mut excluded_bits = self.mask; + // while excluded_bits > 0 { + // let lsbs = (1 << excluded_bits.trailing_zeros()) - 1; + // let msbs = !lsbs; + // returned_bits = returned_bits /* carry lsbs */ | (shifting_bits & lsbs); + // excluded_bits = excluded_bits /* erase mask */ & (excluded_bits - 1); + // shifting_bits = shifting_bits /* erase lsbs */ & msbs; + // shifting_bits = shifting_bits /* shift left */ << 1; + // } + // Hand::from(returned_bits | shifting_bits) + // // CURRENT IMPL: mask at iteration, return as-is + } + + fn advance(&mut self) { + loop { + self.next = self.permute(); + if self.next & self.mask == 0 { + break; + } + } + // // ALTERNATE IMPL: mask at return, iterate as-is + // self.next = self.permute(); + // // CURRENT IMPL: mask at iteration, return as-is + } +} + +impl Iterator for HandIterator { + type Item = Hand; + fn next(&mut self) -> Option { + if self.exhausted() { + None + } else { + let last = self.look(); + self.advance(); + Some(last) + } + } + fn size_hint(&self) -> (usize, Option) { + let combos = self.combinations(); + (combos, Some(combos)) + } +} + +/// size and mask are immutable and must be decided at construction +impl From<(usize, Hand)> for HandIterator { + fn from((n, mask): (usize, Hand)) -> Self { + let mut this = Self { + next: (1 << n) - 1, + #[cfg(feature = "shortdeck")] + mask: u64::from(mask) | 0xFFFF, // remove 2-5 cards + #[cfg(not(feature = "shortdeck"))] + mask: u64::from(mask), + }; + while this.next & this.mask > 0 && !this.exhausted() { + this.next = this.permute(); + } + this + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn n_choose_0() { + let iter = HandIterator::from((0, Hand::empty())); + assert_eq!(iter.count(), 0); + } + #[test] + fn n_choose_1() { + let iter = HandIterator::from((1, Hand::empty())); + assert_eq!(iter.count(), Hand::from(Hand::mask()).size()); + } + #[test] + #[cfg(not(feature = "shortdeck"))] + fn n_choose_2() { + let iter = HandIterator::from((2, Hand::empty())); + assert_eq!(iter.count(), 1326); + } + #[test] + fn n_choose_0_mask_4() { + let mask = Hand::from(0xF); + let iter = HandIterator::from((0, mask)); + assert_eq!(iter.count(), 0); + } + #[test] + #[cfg(not(feature = "shortdeck"))] + fn n_choose_1_mask_4() { + let mask = Hand::from(0xF); + let iter = HandIterator::from((1, mask)); + assert_eq!(iter.count(), 48); + } + #[test] + #[cfg(not(feature = "shortdeck"))] + fn n_choose_2_mask_4() { + let mask = Hand::from(0xF); + let iter = HandIterator::from((2, mask)); + assert_eq!(iter.count(), 1128); + } + #[test] + #[cfg(not(feature = "shortdeck"))] + fn choose_3() { + let mut iter = HandIterator::from((3, Hand::empty())); + assert!(iter.next() == Some(Hand::from(0b00111))); + assert!(iter.next() == Some(Hand::from(0b01011))); + assert!(iter.next() == Some(Hand::from(0b01101))); + assert!(iter.next() == Some(Hand::from(0b01110))); + assert!(iter.next() == Some(Hand::from(0b10011))); + assert!(iter.next() == Some(Hand::from(0b10101))); + assert!(iter.next() == Some(Hand::from(0b10110))); + assert!(iter.next() == Some(Hand::from(0b11001))); + assert!(iter.next() == Some(Hand::from(0b11010))); + assert!(iter.next() == Some(Hand::from(0b11100))); + } + #[test] + #[cfg(feature = "shortdeck")] + fn choose_2_shortdeck() { + let mut iter = HandIterator::from((2, Hand::from(0))); + assert_eq!(iter.next(), Some(Hand::from(0b110000000000000000))); + } + #[test] + #[cfg(not(feature = "shortdeck"))] + fn choose_3_from_5() { + let mask = Hand::from(0b_________________1111_00_1).complement(); + let mut iter = HandIterator::from((3, mask)); + assert!(iter.next() == Some(Hand::from(0b0011_00_1))); + assert!(iter.next() == Some(Hand::from(0b0101_00_1))); + assert!(iter.next() == Some(Hand::from(0b0110_00_1))); + assert!(iter.next() == Some(Hand::from(0b0111_00_0))); + assert!(iter.next() == Some(Hand::from(0b1001_00_1))); + assert!(iter.next() == Some(Hand::from(0b1010_00_1))); + assert!(iter.next() == Some(Hand::from(0b1011_00_0))); + assert!(iter.next() == Some(Hand::from(0b1100_00_1))); + assert!(iter.next() == Some(Hand::from(0b1101_00_0))); + assert!(iter.next() == Some(Hand::from(0b1110_00_0))); + assert!(iter.next() == None); + } +} diff --git a/src/cards/hole.rs b/src/cards/hole.rs index d95d8b2a..f626b87d 100644 --- a/src/cards/hole.rs +++ b/src/cards/hole.rs @@ -1,21 +1,56 @@ -#[derive(Debug, Clone)] -pub struct Hole { - pub cards: Vec, // presize -} +use super::card::Card; +use super::hand::Hand; +use super::observation::Observation; + +#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, PartialOrd, Ord)] +pub struct Hole(Hand); impl Hole { - pub fn new() -> Hole { - Hole { - cards: Vec::with_capacity(2), - } + pub fn empty() -> Self { + Self(Hand::empty()) } } -impl Display for Hole { - fn fmt(&self, f: &mut Formatter) -> Result { - write!(f, "{} {}", self.cards[0], self.cards[1]) +impl std::fmt::Display for Hole { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.0) } } -use super::card::Card; -use std::fmt::{Display, Formatter, Result}; +impl From for Hole { + fn from(hand: Hand) -> Self { + assert!(hand.size() == 2); + Self(hand) + } +} +impl From for Hand { + fn from(hole: Hole) -> Self { + hole.0 + } +} + +impl From for Hole { + fn from(obs: Observation) -> Self { + Self(Hand::from(obs.pocket().clone())) + } +} + +impl From<(Card, Card)> for Hole { + fn from(cards: (Card, Card)) -> Self { + let a = u64::from(cards.0); + let b = u64::from(cards.1); + assert!(a != b); + Self(Hand::from(a | b)) + } +} + +impl TryFrom<&str> for Hole { + type Error = String; + fn try_from(s: &str) -> Result { + let hand = Hand::try_from(s)?; + match hand.size() { + 2 => Ok(Self(hand)), + _ => Err("hand must contain exactly two cards".into()), + } + } +} diff --git a/src/cards/isomorphism.rs b/src/cards/isomorphism.rs new file mode 100644 index 00000000..b20e0102 --- /dev/null +++ b/src/cards/isomorphism.rs @@ -0,0 +1,217 @@ +use super::observation::Observation; +use super::permutation::Permutation; +use crate::Arbitrary; + +/// because of the equivalence of Suit, +/// many Observations are strategically equivalent ! +/// so we can reduce the index space of learned +/// Abstractions by de-symmetrizing over the +/// 4! = 24 Suit Permutation group elements. in other words, +/// canonicalization. +/// +/// we do something a bit different from ~the literature~ here. +/// truly lossless isomorphism would distinguish between +/// pocket: Hand(Ac As) , board: Hand( Flop(2c 3c 4c) , Turn(5c) ) +/// i.e. strategically consider _which cards came on which streets_. +/// but it's memory/compute/efficient to lump all the board cards together, +/// in a kind of lossy imprefect recall kinda way. so we only care +/// about CardsInYourHand vs CardsOnTheBoard without considering street order. +/// +/// but we're able to save quite a bit of space along the way. +/// see [`crate::cards::street::Street::n_isomorphisms`] for a sense of how much. +/// but it's approx 4 (* 5) times smaller, as youd expect for without-replacement +/// sampling on the last two Streets. +#[derive(Copy, Clone, Hash, Eq, PartialEq, Debug, PartialOrd, Ord)] +pub struct Isomorphism(pub Observation); + +impl From for Isomorphism { + fn from(ref observation: Observation) -> Self { + let isomorphism = Permutation::from(observation); + let transformed = isomorphism.permute(observation); + Self(transformed) + } +} + +impl From for Observation { + fn from(equivalence: Isomorphism) -> Self { + equivalence.0 + } +} + +impl From for Isomorphism { + fn from(i: i64) -> Self { + Self(Observation::from(i)) + } +} + +impl From for i64 { + fn from(isomorphism: Isomorphism) -> i64 { + isomorphism.0.into() + } +} + +impl Arbitrary for Isomorphism { + fn random() -> Self { + Self::from(Observation::random()) + } +} + +impl Isomorphism { + pub fn is_canonical(observation: &Observation) -> bool { + Permutation::from(observation) == Permutation::identity() + } +} + +impl std::fmt::Display for Isomorphism { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cards::hand::Hand; + use crate::cards::permutation::Permutation; + use crate::cards::street::Street; + + #[test] + fn false_positives() { + let observation = Observation::from(Street::Rive); + let isomorphism = Isomorphism::from(observation); + assert!(Permutation::exhaust() + .iter() + .map(|p| p.permute(&observation)) + .map(|o| Isomorphism::from(o)) + .all(|i| i == isomorphism)); + } + + #[test] + fn false_negatives() { + let observation = Observation::from(Street::Rive); + let isomorphism = Isomorphism::from(observation); + let transformed = Observation::from(isomorphism); + assert!(Permutation::exhaust() + .iter() + .map(|p| p.permute(&transformed)) + .any(|o| o == observation)); + } + + #[test] + #[cfg(not(feature = "shortdeck"))] + fn super_symmetry() { + let a = Isomorphism::from(Observation::from(( + Hand::try_from("2s Ks").unwrap(), + Hand::try_from("2d 5h 8c Tc Th").unwrap(), + ))); + let b = Isomorphism::from(Observation::from(( + Hand::try_from("2s Ks").unwrap(), + Hand::try_from("2h 5c 8d Tc Td").unwrap(), + ))); + assert!(a == b); + } + + #[test] + fn pocket_rank_symmetry() { + let a = Isomorphism::from(Observation::from(( + Hand::try_from("Ac Ad").unwrap(), + Hand::try_from("Jc Ts 5s").unwrap(), + ))); + let b = Isomorphism::from(Observation::from(( + Hand::try_from("As Ah").unwrap(), + Hand::try_from("Js Tc 5c").unwrap(), + ))); + assert!(a == b); + } + + #[test] + fn public_rank_symmetry() { + let a = Isomorphism::from(Observation::from(( + Hand::try_from("Td As").unwrap(), + Hand::try_from("Ts Ks Kh").unwrap(), + ))); + let b = Isomorphism::from(Observation::from(( + Hand::try_from("Tc Ad").unwrap(), + Hand::try_from("Td Kd Kh").unwrap(), + ))); + assert!(a == b); + } + + #[test] + fn offsuit_backdoor() { + let a = Isomorphism::from(Observation::from(( + Hand::try_from("As Jh").unwrap(), + Hand::try_from("Ks Js 2d").unwrap(), + ))); + let b = Isomorphism::from(Observation::from(( + Hand::try_from("Ah Jd").unwrap(), + Hand::try_from("Kh Jh 2c").unwrap(), + ))); + assert!(a == b); + } + + #[test] + fn offsuit_draw() { + let a = Isomorphism::from(Observation::from(( + Hand::try_from("As Qh").unwrap(), + Hand::try_from("Ks Js 2s").unwrap(), + ))); + let b = Isomorphism::from(Observation::from(( + Hand::try_from("Ad Qh").unwrap(), + Hand::try_from("Kd Jd 2d").unwrap(), + ))); + assert!(a == b); + } + + #[test] + fn monochrome() { + let a = Isomorphism::from(Observation::from(( + Hand::try_from("Ad Kd").unwrap(), + Hand::try_from("Qd Jd Td").unwrap(), + ))); + let b = Isomorphism::from(Observation::from(( + Hand::try_from("As Ks").unwrap(), + Hand::try_from("Qs Js Ts").unwrap(), + ))); + assert!(a == b); + } + + #[test] + fn antichrome() { + let a = Isomorphism::from(Observation::from(( + Hand::try_from("Ac Kc").unwrap(), + Hand::try_from("Qs Js Ts").unwrap(), + ))); + let b = Isomorphism::from(Observation::from(( + Hand::try_from("As Ks").unwrap(), + Hand::try_from("Qh Jh Th").unwrap(), + ))); + assert!(a == b); + } + + #[test] + fn semichrome() { + let a = Isomorphism::from(Observation::from(( + Hand::try_from("Ac Ks").unwrap(), + Hand::try_from("Qc Js Ts").unwrap(), + ))); + let b = Isomorphism::from(Observation::from(( + Hand::try_from("Ad Kh").unwrap(), + Hand::try_from("Qd Jh Th").unwrap(), + ))); + assert!(a == b); + } + + #[test] + fn polychrome() { + let a = Isomorphism::from(Observation::from(( + Hand::try_from("Ac Kd").unwrap(), + Hand::try_from("Qh Js 9c").unwrap(), + ))); + let b = Isomorphism::from(Observation::from(( + Hand::try_from("Ah Ks").unwrap(), + Hand::try_from("Qc Jd 9h").unwrap(), + ))); + assert!(a == b); + } +} diff --git a/src/cards/isomorphisms.rs b/src/cards/isomorphisms.rs new file mode 100644 index 00000000..8e35304d --- /dev/null +++ b/src/cards/isomorphisms.rs @@ -0,0 +1,66 @@ +use super::isomorphism::Isomorphism; +use super::observations::ObservationIterator; +use super::street::Street; + +pub struct IsomorphismIterator(ObservationIterator); + +impl Iterator for IsomorphismIterator { + type Item = Isomorphism; + + fn next(&mut self) -> Option { + while let Some(observation) = self.0.next() { + if Isomorphism::is_canonical(&observation) { + return Some(Isomorphism::from(observation)); + } + } + None + } + + fn size_hint(&self) -> (usize, Option) { + let n = self.0.street().n_isomorphisms(); + (n, Some(n)) + } +} + +impl From for IsomorphismIterator { + fn from(street: Street) -> Self { + Self(ObservationIterator::from(street)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore] + fn n_pref() { + let pref = Street::Pref; + let iter = IsomorphismIterator::from(pref); + assert_eq!(iter.count(), pref.n_isomorphisms()); + } + + #[test] + #[ignore] + fn n_flop() { + let flop = Street::Flop; + let iter = IsomorphismIterator::from(flop); + assert_eq!(iter.count(), flop.n_isomorphisms()); + } + + #[test] + #[ignore] + fn n_turn() { + let turn = Street::Turn; + let iter = IsomorphismIterator::from(turn); + assert_eq!(iter.count(), turn.n_isomorphisms()); + } + + #[test] + #[ignore] + fn n_rive() { + let rive = Street::Rive; + let iter = IsomorphismIterator::from(rive); + assert_eq!(iter.count(), rive.n_isomorphisms()); + } +} diff --git a/src/cards/kicks.rs b/src/cards/kicks.rs new file mode 100644 index 00000000..42daf7d2 --- /dev/null +++ b/src/cards/kicks.rs @@ -0,0 +1,61 @@ +use super::rank::Rank; + +/// A hand's kicker cards. +/// +/// This is a simplified version of the hand's value, and does not include the hand's kicker cards. +/// The value is ordered by the hand's strength, and the kicker cards are used to break ties. +/// WARNING: Implementation of Ord will not correctly compare Suits. +#[derive(Debug, Clone, Default, Copy, Eq, PartialEq, PartialOrd, Ord)] +pub struct Kickers(u16); + +/// u32 isomorphism +/// importantly, we ignore (not erase) the Suit bits +impl From for u16 { + fn from(k: Kickers) -> Self { + k.0 + } +} +impl From for Kickers { + fn from(n: u16) -> Self { + Self(n) + } +} + +/// Vec isomorphism +/// +/// [2c, Ts, Jc, Js, Jd, Jh] +/// xxxxxxxxxxxx 000001100000001 +impl From for Vec { + fn from(k: Kickers) -> Self { + let mut value = k.0; + let mut index = 0u8; + let mut ranks = Vec::new(); + while value > 0 { + if value & 1 == 1 { + ranks.push(Rank::from(index)); + } + value = value >> 1; + index = index + 1; + } + ranks + } +} +impl From> for Kickers { + fn from(ranks: Vec) -> Self { + Self( + ranks + .into_iter() + .map(|r| u16::from(r)) + .fold(0u16, |a, b| a | b), + ) + } +} + +impl std::fmt::Display for Kickers { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + for rank in Vec::::from(self.clone()) { + write!(f, "{} ", rank)?; + } + Ok(()) + } +} diff --git a/src/cards/mod.rs b/src/cards/mod.rs index 44281665..6fd6012c 100644 --- a/src/cards/mod.rs +++ b/src/cards/mod.rs @@ -1,6 +1,18 @@ pub mod board; pub mod card; pub mod deck; +pub mod evaluator; +pub mod hand; +pub mod hands; pub mod hole; +pub mod isomorphism; +pub mod isomorphisms; +pub mod kicks; +pub mod observation; +pub mod observations; +pub mod permutation; pub mod rank; +pub mod ranking; +pub mod street; +pub mod strength; pub mod suit; diff --git a/src/cards/observation.rs b/src/cards/observation.rs new file mode 100644 index 00000000..e4ac5986 --- /dev/null +++ b/src/cards/observation.rs @@ -0,0 +1,208 @@ +use super::card::Card; +use super::deck::Deck; +use super::hand::Hand; +use super::hands::HandIterator; +use super::street::Street; +use super::strength::Strength; +use crate::Arbitrary; +use crate::Equity; +use std::cmp::Ordering; + +/// Observation represents the memoryless state of the game in between chance actions. +/// +/// We store each set of cards as a Hand which does not preserve dealing order. We can +/// generate successors by considering all possible cards that can be dealt. We can calculate +/// the equity of a given hand by comparing strength all possible villain hands. +/// This could be more memory efficient by using [Card; 2] for pocket Hands, +/// then impl From<[Card; 2]> for Hand. But the convenience of having the same Hand type is worth it. +#[derive(Copy, Clone, Hash, Eq, PartialEq, Debug, PartialOrd, Ord)] +pub struct Observation { + pocket: Hand, // if memory-bound: could be Hole/u16 + public: Hand, // if memory-bound: could be Board/[Option; 5] +} + +impl Observation { + pub fn children<'a>(&'a self) -> impl Iterator + 'a { + let n = self.street().n_revealed(); + let removed = Hand::from(*self); + HandIterator::from((n, removed)) + .map(|reveal| Hand::add(self.public, reveal)) + .map(|public| Self::from((self.pocket, public))) + } + pub fn equity(&self) -> Equity { + assert!(self.street() == Street::Rive); + let hand = Hand::from(*self); + let hero = Strength::from(hand); + let (won, sum) = HandIterator::from((2, hand)) + .map(|villain| Hand::add(self.public, villain)) + .map(|villain| Strength::from(villain)) + .map(|villain| hero.cmp(&villain)) + .filter(|&ord| ord != Ordering::Equal) + .fold((0u32, 0u32), |(wins, total), ord| match ord { + Ordering::Greater => (wins + 1, total + 1), + Ordering::Less => (wins, total + 1), + Ordering::Equal => unreachable!(), + }); + match sum { + 0 => 0.5, // all draw edge case + _ => won as Equity / sum as Equity, + } + } + pub fn estimate(&self) -> Equity { + todo!() + } + pub fn street(&self) -> Street { + Street::from(self.public.size()) + } + pub fn pocket(&self) -> &Hand { + &self.pocket + } + pub fn public(&self) -> &Hand { + &self.public + } + fn shuffle(hand: String) -> String { + use rand::seq::SliceRandom; + let ref mut rng = rand::thread_rng(); + let mut cards = hand + .chars() + .collect::>() + .chunks(2) + .map(|c| c.iter().collect::()) + .collect::>(); + cards.shuffle(rng); + cards.join("") + } + pub fn equivalent(&self) -> String { + super::permutation::Permutation::random() + .permute(self) + .to_string() + .split(Self::SEPARATOR) + .map(|s| s.to_string()) + .map(|s| s.trim().to_string()) + .map(|s| Self::shuffle(s)) + .collect::>() + .join(Self::SEPARATOR) + } + + const SEPARATOR: &'static str = "~"; +} +/// i64 isomorphism +/// +/// Packs all the cards in order, starting from LSBs. +/// Good for database serialization. Interchangable with u64 +impl From for i64 { + fn from(observation: Observation) -> Self { + std::iter::empty::() + .chain(observation.public.into_iter()) + .chain(observation.pocket.into_iter()) + .map(|card| 1 + u8::from(card) as u64) // distinguish 0x00 and 2c + .fold(0u64, |acc, card| acc << 8 | card) as i64 // next card + } +} +impl From for Observation { + fn from(bits: i64) -> Self { + Self::from( + (0u64..8u64) + .map(|i| bits >> (i * 8)) + .take_while(|&bits| bits > 0) + .map(|bits| bits as u8) + .map(|bits| bits - 1) // distinguish 0x00 and 2c + .map(Card::from) + .map(Hand::from) + .enumerate() + .fold( + (Hand::empty(), Hand::empty()), + |(pocket, public), (i, hand)| { + if i < 2 { + (Hand::add(pocket, hand), public) + } else { + (pocket, Hand::add(public, hand)) + } + }, + ), + ) + } +} + +/// assemble Observation from private + public Hands +impl From<(Hand, Hand)> for Observation { + fn from((pocket, public): (Hand, Hand)) -> Self { + assert!(pocket.size() == 2); + assert!(public.size() <= 5); + Self { pocket, public } + } +} + +/// Generate a random observation for a given street +impl From for Observation { + fn from(street: Street) -> Self { + let mut deck = Deck::new(); + let n = street.n_observed(); + let public = (0..n) + .map(|_| deck.draw()) + .map(u64::from) + .map(Hand::from) + .fold(Hand::empty(), Hand::add); + let pocket = (0..2) + .map(|_| deck.draw()) + .map(u64::from) + .map(Hand::from) + .fold(Hand::empty(), Hand::add); + Self::from((pocket, public)) + } +} + +/// coalesce public + private cards into single Hand +impl From for Hand { + fn from(observation: Observation) -> Self { + Self::add(observation.pocket, observation.public) + } +} + +impl TryFrom<&str> for Observation { + type Error = String; + fn try_from(s: &str) -> Result { + let (pocket, public) = s + .trim() + .split_once(Self::SEPARATOR) + .unwrap_or((s.trim(), "")); + let pocket = Hand::try_from(pocket)?; + let public = Hand::try_from(public)?; + match (pocket.size(), public.size()) { + (2, 0) | (2, 3) | (2, 4) | (2, 5) => Ok(Self::from((pocket, public))), + _ => Err(format!("invalid card counts: {} {}", pocket, public)), + } + } +} + +impl Arbitrary for Observation { + fn random() -> Self { + Self::from(Street::random()) + } +} + +/// display Observation as pocket + public +impl std::fmt::Display for Observation { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{} {} {}", self.pocket, Self::SEPARATOR, self.public) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cards::isomorphism::Isomorphism; + + #[test] + fn bijective_i64() { + let random = Observation::random(); + assert!(random == Observation::from(i64::from(random))); + } + + #[test] + fn shuffle() { + let random = Observation::random(); + let swappy = Observation::try_from(random.equivalent().as_str()).unwrap(); + assert!(Isomorphism::from(random) == Isomorphism::from(swappy)); + } +} diff --git a/src/cards/observations.rs b/src/cards/observations.rs new file mode 100644 index 00000000..577282a8 --- /dev/null +++ b/src/cards/observations.rs @@ -0,0 +1,131 @@ +use super::hand::Hand; +use super::hands::HandIterator; +use super::observation::Observation; +use super::street::Street; + +/// ObservationIterator is an iterator over all possible Observations for a given Street. +/// +/// composing Iterators like this helps when we get to +/// lazily generating Isomorphisms from Observations, or +/// want to do other FP tricks or sharding. +pub struct ObservationIterator { + street: Street, + pocket: Hand, + outer: HandIterator, + inner: HandIterator, +} + +impl From for ObservationIterator { + fn from(street: Street) -> Self { + // weird handling of Street::Pref edge. could be coupled with + // weird handling of HandIterator to be more elegant. + // think i need Option in HandIterator rather than store last. + // need to make it work with Street::Pref (Hand::empty()) + // and it should compose well with a separate HandIterator, so + // ObsIterator can reap the benefit + + // start with first card + let pocket = Self::start(); + let inner = HandIterator::from((street.n_observed(), pocket)); + let mut outer = HandIterator::from((2, Hand::empty())); + match street { + Street::Pref => None, + _ => outer.next(), + }; + Self { + street, + pocket, + outer, + inner, + } + } +} + +impl Iterator for ObservationIterator { + type Item = Observation; + fn next(&mut self) -> Option { + match self.inner.next() { + Some(next) => self.inner(next), + None => match self.outer.next() { + Some(next) => self.outer(next), + None => None, + }, + } + } + + fn size_hint(&self) -> (usize, Option) { + let n = self.combinations(); + (n, Some(n)) + } +} + +impl ObservationIterator { + pub fn combinations(&self) -> usize { + self.outer.combinations() * self.inner.combinations() + } + pub fn street(&self) -> Street { + self.street + } + fn start() -> Hand { + // 2c 2d + #[cfg(not(feature = "shortdeck"))] + let pocket = Hand::from(0x3); + // 6c 6d + #[cfg(feature = "shortdeck")] + let pocket = Hand::from(0x30000); + pocket + } + fn inner(&mut self, public: Hand) -> Option { + Some(Observation::from((self.pocket, public))) + } + fn outer(&mut self, pocket: Hand) -> Option { + self.pocket = pocket; + match self.street { + Street::Pref => Some(Observation::from((self.pocket, Hand::empty()))), + street => { + self.inner = HandIterator::from((street.n_observed(), self.pocket)); + self.inner + .next() + .map(|public| Observation::from((self.pocket, public))) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore] + fn n_pref() { + let street = Street::Pref; + let iter = ObservationIterator::from(street); + assert_eq!(iter.combinations(), street.n_observations()); + assert_eq!(iter.combinations(), iter.count()); + } + #[test] + #[ignore] + fn n_flop() { + let street = Street::Flop; + let iter = ObservationIterator::from(street); + assert_eq!(iter.combinations(), street.n_observations()); + assert_eq!(iter.combinations(), iter.count()); + } + #[test] + #[ignore] + fn n_turn() { + let street = Street::Turn; + let iter = ObservationIterator::from(street); + assert_eq!(iter.combinations(), street.n_observations()); + assert_eq!(iter.combinations(), iter.count()); + } + #[test] + #[ignore] + fn n_rive() { + let street = Street::Rive; + let iter = ObservationIterator::from(street); + assert_eq!(iter.combinations(), street.n_observations()); + assert_eq!(iter.combinations(), iter.count()); + } +} diff --git a/src/cards/permutation.rs b/src/cards/permutation.rs new file mode 100644 index 00000000..e10c27ec --- /dev/null +++ b/src/cards/permutation.rs @@ -0,0 +1,226 @@ +use super::hand::Hand; +use super::observation::Observation; +use super::suit::Suit; +use crate::Arbitrary; +use rand::seq::SliceRandom; + +/// an array of 4 unique Suits represents +/// any of the 4! = 24 elements in the Suit permutation group. +/// by assuming a "canonical" order of suits (C < D < H < S), +/// we use [Suit; 4] to map C -> P[0], D -> P[1], H -> P[2], S -> P[3]. +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +pub struct Permutation([Suit; 4]); + +/// this yields consistent, though, possibly non-unique, +/// Permutation that will map an Observation to its canonical form. +/// suits are sorted co-lexicographically by the number of cards +/// that they are represented by in hole cards and on the board. +/// ties are broken by the arbitrary enum impl Ord for Suit ! +impl From<&Observation> for Permutation { + fn from(observation: &Observation) -> Self { + let mut permutation = Suit::all(); + let mut colex = Suit::all().map(|suit| Self::colex(&observation, &suit)); + colex.sort_by(Self::order); + colex + .into_iter() + .enumerate() + .map(|(i, (suit, _, _))| (suit as usize, Suit::from(i as u8))) + .for_each(|(index, value)| permutation[index] = value); + Self(permutation) + } +} + +impl Permutation { + /// the image of an Observation under a Permutation + /// is computed from applying the Permutation to + /// its constituent Hands, pocket and public + pub fn permute(&self, observation: &Observation) -> Observation { + Observation::from(( + self.image(observation.pocket()), + self.image(observation.public()), + )) + } + + /// the image of a hand under a permutation + /// is the union of its shifted sub-Hands + pub fn image(&self, hand: &Hand) -> Hand { + Suit::all() + .iter() + .map(|suit| self.shift(suit, hand)) + .fold(Hand::empty(), |acc, x| Hand::add(acc, x)) + } + + /// impose order by breaking symmetries + /// 1. who has fewer cards in pocket? + /// 2. who has fewer cards on public? + /// 3. who has weaker pocket cards? + /// 4. who has weaker public cards? + /// 5. who has stronger pocket cards? redundant, tbh. due to 3. + /// 6. who has stronger public cards? + /// 7. tie delegates to Suit order + fn order(hearts: &(Suit, Hand, Hand), spades: &(Suit, Hand, Hand)) -> std::cmp::Ordering { + std::cmp::Ordering::Equal + .then_with(|| hearts.1.size().cmp(&spades.1.size())) + .then_with(|| hearts.2.size().cmp(&spades.2.size())) + .then_with(|| hearts.1.min_rank().cmp(&spades.1.min_rank())) + .then_with(|| hearts.2.min_rank().cmp(&spades.2.min_rank())) + .then_with(|| hearts.1.max_rank().cmp(&spades.1.max_rank())) + .then_with(|| hearts.2.max_rank().cmp(&spades.2.max_rank())) + .then_with(|| hearts.0.cmp(&spades.0)) // tiebreaker + } + + /// there's this thing called co-lexicographic order + /// which is a total ordering on some sub sets of cards + /// in our case Observation. it implements Order at different + /// scopes to break symmetries of strategically identical Observations. + fn colex(observation: &Observation, suit: &Suit) -> (Suit, Hand, Hand) { + let pocket = observation.pocket().of(suit); + let public = observation.public().of(suit); + (*suit, pocket, public) + } + + /// the hand here gets filtered by the "old" suit + /// and then we bitshift so that it is in its "new" suit + /// e.g. Full Hand -> Hearts Hand -> Spades Hand + fn shift(&self, suit: &Suit, hand: &Hand) -> Hand { + let old = *suit; + let new = self.map(suit); + let shift = new as i8 - old as i8; + let cards = u64::from(*suit) & u64::from(*hand); + if shift >= 0 { + Hand::from(cards << shift as u64) + } else { + Hand::from(cards >> shift.abs() as u64) + } + } + /// get the image of a Suit under a Permutation + fn map(&self, suit: &Suit) -> Suit { + self.0[*suit as usize] + } + + pub const fn identity() -> Self { + Self(Suit::all()) + } + pub const fn exhaust() -> [Self; 24] { + [ + Self([Suit::C, Suit::D, Suit::H, Suit::S]), + Self([Suit::C, Suit::D, Suit::S, Suit::H]), + Self([Suit::C, Suit::H, Suit::D, Suit::S]), + Self([Suit::C, Suit::H, Suit::S, Suit::D]), + Self([Suit::C, Suit::S, Suit::D, Suit::H]), + Self([Suit::C, Suit::S, Suit::H, Suit::D]), + Self([Suit::D, Suit::C, Suit::H, Suit::S]), + Self([Suit::D, Suit::C, Suit::S, Suit::H]), + Self([Suit::D, Suit::H, Suit::C, Suit::S]), + Self([Suit::D, Suit::H, Suit::S, Suit::C]), + Self([Suit::D, Suit::S, Suit::C, Suit::H]), + Self([Suit::D, Suit::S, Suit::H, Suit::C]), + Self([Suit::H, Suit::C, Suit::D, Suit::S]), + Self([Suit::H, Suit::C, Suit::S, Suit::D]), + Self([Suit::H, Suit::D, Suit::C, Suit::S]), + Self([Suit::H, Suit::D, Suit::S, Suit::C]), + Self([Suit::H, Suit::S, Suit::C, Suit::D]), + Self([Suit::H, Suit::S, Suit::D, Suit::C]), + Self([Suit::S, Suit::C, Suit::D, Suit::H]), + Self([Suit::S, Suit::C, Suit::H, Suit::D]), + Self([Suit::S, Suit::D, Suit::C, Suit::H]), + Self([Suit::S, Suit::D, Suit::H, Suit::C]), + Self([Suit::S, Suit::H, Suit::C, Suit::D]), + Self([Suit::S, Suit::H, Suit::D, Suit::C]), + ] + } +} + +impl Arbitrary for Permutation { + fn random() -> Self { + let ref mut rng = rand::thread_rng(); + Self::exhaust().choose(rng).copied().unwrap() + } +} + +impl std::fmt::Display for Permutation { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + Suit::all() + .iter() + .map(|s| writeln!(f, "{} -> {}", s, self.map(s))) + .last() + .unwrap() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cards::suit::Suit; + + #[test] + fn map_identity() { + let identity = Permutation::identity(); + assert!(identity.map(&Suit::C) == Suit::C); + assert!(identity.map(&Suit::D) == Suit::D); + assert!(identity.map(&Suit::H) == Suit::H); + assert!(identity.map(&Suit::S) == Suit::S); + } + + #[test] + fn map_arbitrary() { + let permutation = Permutation([Suit::H, Suit::S, Suit::C, Suit::D]); + assert!(permutation.map(&Suit::C) == Suit::H); + assert!(permutation.map(&Suit::D) == Suit::S); + assert!(permutation.map(&Suit::H) == Suit::C); + assert!(permutation.map(&Suit::S) == Suit::D); + } + + #[test] + fn permute_simple() { + let permutation = Permutation([Suit::H, Suit::C, Suit::S, Suit::D]); + let hearts = Hand::from(0b_0100_0100_0100_0100_0100_0100_0100_0100_u64); + let spades = Hand::from(0b_1000_1000_1000_1000_1000_1000_1000_1000_u64); + assert!(permutation.image(&hearts) == spades); + } + + #[test] + fn permute_unique() { + let ref hand = Hand::try_from("Ac Kd Qh Js").unwrap(); + let mut unique = std::collections::HashSet::new(); + let n = Permutation::exhaust() + .into_iter() + .map(|p| p.image(hand)) + .inspect(|h| assert!(unique.insert(*h))) + .count(); + assert!(n == 24); + } + + #[test] + fn permute_complex() { + let permutation = Permutation([Suit::D, Suit::H, Suit::C, Suit::S]); + let original = Hand::from(0b_1010_1010_1010_1010__0100_0100_0100_0100_u64); + let permuted = Hand::from(0b_1100_1100_1100_1100__0001_0001_0001_0001_u64); + assert!(permutation.image(&original) == permuted); + } + + #[test] + fn permute_rotation() { + let permutation = Permutation([Suit::S, Suit::C, Suit::D, Suit::H]); + let original = Hand::try_from("Ac Kd Qh Js").unwrap(); + let permuted = Hand::try_from("As Kc Qd Jh").unwrap(); + assert!(permutation.image(&original) == permuted); + } + + #[test] + #[cfg(not(feature = "shortdeck"))] + fn permute_interior() { + let permutation = Permutation([Suit::C, Suit::H, Suit::D, Suit::S]); + let original = Hand::try_from("2c 3d 4h 5s").unwrap(); + let permuted = Hand::try_from("2c 3h 4d 5s").unwrap(); + assert!(permutation.image(&original) == permuted); + } + + #[test] + fn permute_identity() { + use crate::Arbitrary; + let permutation = Permutation::identity(); + let hand = Hand::random(); + assert!(permutation.image(&hand) == hand); + } +} diff --git a/src/cards/rank.rs b/src/cards/rank.rs index 6e790387..787d1961 100644 --- a/src/cards/rank.rs +++ b/src/cards/rank.rs @@ -1,5 +1,6 @@ -#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)] +#[derive(Debug, Default, Clone, Copy, Hash, Eq, PartialEq, Ord, PartialOrd)] pub enum Rank { + #[default] Two = 0, Three = 1, Four = 2, @@ -15,7 +16,21 @@ pub enum Rank { Ace = 12, } -// u8 isomorphism +impl Rank { + const fn mask() -> u16 { + 0b1111111111111 + } + pub fn lo(bits: u64) -> Self { + let rank = (00 + 0 + bits.trailing_zeros()) as u8 / 4; + Rank::from(rank) + } + pub fn hi(bits: u64) -> Self { + let rank = (64 - 1 - bits.leading_zeros()) as u8 / 4; + Rank::from(rank) + } +} + +/// u8 isomorphism impl From for Rank { fn from(n: u8) -> Rank { match n { @@ -32,7 +47,7 @@ impl From for Rank { 10 => Rank::Queen, 11 => Rank::King, 12 => Rank::Ace, - _ => panic!("Invalid rank"), + _ => panic!("Invalid rank u8: {}", n), } } } @@ -42,49 +57,89 @@ impl From for u8 { } } -/// u32 isomorphism. -/// with this we get the highest rank in a union of cards in u32 representation -/// xxxxxxxxxxxxxxx xxxx 0000Txxxxxxxx -impl From for Rank { - fn from(n: u32) -> Rank { - let msb = (32 - Rank::mask(n).leading_zeros() - 1) as u8; +/// u16 isomorphism +/// +/// With 13 ranks we only need 13 bits +impl From for Rank { + fn from(n: u16) -> Rank { + let msb = (16 - 1 - (n & Self::mask()).leading_zeros()) as u8; Rank::from(msb) } } -impl From for u32 { - fn from(r: Rank) -> u32 { +impl From for u16 { + fn from(r: Rank) -> u16 { 1 << u8::from(r) } } -impl Rank { - pub fn mask(n: u32) -> u32 { - n & 0b00000000000000000001111111111111 +/// u64 injection +impl From for u64 { + fn from(r: Rank) -> u64 { + 0xF << (u8::from(r) * 4) } } -impl Display for Rank { - fn fmt(&self, f: &mut Formatter) -> Result { - write!( - f, - "{}", - match self { - Rank::Two => "2", - Rank::Three => "3", - Rank::Four => "4", - Rank::Five => "5", - Rank::Six => "6", - Rank::Seven => "7", - Rank::Eight => "8", - Rank::Nine => "9", - Rank::Ten => "T", - Rank::Jack => "J", - Rank::Queen => "Q", - Rank::King => "K", - Rank::Ace => "A", - } - ) +/// str isomorphism +impl TryFrom<&str> for Rank { + type Error = String; + fn try_from(s: &str) -> Result { + match s.trim().to_uppercase().as_str() { + "2" => Ok(Rank::Two), + "3" => Ok(Rank::Three), + "4" => Ok(Rank::Four), + "5" => Ok(Rank::Five), + "6" => Ok(Rank::Six), + "7" => Ok(Rank::Seven), + "8" => Ok(Rank::Eight), + "9" => Ok(Rank::Nine), + "T" => Ok(Rank::Ten), + "J" => Ok(Rank::Jack), + "Q" => Ok(Rank::Queen), + "K" => Ok(Rank::King), + "A" => Ok(Rank::Ace), + _ => Err(format!("invalid rank str: {}", s)), + } } } -use std::fmt::{Display, Formatter, Result}; +impl std::fmt::Display for Rank { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Rank::Two => write!(f, "2"), + Rank::Three => write!(f, "3"), + Rank::Four => write!(f, "4"), + Rank::Five => write!(f, "5"), + Rank::Six => write!(f, "6"), + Rank::Seven => write!(f, "7"), + Rank::Eight => write!(f, "8"), + Rank::Nine => write!(f, "9"), + Rank::Ten => write!(f, "T"), + Rank::Jack => write!(f, "J"), + Rank::Queen => write!(f, "Q"), + Rank::King => write!(f, "K"), + Rank::Ace => write!(f, "A"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bijective_u8() { + let rank = Rank::Five; + assert!(rank == Rank::from(u8::from(rank))); + } + + #[test] + fn bijective_u16() { + let rank = Rank::Five; + assert!(rank == Rank::from(u16::from(rank))); + } + + #[test] + fn injective_u64() { + assert!(u64::from(Rank::Five) == 0b1111000000000000); + } +} diff --git a/src/cards/ranking.rs b/src/cards/ranking.rs new file mode 100644 index 00000000..161a6496 --- /dev/null +++ b/src/cards/ranking.rs @@ -0,0 +1,64 @@ +use super::rank::Rank; + +/// A poker hand's value. +/// +/// This is a simplified version of the hand's value, and does not include the hand's kicker cards. +/// The value is ordered by the hand's Strength, and the kicker cards are used to break ties. +#[cfg(feature = "shortdeck")] +#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord)] +pub enum Ranking { + HighCard(Rank), // 4 kickers + OnePair(Rank), // 3 kickers + TwoPair(Rank, Rank), // 1 kickers + ThreeOAK(Rank), // 2 kickers + Straight(Rank), // 0 kickers + Flush(Rank), // 0 kickers + FullHouse(Rank, Rank), // 0 kickers + FourOAK(Rank), // 1 kickers + StraightFlush(Rank), // 0 kickers + MAX, // useful for showdown implementation +} +#[cfg(not(feature = "shortdeck"))] +#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord)] +pub enum Ranking { + HighCard(Rank), // 4 kickers + OnePair(Rank), // 3 kickers + TwoPair(Rank, Rank), // 1 kickers + ThreeOAK(Rank), // 2 kickers + Straight(Rank), // 0 kickers + FullHouse(Rank, Rank), // 0 kickers + Flush(Rank), // 0 kickers + FourOAK(Rank), // 1 kickers + StraightFlush(Rank), // 0 kickers + MAX, // useful for showdown implementation +} + +impl Ranking { + pub fn n_kickers(&self) -> usize { + let n = match self { + Ranking::HighCard(_) => 4, + Ranking::OnePair(_) => 3, + Ranking::ThreeOAK(_) => 2, + Ranking::FourOAK(_) | Ranking::TwoPair(_, _) => 1, + _ => 0, + }; + n + } +} + +impl std::fmt::Display for Ranking { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Ranking::MAX => unreachable!(), + Ranking::FullHouse(r1, r2) => write!(f, "FullHouse {}{}", r1, r2), + Ranking::TwoPair(r1, r2) => write!(f, "TwoPair {}{}", r1, r2), + Ranking::HighCard(r) => write!(f, "HighCard {} ", r), + Ranking::OnePair(r) => write!(f, "OnePair {} ", r), + Ranking::ThreeOAK(r) => write!(f, "ThreeOfAKind {} ", r), + Ranking::Straight(r) => write!(f, "Straight {} ", r), + Ranking::FourOAK(r) => write!(f, "FourOfAKind {} ", r), + Ranking::Flush(r) => write!(f, "Flush {} ", r), + Ranking::StraightFlush(r) => write!(f, "StraightFlush {} ", r), + } + } +} diff --git a/src/cards/street.rs b/src/cards/street.rs new file mode 100644 index 00000000..93c18e78 --- /dev/null +++ b/src/cards/street.rs @@ -0,0 +1,201 @@ +use crate::clustering::abstraction::Abstraction; + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub enum Street { + Pref = 0isize, + Flop = 1isize, + Turn = 2isize, + Rive = 3isize, +} + +impl Street { + pub const fn all() -> &'static [Self] { + &[Self::Pref, Self::Flop, Self::Turn, Self::Rive] + } + pub const fn next(&self) -> Self { + match self { + Self::Pref => Self::Flop, + Self::Flop => Self::Turn, + Self::Turn => Self::Rive, + Self::Rive => panic!("terminal"), + } + } + pub const fn prev(&self) -> Self { + match self { + Self::Pref => panic!("starting"), + Self::Flop => Self::Pref, + Self::Turn => Self::Flop, + Self::Rive => Self::Turn, + } + } + pub const fn k(&self) -> usize { + match self { + Self::Pref => self.n_isomorphisms(), + Self::Flop => crate::KMEANS_FLOP_CLUSTER_COUNT, + Self::Turn => crate::KMEANS_TURN_CLUSTER_COUNT, + Self::Rive => 0, + } + } + pub const fn t(&self) -> usize { + match self { + Self::Pref => 0, + Self::Flop => crate::KMEANS_FLOP_TRAINING_ITERATIONS, + Self::Turn => crate::KMEANS_TURN_TRAINING_ITERATIONS, + Self::Rive => 0, + } + } + pub const fn n_observed(&self) -> usize { + match self { + Self::Pref => 0, + Self::Flop => 3, + Self::Turn => 4, + Self::Rive => 5, + } + } + pub const fn n_revealed(&self) -> usize { + match self { + Self::Pref => 3, + Self::Flop => 1, + Self::Turn => 1, + Self::Rive => panic!("terminal"), + } + } + + pub const fn n_abstractions(&self) -> usize { + match self { + Self::Rive => Abstraction::size(), + _ => self.k(), + } + } + + #[cfg(not(feature = "shortdeck"))] + pub const fn n_children(&self) -> usize { + match self { + Self::Pref => 19_600, + Self::Flop => 0___47, + Self::Turn => 0___46, + Self::Rive => panic!("terminal"), + } + } + #[cfg(not(feature = "shortdeck"))] + pub const fn n_isomorphisms(&self) -> usize { + match self { + Self::Pref => 0_________169, + Self::Flop => 0___1_286_792, + Self::Turn => 0__13_960_050, + Self::Rive => 0_123_156_254, + } + } + #[cfg(not(feature = "shortdeck"))] + pub const fn n_observations(&self) -> usize { + match self { + Self::Pref => 0_______1_326, + Self::Flop => 0__25_989_600, + Self::Turn => 0_305_377_800, + Self::Rive => 2_809_475_760, + } + } + #[cfg(feature = "shortdeck")] + pub const fn n_isomorphisms(&self) -> usize { + match self { + Self::Pref => 0__________81, + Self::Flop => 0_____186_696, + Self::Turn => 0___1_340_856, + Self::Rive => 0___7_723_728, + } + } + #[cfg(feature = "shortdeck")] + pub const fn n_observations(&self) -> usize { + match self { + Self::Pref => 0_________630, + Self::Flop => 0___3_769_920, + Self::Turn => 0__29_216_880, + Self::Rive => 0_175_301_280, + } + } + #[cfg(feature = "shortdeck")] + pub const fn n_children(&self) -> usize { + match self { + Self::Pref => 5_984, + Self::Flop => 0__31, + Self::Turn => 0__30, + Self::Rive => panic!("terminal"), + } + } +} + +impl From for Street { + fn from(n: isize) -> Self { + match n { + 0 => Self::Pref, + 1 => Self::Flop, + 2 => Self::Turn, + 3 => Self::Rive, + _ => panic!("no other u8s"), + } + } +} + +impl From for Street { + fn from(n: usize) -> Self { + match n { + 0 => Self::Pref, + 3 => Self::Flop, + 4 => Self::Turn, + 5 => Self::Rive, + _ => panic!("no other usizes"), + } + } +} + +/// useful for reference of Postgres Street::from(i64) calculation that is done in analysis.sql +impl From for Street { + fn from(obs: i64) -> Self { + Self::from( + (0u64..8u64) + .map(|i| obs >> (i * 8)) + .take_while(|bits| *bits > 0) + .map(|bits| bits as u8) + .map(|bits| bits - 1) + .map(|bits| 1u64 << bits) + .skip(2) + .count(), + ) + } +} + +impl std::fmt::Display for Street { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Pref => write!(f, "preflop"), + Self::Flop => write!(f, "flop"), + Self::Turn => write!(f, "turn"), + Self::Rive => write!(f, "river"), + } + } +} + +impl TryFrom<&str> for Street { + type Error = Box; + fn try_from(s: &str) -> Result { + match s.to_uppercase().chars().next() { + Some('P') => Ok(Self::Pref), + Some('F') => Ok(Self::Flop), + Some('T') => Ok(Self::Turn), + Some('R') => Ok(Self::Rive), + _ => Err("invalid street character".into()), + } + } +} + +impl crate::Arbitrary for Street { + fn random() -> Self { + use rand::Rng; + match rand::thread_rng().gen_range(0..4) { + 0 => Self::Pref, + 1 => Self::Flop, + 2 => Self::Turn, + _ => Self::Rive, + } + } +} diff --git a/src/cards/strength.rs b/src/cards/strength.rs new file mode 100644 index 00000000..8c584289 --- /dev/null +++ b/src/cards/strength.rs @@ -0,0 +1,41 @@ +use super::evaluator::Evaluator; +use super::hand::Hand; +use super::kicks::Kickers; +use super::ranking::Ranking; + +/// A hand's strength. +/// +/// This will always be constructed from a Hand, which is an unordered +/// set of Cards. The strength is determined by the Hand's value, and the +/// kicker cards are used to break ties. +#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord)] +pub struct Strength { + value: Ranking, + pub kicks: Kickers, +} + +impl From for Strength { + fn from(hand: Hand) -> Self { + Self::from(Evaluator::from(hand)) + } +} + +impl From for Strength { + fn from(e: Evaluator) -> Self { + let value = e.find_ranking(); + let kicks = e.find_kickers(value); + Self::from((value, kicks)) + } +} + +impl From<(Ranking, Kickers)> for Strength { + fn from((value, kicks): (Ranking, Kickers)) -> Self { + Self { value, kicks } + } +} + +impl std::fmt::Display for Strength { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{:<18}{:>5}", self.value, self.kicks) + } +} diff --git a/src/cards/suit.rs b/src/cards/suit.rs index fdb51d8f..ed15753e 100644 --- a/src/cards/suit.rs +++ b/src/cards/suit.rs @@ -1,19 +1,27 @@ -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Default, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)] pub enum Suit { - Club = 0, - Diamond = 1, - Heart = 2, - Spade = 3, + #[default] + C = 0, + D = 1, + H = 2, + S = 3, } +impl Suit { + pub const fn all() -> [Suit; 4] { + [Suit::C, Suit::D, Suit::H, Suit::S] + } +} + +/// u8 isomorphism impl From for Suit { fn from(n: u8) -> Suit { match n { - 0 => Suit::Club, - 1 => Suit::Diamond, - 2 => Suit::Heart, - 3 => Suit::Spade, - _ => panic!("Invalid suit"), + 0 => Suit::C, + 1 => Suit::D, + 2 => Suit::H, + 3 => Suit::S, + _ => unreachable!("invalid suit"), } } } @@ -23,30 +31,58 @@ impl From for u8 { } } -// xxxxxxxxxxxxxxx cdhs xxxxxxxxxxxxx -impl From for Suit { - fn from(n: u32) -> Suit { - Suit::from((n >> 13).trailing_zeros() as u8) +/// u64 representation +impl From for u64 { + fn from(s: Suit) -> u64 { + match s { + Suit::C => 0x0001111111111111, + Suit::D => 0x0002222222222222, + Suit::H => 0x0004444444444444, + Suit::S => 0x0008888888888888, + } } } -impl From for u32 { - fn from(s: Suit) -> u32 { - 1 << (13 + u8::from(s)) + +/// str isomorphism +impl TryFrom<&str> for Suit { + type Error = String; + fn try_from(s: &str) -> Result { + match s.trim().to_lowercase().as_str() { + "c" | "♣" => Ok(Suit::C), + "d" | "♦" => Ok(Suit::D), + "h" | "♥" => Ok(Suit::H), + "s" | "♠" => Ok(Suit::S), + _ => Err(format!("invalid suit str: {}", s)), + } } } -impl Display for Suit { - fn fmt(&self, f: &mut Formatter) -> Result { - write!( - f, - "{}", - match self { - Suit::Club => "c", - Suit::Diamond => "d", - Suit::Heart => "h", - Suit::Spade => "s", - } - ) +impl std::fmt::Display for Suit { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Suit::C => write!(f, "c"), + Suit::D => write!(f, "d"), + Suit::H => write!(f, "h"), + Suit::S => write!(f, "s"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bijective_u8() { + let suit = Suit::D; + assert!(suit == Suit::from(u8::from(suit))); + } + + #[test] + fn injective_u64() { + assert!(u64::from(Suit::C) == 0b0001000100010001000100010001000100010001000100010001); + assert!(u64::from(Suit::D) == 0b0010001000100010001000100010001000100010001000100010); + assert!(u64::from(Suit::H) == 0b0100010001000100010001000100010001000100010001000100); + assert!(u64::from(Suit::S) == 0b1000100010001000100010001000100010001000100010001000); } } -use std::fmt::{Display, Formatter, Result}; diff --git a/src/clustering/abstraction.rs b/src/clustering/abstraction.rs new file mode 100644 index 00000000..1cf31da4 --- /dev/null +++ b/src/clustering/abstraction.rs @@ -0,0 +1,231 @@ +use crate::cards::street::Street; +use crate::transport::support::Support; +use crate::Arbitrary; +use crate::Probability; +use std::hash::Hash; +use std::u64; + +/// Abstraction represents a lookup value for a given set of Observations. +/// +/// - River: we use a u8 to represent the equity bucket, i.e. Equity(0) is the worst bucket, and Equity(50) is the best bucket. +/// - Pre-Flop: we do not use any abstraction, rather store the 169 strategically-unique hands as u64. +/// - Other Streets: we use a u64 to represent the hash signature of the centroid Histogram over lower layers of abstraction. +#[derive(Copy, Clone, Hash, Eq, PartialEq, Debug, PartialOrd, Ord)] +pub enum Abstraction { + Percent(u64), // river + Learned(u64), // flop, turn + Preflop(u64), // preflop +} + +/// establish that the lower 56 LSBs are what the index +/// contains information about Abstraction +/// the upper 8 MSBs is the Street tag' + +const H: u64 = 0xFF00000000000000; // street mask +const M: u64 = 0x00FFFFFFFFFFF000; // hash mask +const L: u64 = 0x0000000000000FFF; // index mask + +impl Abstraction { + const DELIM: &'static str = "::"; + const N: usize = crate::KMEANS_EQTY_CLUSTER_COUNT - 1; + pub const fn size() -> usize { + Self::N as usize + 1 + } + pub fn range() -> impl Iterator { + (0..=Self::N).map(|i| Self::from((Street::Rive, i as usize))) + } + pub fn street(&self) -> Street { + match self { + Abstraction::Percent(n) | Abstraction::Learned(n) | Abstraction::Preflop(n) => { + match ((H & n) >> H.count_zeros()) as isize { + 0 => Street::Pref, + 1 => Street::Flop, + 2 => Street::Turn, + 3 => Street::Rive, + _ => panic!("at the disco"), + } + } + } + } + pub fn index(&self) -> usize { + match self { + Abstraction::Percent(n) | Abstraction::Learned(n) | Abstraction::Preflop(n) => { + (L & n) as usize + } + } + } + pub fn all(street: Street) -> Vec { + if street == Street::Rive { + Self::range().collect() + } else { + (0..street.k()).map(|i| Self::from((street, i))).collect() + } + } + fn signature(street: Street, index: usize) -> usize { + let bits = L & index as u64; + let bits = bits | (street as u8 as u64) << L.count_ones(); + let bits = bits.wrapping_mul(0x9E3779B97F4A7C15); + let bits = M & bits; + bits as usize + } + fn quantize(p: Probability) -> usize { + (p * Self::N as Probability).round() as usize + } + fn floatize(q: usize) -> Probability { + q as Probability / Self::N as Probability + } +} + +impl From<(Street, usize)> for Abstraction { + fn from((street, index): (Street, usize)) -> Self { + let mut bits = 0; + bits |= L & index as u64; + bits |= M & Self::signature(street, index) as u64; + bits |= H & (street as u8 as u64) << H.count_zeros(); + match street { + Street::Pref => Abstraction::Preflop(bits), + Street::Flop => Abstraction::Learned(bits), + Street::Turn => Abstraction::Learned(bits), + Street::Rive => Abstraction::Percent(bits), + } + } +} + +/// probability isomorphism +/// +/// for river, we use a u8 to represent the equity bucket, +/// i.e. Equity(0) is the 0% equity bucket, +/// and Equity(N) is the 100% equity bucket. +impl From for Abstraction { + fn from(p: Probability) -> Self { + assert!(p >= 0.); + assert!(p <= 1.); + Self::from((Street::Rive, Self::quantize(p))) + } +} +impl From for Probability { + fn from(abstraction: Abstraction) -> Self { + match abstraction { + Abstraction::Percent(_) => Abstraction::floatize(abstraction.index()), + Abstraction::Learned(_) => unreachable!("no cluster into probability"), + Abstraction::Preflop(_) => unreachable!("no preflop into probability"), + } + } +} + +/// u64 isomorphism +/// +/// conversion to u64 for SQL storage. +impl From for u64 { + fn from(a: Abstraction) -> Self { + match a { + Abstraction::Percent(n) | Abstraction::Learned(n) | Abstraction::Preflop(n) => n, + } + } +} +impl From for Abstraction { + fn from(n: u64) -> Self { + match ((H & n) >> H.count_zeros()) as u8 { + 0 => Abstraction::Preflop(n), + 1 => Abstraction::Learned(n), + 2 => Abstraction::Learned(n), + 3 => Abstraction::Percent(n), + _ => unreachable!("Invalid street value"), + } + } +} + +/// i64 isomorphism +/// +/// conversion to i64 for SQL storage. +impl From for i64 { + fn from(abstraction: Abstraction) -> Self { + u64::from(abstraction) as i64 + } +} +impl From for Abstraction { + fn from(n: i64) -> Self { + Self::from(n as u64) + } +} +/// string isomorophism +impl TryFrom<&str> for Abstraction { + type Error = Box; + fn try_from(s: &str) -> Result { + let s = s.trim().split(Self::DELIM).collect::>(); + let a = s.get(0).copied().ok_or("broken delimiter")?; + let b = s.get(1).copied().ok_or("broken delimiter")?; + let street = Street::try_from(a)?; + let index = usize::from_str_radix(b, 16)?; + Ok(Abstraction::from((street, index))) + } +} +impl std::fmt::Display for Abstraction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}{}{:02x}", + self.street() + .to_string() + .chars() + .next() + .unwrap() + .to_uppercase(), + Self::DELIM, + self.index() + ) + } +} + +impl Arbitrary for Abstraction { + fn random() -> Self { + use rand::Rng; + let street = Street::Flop; + let k = street.k(); + let i = rand::thread_rng().gen_range(0..k); + Abstraction::from((street, i)) + } +} + +impl Support for Abstraction {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cards::observation::Observation; + use crate::cards::street::Street; + use crate::Arbitrary; + + #[test] + fn is_quantize_inverse_floatize() { + for p in (0..=100).map(|x| x as Probability / 100.) { + let q = Abstraction::quantize(p); + let f = Abstraction::floatize(q); + assert!((p - f).abs() < 1. / Abstraction::N as Probability); + } + } + #[test] + fn is_floatize_inverse_quantize() { + for q in 0..=Abstraction::N { + let p = Abstraction::floatize(q); + let i = Abstraction::quantize(p); + assert!(q == i); + } + } + #[test] + fn bijective_u64_random() { + let random = Abstraction::random(); + assert_eq!(random, Abstraction::from(u64::from(random))); + } + #[test] + fn bijective_u64_equity() { + let equity = Abstraction::from(Observation::from(Street::Rive).equity()); + assert_eq!(equity, Abstraction::from(u64::from(equity))); + } + #[test] + fn bijective_str() { + let abs = Abstraction::random(); + let str = format!("{}", abs); + assert_eq!(abs, Abstraction::try_from(str.as_str()).unwrap()); + } +} diff --git a/src/clustering/emd.rs b/src/clustering/emd.rs new file mode 100644 index 00000000..e8e959f2 --- /dev/null +++ b/src/clustering/emd.rs @@ -0,0 +1,170 @@ +use super::heuristic::Heuristic; +use super::histogram::Histogram; +use super::metric::Metric; +use super::pair::Pair; +use super::sinkhorn::Sinkhorn; +use crate::transport::coupling::Coupling; +use crate::Arbitrary; +use std::collections::BTreeMap; + +/// this guy is used just to construct arbitrary metric, histogram, histogram tuples +/// to test transport mechanisms +pub struct EMD(Metric, Histogram, Histogram, Histogram); + +impl EMD { + pub fn metric(&self) -> &Metric { + &self.0 + } + pub fn sinkhorn(&self) -> Sinkhorn { + Sinkhorn::from((&self.1, &self.2, &self.0)).minimize() + } + pub fn heuristic(&self) -> Heuristic { + Heuristic::from((&self.1, &self.2, &self.0)).minimize() + } + pub fn inner(self) -> (Metric, Histogram, Histogram, Histogram) { + (self.0, self.1, self.2, self.3) + } +} + +impl Arbitrary for EMD { + fn random() -> Self { + // construct random metric satisfying symmetric semipositivity + use rand::Rng; + let mut rng = rand::thread_rng(); + let p = Histogram::random(); + let q = Histogram::random(); + let r = Histogram::random(); + let m = Metric::from( + std::iter::empty() + .chain(p.support()) + .chain(q.support()) + .chain(r.support()) + .flat_map(|x| { + std::iter::empty() + .chain(p.support()) + .chain(q.support()) + .chain(r.support()) + .map(move |y| (x, y)) + }) + .filter(|(x, y)| x > y) + .map(|(x, y)| Pair::from((x, y))) + .map(|paired| (paired, rng.gen::())) + .collect::>(), + ); + Self(m, p, q, r) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cards::observation::Observation; + use crate::cards::street::Street; + use crate::clustering::histogram::Histogram; + + /// equity implementation should be + /// 1. symmetric + /// 2. positive semidefinite + /// 3. self-annihilating + + #[test] + fn is_equity_emd_symmetric() { + let metric = Metric::default(); + let ref h1 = Histogram::from(Observation::from(Street::Turn)); + let ref h2 = Histogram::from(Observation::from(Street::Turn)); + let d12 = metric.emd(h1, h2); + let d21 = metric.emd(h2, h1); + assert!(d12 == d21); + } + #[test] + fn is_equity_emd_positive() { + let metric = Metric::default(); + let ref h1 = Histogram::from(Observation::from(Street::Turn)); + let ref h2 = Histogram::from(Observation::from(Street::Turn)); + let d12 = metric.emd(h1, h2); + let d21 = metric.emd(h2, h1); + assert!(d12 > 0.); + assert!(d21 > 0.); + } + #[test] + fn is_equity_emd_zero() { + let metric = Metric::default(); + let h = Histogram::from(Observation::from(Street::Turn)); + let d = metric.emd(&h, &h); + assert!(d == 0.); + } + + /// sinkhorn implementation should be + /// 1. positive semidefinite + /// 2. approximately symmetric (untested) + /// 3. approximately self-annihilating + /// 4. satisfies triangle inequality + + #[test] + fn is_sinkhorn_emd_triangle() { + let EMD(metric, h1, h2, h3) = EMD::random(); + let d12 = Sinkhorn::from((&h1, &h2, &metric)).minimize().cost(); + let d23 = Sinkhorn::from((&h2, &h3, &metric)).minimize().cost(); + let d13 = Sinkhorn::from((&h1, &h3, &metric)).minimize().cost(); + assert!(d12 + d23 >= d13, "{} + {} > {}", d12, d23, d13); + assert!(d12 + d13 >= d23, "{} + {} > {}", d12, d13, d23); + assert!(d23 + d13 >= d12, "{} + {} > {}", d23, d13, d12); + } + #[test] + fn is_sinkhorn_emd_positive() { + let EMD(metric, h1, h2, _) = EMD::random(); + let d12 = Sinkhorn::from((&h1, &h2, &metric)).minimize().cost(); + let d21 = Sinkhorn::from((&h2, &h1, &metric)).minimize().cost(); + assert!(d12 > 0., "{}", d12); + assert!(d21 > 0., "{}", d21); + } + #[test] + fn is_sinkhorn_emd_zero() { + const TOLERANCE: f32 = 0.01; + let EMD(metric, h1, h2, _) = EMD::random(); + let d11 = Sinkhorn::from((&h1, &h1, &metric)).minimize().cost(); + let d22 = Sinkhorn::from((&h2, &h2, &metric)).minimize().cost(); + assert!( + d11 <= TOLERANCE, + "consider decreasing temp or tolerance\n{d11} {TOLERANCE}", + ); + assert!( + d22 <= TOLERANCE, + "consider decreasing temp or tolerance\n{d22} {TOLERANCE}", + ); + } + + /// heuristic implementation should be + /// 1. positive semidefinite + /// 2. approximately symmetric + /// 3. exactly self-annihilating + /// 4. satisfies triangle inequality + + #[test] + fn is_heuristic_emd_triangle() { + const TOLERANCE: f32 = 1.25; + let EMD(metric, h1, h2, h3) = EMD::random(); + let d12 = Heuristic::from((&h1, &h2, &metric)).minimize().cost(); + let d23 = Heuristic::from((&h2, &h3, &metric)).minimize().cost(); + let d13 = Heuristic::from((&h1, &h3, &metric)).minimize().cost(); + assert!(d12 + d23 >= d13 / TOLERANCE, "{} + {} > {}", d12, d23, d13); + assert!(d12 + d13 >= d23 / TOLERANCE, "{} + {} > {}", d12, d13, d23); + assert!(d23 + d13 >= d12 / TOLERANCE, "{} + {} > {}", d23, d13, d12); + } + #[test] + fn is_heuristic_emd_positive() { + let EMD(metric, h1, h2, _) = EMD::random(); + let d12 = Heuristic::from((&h1, &h2, &metric)).minimize().cost(); + let d21 = Heuristic::from((&h2, &h1, &metric)).minimize().cost(); + assert!(d12 > 0.); + assert!(d21 > 0.); + } + #[test] + fn is_heuristic_emd_zero() { + let EMD(metric, h1, h2, _) = EMD::random(); + let d11 = Heuristic::from((&h1, &h1, &metric)).minimize().cost(); + let d22 = Heuristic::from((&h2, &h2, &metric)).minimize().cost(); + assert!(d11 == 0.); + assert!(d22 == 0.); + } +} diff --git a/src/clustering/equity.rs b/src/clustering/equity.rs new file mode 100644 index 00000000..c1546ffc --- /dev/null +++ b/src/clustering/equity.rs @@ -0,0 +1,61 @@ +use super::abstraction::Abstraction; +use super::histogram::Histogram; +use crate::transport::measure::Measure; +use crate::{Energy, Probability}; + +/// useful struct for grouping methods that help in calculating +/// optimal transport between two Equity Histograms. +/// more broadly, this can generalize to calclaute distance +/// between arbitrary distributions over the [0, 1] interval. +/// +/// in the Kontorovich-Rubinstein dual formulation of optimal transport, +/// we can think of the constraint as being probabilistic unitarity. +/// equivalently, we constrain the coupling to be doubly stochastic +/// over the product of support spaces, i.e. [0, 1] x [0, 1]. +pub struct Equity; + +impl Measure for Equity { + type X = Abstraction; //::Equity(i8) variant + type Y = Abstraction; //::Equity(i8) variant + fn distance(&self, x: &Self::X, y: &Self::Y) -> f32 { + (Probability::from(*x) - Probability::from(*y)).abs() + } +} + +/// different distance metrics over Equity Histograms +/// conveniently have properties of distributions over the [0, 1] interval. +#[allow(dead_code)] +impl Equity { + pub fn variation(x: &Histogram, y: &Histogram) -> Energy { + let mut cdf_x = 0.0; + let mut cdf_y = 0.0; + Abstraction::range() + .map(|abstraction| { + cdf_x += x.density(&abstraction); + cdf_y += y.density(&abstraction); + cdf_x - cdf_y + }) + .map(|delta| delta.abs()) + .sum::() + / Abstraction::size() as Energy + } + pub fn euclidean(x: &Histogram, y: &Histogram) -> Energy { + Abstraction::range() + .map(|abstraction| x.density(&abstraction) - y.density(&abstraction)) + .map(|delta| delta * delta) + .sum::() + .sqrt() + } + pub fn chisquare(x: &Histogram, y: &Histogram) -> Energy { + Abstraction::range() + .map(|abstraction| (x.density(&abstraction), y.density(&abstraction))) + .map(|(x, y)| (x - y).powi(2) / (x + y)) + .sum::() + } + pub fn divergent(x: &Histogram, y: &Histogram) -> Energy { + Abstraction::range() + .map(|abstraction| (x.density(&abstraction), y.density(&abstraction))) + .map(|(x, y)| (x - y).abs()) + .sum::() + } +} diff --git a/src/clustering/heuristic.rs b/src/clustering/heuristic.rs new file mode 100644 index 00000000..1a68b8a9 --- /dev/null +++ b/src/clustering/heuristic.rs @@ -0,0 +1,95 @@ +use super::abstraction::Abstraction; +use super::histogram::Histogram; +use super::metric::Metric; +use super::pair::Pair; +use super::potential::Potential; +use crate::transport::coupling::Coupling; +use crate::transport::measure::Measure; +use crate::Probability; +use std::collections::BTreeMap; + +/// greedy algorithm for optimimal transport. +/// my favorite interpretation of this is in the formalization +/// of bipartite matching. we have a Left set of sources and a +/// Right set of targets. we want to find a way to pair each source +/// to a target under the constraint of conserving probability mass. +/// +/// for each step of the algorithm, we pair the next source to its nearest target. +/// we move as much mass as we can. we then continue to an arbitrary next source, and repeat. +/// +/// this is O(N * M) in (sources, targets) size. for us these are both +/// the K number of clusters at different layers of the hierarchy. +/// +/// if anything, the most expensive part about this is the double BTreeMap +/// allocation. considering that we do this a few billion times it is +/// probably worth optimizing into a 0-alloc implementation. +/// +/// also, it turns out this algorithm sucks in worst case. like it's just not at all +/// a reasonable heuristic, even in pathological 1D trivial cases. +pub struct Heuristic<'a> { + plan: BTreeMap, + metric: &'a Metric, + source: &'a Histogram, + target: &'a Histogram, +} + +impl Coupling for Heuristic<'_> { + type X = Abstraction; + type Y = Abstraction; + type P = Potential; + type Q = Potential; + type M = Metric; + + fn cost(&self) -> Probability { + self.plan.values().sum() + } + fn flow(&self, x: &Self::X, y: &Self::Y) -> Probability { + let ref index = Pair::from((x, y)); + self.plan + .get(index) + .copied() + .expect("missing in transport plan") + } + fn minimize(mut self) -> Self { + self.plan.clear(); + let ref mut pile = Potential::normalize(self.source); + let ref mut sink = Potential::normalize(self.target); + 'cost: while pile.values().any(|&dx| dx > 0.) { + 'pile: for (x, dx) in pile + .iter_mut() + .filter(|(_, dx)| **dx > 0.) + .map(|(&x, dx)| (x, dx)) + .collect::>() + { + match sink + .iter_mut() + .filter(|(_, dy)| **dy > 0.) + .map(|(&y, dy)| ((y, dy), self.metric.distance(&x, &y))) + .min_by(|(_, d1), (_, d2)| d1.partial_cmp(d2).unwrap()) + { + None => break 'cost, + Some(((y, dy), distance)) => { + let mass = Probability::min(*dx, *dy); + let pair = Pair::from((&x, &y)); + *dx -= mass; + *dy -= mass; + *self.plan.entry(pair).or_default() += mass * distance; + continue 'pile; + } + } + } + } + self + } +} + +impl<'a> From<(&'a Histogram, &'a Histogram, &'a Metric)> for Heuristic<'a> { + fn from((source, target, metric): (&'a Histogram, &'a Histogram, &'a Metric)) -> Self { + Self { + plan: BTreeMap::default(), + metric, + source, + target, + } + } +} diff --git a/src/clustering/histogram.rs b/src/clustering/histogram.rs new file mode 100644 index 00000000..afba8af0 --- /dev/null +++ b/src/clustering/histogram.rs @@ -0,0 +1,184 @@ +use crate::cards::observation::Observation; +use crate::clustering::abstraction::Abstraction; +use crate::transport::density::Density; +use crate::Arbitrary; +use crate::Equity; +use crate::Probability; +use std::collections::BTreeMap; +use std::ops::AddAssign; + +/// A distribution over arbitrary Abstractions. +/// +/// The sum of the weights is the total number of samples. +/// The weight of an abstraction is the number of times it was sampled. +#[derive(Debug, Default, Clone)] +pub struct Histogram { + mass: usize, + counts: BTreeMap, +} + +impl Histogram { + pub fn set(&mut self, abs: Abstraction, count: usize) { + self.counts.insert(abs, count); + self.mass += count; + } + /// the weight of a given Abstraction. returns 0 if the Abstraction was never witnessed. + pub fn density(&self, x: &Abstraction) -> Probability { + self.counts.get(x).copied().unwrap_or(0usize) as f32 / self.mass as f32 + } + /// all witnessed Abstractions in the support + pub fn support(&self) -> impl Iterator { + self.counts.keys() + } + /// size of the support + pub fn n(&self) -> usize { + self.counts.len() + } + + /// insert the Abstraction into our support, + /// incrementing its local weight, + /// incrementing our global norm. + pub fn increment(mut self, abstraction: Abstraction) -> Self { + self.mass.add_assign(1usize); + self.counts + .entry(abstraction) + .or_insert(0usize) + .add_assign(1usize); + self + } + /// absorb the other histogram into this one. + pub fn absorb(&mut self, other: &Self) { + self.mass += other.mass; + for (key, count) in other.counts.iter() { + self.counts.entry(*key).or_insert(0usize).add_assign(*count); + } + } + + /// it is useful in EMD calculation + /// to know if we're dealing with ::Equity or ::Random + /// Abstraction variants, so we expose this method to + /// infer the type of Abstraction contained by this Histogram. + pub fn peek(&self) -> &Abstraction { + self.counts.keys().next().expect("non empty histogram") + } + /// exhaustive calculation of all + /// possible Rivers and Showdowns, + /// naive to strategy of course. + pub fn equity(&self) -> Equity { + assert!(matches!(self.peek(), Abstraction::Percent(_))); + self.pdf().iter().map(|(x, y)| x * y).sum() + } + /// this yields the posterior equity distribution + /// at Street::Turn. + /// this is the only street we explicitly can calculate + /// the Probability of transitioning into a Probability + /// Probability -> Probability + /// vs Probability -> Abstraction + /// hence a distribution over showdown equities. + pub fn pdf(&self) -> Vec<(Equity, Probability)> { + assert!(matches!(self.peek(), Abstraction::Percent(_))); + self.counts + .iter() + .map(|(&key, &value)| (key, value as f32 / self.mass as f32)) + .map(|(k, v)| (Equity::from(k), Probability::from(v))) + .collect() + } + + /// owned vector of Abstractions and their densities + /// sorted by density in descending order (most likely first) + pub fn distribution(&self) -> Vec<(Abstraction, Probability)> { + let mut distribution = self + .support() + .copied() + .map(|abs| (abs, self.density(&abs))) + .collect::>(); + distribution.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + distribution + } +} + +impl From for Histogram { + fn from(ref turn: Observation) -> Self { + assert!(turn.street() == crate::cards::street::Street::Turn); + turn.children() + .map(|river| Abstraction::from(river.equity())) + .fold(Self::default(), |hist, abs| hist.increment(abs)) + } +} + +impl From> for Histogram { + fn from(a: Vec) -> Self { + a.into_iter() + .fold(Self::default(), |hist, abs| hist.increment(abs)) + } +} + +impl Density for Histogram { + type S = Abstraction; + fn density(&self, x: &Self::S) -> f32 { + self.density(x) + } + fn support(&self) -> impl Iterator { + self.support() + } +} + +impl Arbitrary for Histogram { + fn random() -> Self { + const S: usize = 16; + const N: usize = 64; + (0..) + .map(|_| Abstraction::random()) + .filter(|a| a.street() == crate::cards::street::Street::Flop) + .take(S) + .collect::>() + .into_iter() + .cycle() + .filter(|_| rand::random::()) + .take(N) + .fold(Self::default(), |h, a| h.increment(a)) + } +} + +impl std::fmt::Display for Histogram { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + assert!(matches!(self.peek(), Abstraction::Percent(_))); + // 1. interpret each key of the Histogram as probability + // 2. they should already be sorted bc BTreeMap + let ref pdf = self.pdf(); + // 3. Create 32 bins for the x-axis + let n_x_bins = 32; + let ref mut bins = vec![0.0; n_x_bins]; + for (key, value) in pdf { + let x = key * n_x_bins as f32; + let x = x.floor() as usize; + let x = x.min(n_x_bins - 1); + bins[x] += value; + } + // 4. Print the histogram + writeln!(f)?; + let n_y_bins = 10; + for y in (1..=n_y_bins).rev() { + for bin in bins.iter().copied() { + if bin >= y as f32 / n_y_bins as f32 { + write!(f, "█")?; + } else if bin >= y as f32 / n_y_bins as f32 - 0.75 / n_y_bins as f32 { + write!(f, "▆")?; + } else if bin >= y as f32 / n_y_bins as f32 - 0.50 / n_y_bins as f32 { + write!(f, "▄")?; + } else if bin >= y as f32 / n_y_bins as f32 - 0.25 / n_y_bins as f32 { + write!(f, "▂")?; + } else { + write!(f, " ")?; + } + } + writeln!(f)?; + } + // 5. Print x-axis + for _ in 0..n_x_bins { + write!(f, "-")?; + } + // 6. flush to STDOUT + Ok(()) + } +} diff --git a/src/clustering/layer.rs b/src/clustering/layer.rs new file mode 100644 index 00000000..1e9b9d9b --- /dev/null +++ b/src/clustering/layer.rs @@ -0,0 +1,286 @@ +use super::abstraction::Abstraction; +use super::histogram::Histogram; +use super::lookup::Lookup; +use super::metric::Metric; +use super::pair::Pair; +use super::transitions::Decomp; +use crate::cards::isomorphism::Isomorphism; +use crate::cards::isomorphisms::IsomorphismIterator; +use crate::cards::street::Street; +use crate::Energy; +use rand::distributions::Distribution; +use rand::distributions::WeightedIndex; +use std::collections::BTreeMap; + +type Neighbor = (usize, f32); + +pub struct Layer { + street: Street, + metric: Metric, + points: Vec, // positioned by Isomorphism + kmeans: Vec, // positioned by K-means abstraction +} + +impl Layer { + #[cfg(feature = "native")] + /// all-in-one entry point for learning the kmeans abstraction and + /// writing to disk in pgcopy + pub fn learn() { + use crate::save::upload::Table; + Street::all() + .into_iter() + .rev() + .filter(|&&s| !Self::done(s)) + .map(|&s| Self::grow(s).save()) + .count(); + } + + /// reference to the all points up to isomorphism + fn points(&self) -> &Vec /* N */ { + &self.points + } + /// reference to the current kmeans centorid histograms + fn kmeans(&self) -> &Vec /* K */ { + &self.kmeans + } + + #[cfg(feature = "native")] + /// primary clustering algorithm loop + fn cluster(mut self) -> Self { + log::info!("{:<32}{:<32}", "initialize kmeans", self.street()); + let ref mut init = self.init(); + let ref mut last = self.kmeans; + std::mem::swap(init, last); + log::info!("{:<32}{:<32}", "clustering kmeans", self.street()); + let t = self.street().t(); + let progress = crate::progress(t); + for _ in 0..t { + let ref mut next = self.next(); + let ref mut last = self.kmeans; + std::mem::swap(next, last); + progress.inc(1); + } + progress.finish(); + println!(); + self + } + + #[cfg(feature = "native")] + /// initializes the centroids for k-means clustering using the k-means++ algorithm + /// 1. choose 1st centroid randomly from the dataset + /// 2. choose nth centroid with probability proportional to squared distance of nearest neighbors + /// 3. collect histograms and label with arbitrary (random) `Abstraction`s + fn init(&self) -> Vec /* K */ { + use rand::rngs::SmallRng; + use rand::SeedableRng; + use rayon::iter::IntoParallelRefIterator; + use rayon::iter::ParallelIterator; + use std::hash::DefaultHasher; + use std::hash::Hash; + use std::hash::Hasher; + // don't do any abstraction on preflop + let k = self.street().k(); + let n = self.points().len(); + if self.street() == Street::Pref { + assert!(n == k); + return self.points().clone(); + } + // deterministic pseudo-random clustering + let ref mut hasher = DefaultHasher::default(); + self.street().hash(hasher); + let ref mut rng = SmallRng::seed_from_u64(hasher.finish()); + // kmeans++ initialization + let progress = crate::progress(k * n); + let mut potentials = vec![1.; n]; + let mut histograms = Vec::new(); + while histograms.len() < k { + let i = WeightedIndex::new(potentials.iter()) + .expect("valid weights array") + .sample(rng); + let x = self + .points() + .get(i) + .expect("sharing index with outer layer"); + histograms.push(x.clone()); + potentials[i] = 0.; + potentials = self + .points() + .par_iter() + .map(|h| self.emd(x, h)) + .map(|p| p * p) + .inspect(|_| progress.inc(1)) + .collect::>() + .iter() + .zip(potentials.iter()) + .map(|(d0, d1)| Energy::min(*d0, *d1)) + .collect::>(); + } + progress.finish(); + println!(); + histograms + } + + #[cfg(feature = "native")] + /// calculates the next step of the kmeans iteration by + /// determining K * N optimal transport calculations and + /// taking the nearest neighbor + fn next(&self) -> Vec /* K */ { + use rayon::iter::IntoParallelRefIterator; + use rayon::iter::ParallelIterator; + let k = self.street().k(); + let mut loss = 0f32; + let mut centroids = vec![Histogram::default(); k]; + // assign points to nearest neighbors + for (point, (neighbor, distance)) in self + .points() + .par_iter() + .map(|h| (h, self.neighborhood(h))) + .collect::>() + .into_iter() + { + loss = loss + distance * distance; + centroids + .get_mut(neighbor) + .expect("index from neighbor calculation") + .absorb(point); + } + log::debug!( + "{:<32}{:<32}", + "abstraction cluster RMS error", + (loss / self.points().len() as f32).sqrt() + ); + centroids + } + + /// wrawpper for distance metric calculations + fn emd(&self, x: &Histogram, y: &Histogram) -> Energy { + self.metric.emd(x, y) + } + /// because we have fixed-order Abstractions that are determined by + /// street and K-index, we should encapsulate the self.street depenency + fn abstraction(&self, i: usize) -> Abstraction { + Abstraction::from((self.street(), i)) + } + /// calculates nearest neighbor and separation distance for a Histogram + fn neighborhood(&self, x: &Histogram) -> Neighbor { + self.kmeans() + .iter() + .enumerate() + .map(|(k, h)| (k, self.emd(x, h))) + .min_by(|(_, dx), (_, dy)| dx.partial_cmp(dy).unwrap()) + .expect("find nearest neighbor") + .into() + } + + /// reference to current street + fn street(&self) -> Street { + self.street + } + /// take outer triangular product of current learned kmeans + /// Histograms, using whatever is stored as the future metric + fn metric(&self) -> Metric { + log::info!("{:<32}{:<32}", "calculating metric", self.street()); + let mut metric = BTreeMap::new(); + for (i, x) in self.kmeans.iter().enumerate() { + for (j, y) in self.kmeans.iter().enumerate() { + if i > j { + let ref a = self.abstraction(i); + let ref b = self.abstraction(j); + let index = Pair::from((a, b)); + let distance = self.metric.emd(x, y) + self.metric.emd(y, x); + let distance = distance / 2.; + metric.insert(index, distance); + } + } + } + Metric::from(metric) + } + /// in ObsIterator order, get a mapping of + /// Isomorphism -> Abstraction + #[cfg(feature = "native")] + fn lookup(&self) -> Lookup { + log::info!("{:<32}{:<32}", "calculating lookup", self.street()); + use crate::save::upload::Table; + use rayon::iter::IntoParallelRefIterator; + use rayon::iter::ParallelIterator; + let street = self.street(); + match street { + Street::Pref | Street::Rive => Lookup::grow(street), + Street::Flop | Street::Turn => self + .points() + .par_iter() + .map(|h| self.neighborhood(h)) + .collect::>() + .into_iter() + .map(|(k, _)| self.abstraction(k)) + .zip(IsomorphismIterator::from(street)) + .map(|(abs, iso)| (iso, abs)) + .collect::>() + .into(), + } + } + /// in AbsIterator order, get a mapping of + /// Abstraction -> Histogram + /// end-of-recurse call + fn decomp(&self) -> Decomp { + log::info!("{:<32}{:<32}", "calculating transitions", self.street()); + self.kmeans() + .iter() + .cloned() + .enumerate() + .map(|(k, centroid)| (self.abstraction(k), centroid)) + .collect::>() + .into() + } +} + +#[cfg(feature = "native")] +impl crate::save::upload::Table for Layer { + fn done(street: Street) -> bool { + Lookup::done(street) && Decomp::done(street) && Metric::done(street) + } + fn save(&self) { + self.metric().save(); + self.lookup().save(); + self.decomp().save(); + } + fn grow(street: Street) -> Self { + let layer = match street { + Street::Rive => Self { + street, + kmeans: Vec::default(), + points: Vec::default(), + metric: Metric::default(), + }, + _ => Self { + street, + kmeans: Vec::default(), + points: Lookup::load(street.next()).projections(), + metric: Metric::load(street.next()), + }, + }; + layer.cluster() + } + + fn name() -> String { + unimplemented!() + } + fn copy() -> String { + unimplemented!() + } + fn load(_: Street) -> Self { + unimplemented!() + } + fn creates() -> String { + unimplemented!() + } + fn indices() -> String { + unimplemented!() + } + fn columns() -> &'static [tokio_postgres::types::Type] { + unimplemented!() + } + fn sources() -> Vec { + unimplemented!() + } +} diff --git a/src/clustering/lookup.rs b/src/clustering/lookup.rs new file mode 100644 index 00000000..31819d54 --- /dev/null +++ b/src/clustering/lookup.rs @@ -0,0 +1,209 @@ +use crate::cards::isomorphism::Isomorphism; +use crate::cards::isomorphisms::IsomorphismIterator; +use crate::cards::observation::Observation; +use crate::cards::street::Street; +use crate::clustering::abstraction::Abstraction; +use crate::clustering::histogram::Histogram; +use std::collections::BTreeMap; + +#[derive(Default)] +/// this is the grand lookup table for all the Isomorphism -> Abstraction +/// mappings. we spend a lot of compute over a lot of hands (all of them!) +/// to precompute this mapping. +pub struct Lookup(BTreeMap); + +impl From for BTreeMap { + fn from(lookup: Lookup) -> BTreeMap { + lookup.0 + } +} +impl From> for Lookup { + fn from(map: BTreeMap) -> Self { + Self(map) + } +} + +impl Lookup { + /// lookup the pre-computed abstraction for the outer observation + pub fn lookup(&self, obs: &Observation) -> Abstraction { + self.0 + .get(&Isomorphism::from(*obs)) + .cloned() + .expect(&format!("precomputed abstraction missing for {obs}")) + } + #[cfg(feature = "native")] + /// generate the entire space of inner layers + pub fn projections(&self) -> Vec { + use rayon::iter::IntoParallelIterator; + use rayon::iter::ParallelIterator; + IsomorphismIterator::from(self.street().prev()) + .collect::>() + .into_par_iter() + .map(|inner| self.future(&inner)) + .collect::>() + } + /// distribution over potential next states. this "layer locality" is what + /// makes imperfect recall hierarchical kmeans nice + fn future(&self, iso: &Isomorphism) -> Histogram { + assert!(iso.0.street() != Street::Rive); + iso.0 + .children() + .map(|o| self.lookup(&o)) + .collect::>() + .into() + } + fn street(&self) -> Street { + self.0.keys().next().expect("non empty").0.street() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[ignore] + #[test] + fn persistence() { + use crate::save::upload::Table; + let street = Street::Pref; + let lookup = Lookup::grow(street); + lookup.save(); + let loaded = Lookup::load(street); + std::iter::empty() + .chain(lookup.0.iter().zip(loaded.0.iter())) + .chain(loaded.0.iter().zip(lookup.0.iter())) + .all(|((s1, l1), (s2, l2))| s1 == s2 && l1 == l2); + } +} + +#[cfg(feature = "native")] +impl crate::save::upload::Table for Lookup { + fn name() -> String { + "isomorphism".to_string() + } + fn columns() -> &'static [tokio_postgres::types::Type] { + &[ + tokio_postgres::types::Type::INT8, + tokio_postgres::types::Type::INT8, + ] + } + fn sources() -> Vec { + Street::all() + .iter() + .rev() + .copied() + .map(Self::path) + .collect() + } + fn creates() -> String { + " + CREATE TABLE IF NOT EXISTS isomorphism ( + obs BIGINT, + abs BIGINT, + position INTEGER + );" + .to_string() + } + fn indices() -> String { + " + CREATE INDEX IF NOT EXISTS idx_isomorphism_covering ON isomorphism (obs, abs) INCLUDE (abs); + CREATE INDEX IF NOT EXISTS idx_isomorphism_abs_position ON isomorphism (abs, position); + CREATE INDEX IF NOT EXISTS idx_isomorphism_abs_obs ON isomorphism (abs, obs); + CREATE INDEX IF NOT EXISTS idx_isomorphism_abs ON isomorphism (abs); + CREATE INDEX IF NOT EXISTS idx_isomorphism_obs ON isomorphism (obs); + -- + WITH numbered AS ( + SELECT obs, abs, row_number() OVER (PARTITION BY abs ORDER BY obs) - 1 as rn + FROM isomorphism + ) + UPDATE isomorphism + SET position = numbered.rn + FROM numbered + WHERE isomorphism.obs = numbered.obs + AND isomorphism.abs = numbered.abs; + " + .to_string() + } + fn copy() -> String { + " + COPY isomorphism ( + obs, + abs + ) + FROM STDIN BINARY + " + .to_string() + } + /// abstractions for River are calculated once via obs.equity + /// abstractions for Preflop are cequivalent to just enumerating isomorphisms + fn grow(street: Street) -> Self { + use rayon::iter::IntoParallelIterator; + use rayon::iter::ParallelIterator; + match street { + Street::Rive => IsomorphismIterator::from(Street::Rive) + .collect::>() + .into_par_iter() + .map(|iso| (iso, Abstraction::from(iso.0.equity()))) + .collect::>() + .into(), + Street::Pref => IsomorphismIterator::from(Street::Pref) + .enumerate() + .map(|(k, iso)| (iso, Abstraction::from((Street::Pref, k)))) + .collect::>() + .into(), + Street::Flop | Street::Turn => panic!("lookup must be learned via layer for {street}"), + } + } + fn load(street: Street) -> Self { + let ref path = Self::path(street); + log::info!("{:<32}{:<32}", "loading lookup", path); + use byteorder::ReadBytesExt; + use byteorder::BE; + use std::fs::File; + use std::io::BufReader; + use std::io::Read; + use std::io::Seek; + use std::io::SeekFrom; + let ref file = File::open(path).expect(&format!("open {}", path)); + let mut lookup = BTreeMap::new(); + let mut reader = BufReader::new(file); + let ref mut buffer = [0u8; 2]; + reader.seek(SeekFrom::Start(19)).expect("seek past header"); + while reader.read_exact(buffer).is_ok() { + match u16::from_be_bytes(buffer.clone()) { + 2 => { + assert!(8 == reader.read_u32::().expect("observation length")); + let iso = reader.read_i64::().expect("read observation"); + assert!(8 == reader.read_u32::().expect("abstraction length")); + let abs = reader.read_i64::().expect("read abstraction"); + let observation = Isomorphism::from(iso); + let abstraction = Abstraction::from(abs); + lookup.insert(observation, abstraction); + } + 0xFFFF => break, + n => panic!("unexpected number of fields: {}", n), + } + } + Self(lookup) + } + fn save(&self) { + const N_FIELDS: u16 = 2; + let street = self.street(); + let ref path = Self::path(street); + let ref mut file = File::create(path).expect(&format!("touch {}", path)); + use byteorder::WriteBytesExt; + use byteorder::BE; + use std::fs::File; + use std::io::Write; + log::info!("{:<32}{:<32}", "saving lookup", path); + file.write_all(Self::header()).expect("header"); + for (Isomorphism(obs), abs) in self.0.iter() { + file.write_u16::(N_FIELDS).unwrap(); + file.write_u32::(size_of::() as u32).unwrap(); + file.write_i64::(i64::from(*obs)).unwrap(); + file.write_u32::(size_of::() as u32).unwrap(); + file.write_i64::(i64::from(*abs)).unwrap(); + } + file.write_u16::(Self::footer()).expect("trailer"); + } +} diff --git a/src/clustering/metric.rs b/src/clustering/metric.rs new file mode 100644 index 00000000..9f6301ac --- /dev/null +++ b/src/clustering/metric.rs @@ -0,0 +1,219 @@ +use super::equity::Equity; +use super::sinkhorn::Sinkhorn; +use crate::cards::street::Street; +use crate::clustering::abstraction::Abstraction; +use crate::clustering::histogram::Histogram; +use crate::clustering::pair::Pair; +use crate::transport::coupling::Coupling; +use crate::transport::measure::Measure; +use crate::Energy; +use std::collections::BTreeMap; + +/// Distance metric for kmeans clustering. +/// encapsulates distance between `Abstraction`s of the "previous" hierarchy, +/// as well as: distance between `Histogram`s of the "current" hierarchy. +#[derive(Default)] +pub struct Metric(BTreeMap); + +impl Measure for Metric { + type X = Abstraction; + type Y = Abstraction; + fn distance(&self, x: &Self::X, y: &Self::Y) -> Energy { + if x == y { + 0. + } else { + match (x, y) { + (Self::X::Learned(_), Self::Y::Learned(_)) => self.lookup(x, y), + (Self::X::Percent(_), Self::Y::Percent(_)) => Equity.distance(x, y), + (Self::X::Preflop(_), Self::Y::Preflop(_)) => unreachable!("no preflop distance"), + _ => unreachable!(), + } + } + } +} + +impl Metric { + fn lookup(&self, x: &Abstraction, y: &Abstraction) -> Energy { + self.0 + .get(&Pair::from((x, y))) + .copied() + .expect("missing abstraction pair") + } + + pub fn emd(&self, source: &Histogram, target: &Histogram) -> Energy { + match source.peek() { + Abstraction::Learned(_) => Sinkhorn::from((source, target, self)).minimize().cost(), + Abstraction::Percent(_) => Equity::variation(source, target), + Abstraction::Preflop(_) => unreachable!("no preflop emd"), + } + } + + /// we're assuming tht the street is being generated AFTER the learned kmeans + /// cluster distance calculation. so we should have (Street::K() choose 2) + /// entreis in our abstraction pair lookup table. + /// if this is off by just a few then it probably means a bunch of collisions + /// maybe i should determinsitcally seed kmeans process, could be cool for reproducability too + /// + /// TODO + /// + /// determine street dynamiccaly by checking for existence of XOR'ed abstraction pairs using + /// Abstraction::From(Street, Index) + /// + /// it's also not great that we are FORCED to have different number of abstractions + /// clusters K means for each street to avoid nC2 collisions !! + /// we should either just store Street as Self.1 or determine from XOR hits what street we're on + /// whichever solution should work with test case so we don't have to remove test case + /// to not overwrite existing metric. we like overwriting river.metric bc it can be empty + fn street(&self) -> Street { + fn choose_2(k: usize) -> usize { + k * (k.saturating_sub(1)) / 2 + } + match self.0.len() { + n if n == choose_2(Street::Rive.k()) => Street::Rive, + n if n == choose_2(Street::Turn.k()) => Street::Turn, + n if n == choose_2(Street::Flop.k()) => Street::Flop, + n if n == choose_2(Street::Pref.k()) => Street::Pref, + _ => Street::Rive, // assertion of no-collisions is convenient for tests + } + } +} + +#[cfg(feature = "native")] +impl crate::save::upload::Table for Metric { + fn name() -> String { + "metric".to_string() + } + fn columns() -> &'static [tokio_postgres::types::Type] { + &[ + tokio_postgres::types::Type::INT8, + tokio_postgres::types::Type::FLOAT4, + ] + } + fn sources() -> Vec { + Street::all() + .iter() + .rev() + .copied() + .map(Self::path) + .collect() + } + fn copy() -> String { + "COPY metric ( + xor, + dx + ) + FROM STDIN BINARY + " + .to_string() + } + fn creates() -> String { + " + CREATE TABLE IF NOT EXISTS metric ( + xor BIGINT, + dx REAL + ); + " + .to_string() + } + fn indices() -> String { + " + INSERT INTO metric ( + xor, + dx + ) VALUES ( + 0, + 0 + ); + CREATE INDEX IF NOT EXISTS idx_metric_xor ON metric (xor); + CREATE INDEX IF NOT EXISTS idx_metric_dx ON metric (dx); + " + .to_string() + } + fn load(street: Street) -> Self { + let ref path = Self::path(street); + log::info!("{:<32}{:<32}", "loading metric", path); + use byteorder::ReadBytesExt; + use byteorder::BE; + use std::fs::File; + use std::io::BufReader; + use std::io::Read; + use std::io::Seek; + use std::io::SeekFrom; + let ref file = File::open(path).expect(&format!("open {}", path)); + let mut metric = BTreeMap::new(); + let mut reader = BufReader::new(file); + let ref mut buffer = [0u8; 2]; + reader.seek(SeekFrom::Start(19)).expect("seek past header"); + while reader.read_exact(buffer).is_ok() { + match u16::from_be_bytes(buffer.clone()) { + 2 => { + reader.read_u32::().expect("pair length"); + let pair = reader.read_i64::().expect("read pair"); + reader.read_u32::().expect("distance length"); + let dist = reader.read_f32::().expect("read distance"); + metric.insert(Pair::from(pair), dist); + continue; + } + 0xFFFF => break, + n => panic!("unexpected number of fields: {}", n), + } + } + Self(metric) + } + fn save(&self) { + const N_FIELDS: u16 = 2; + let street = self.street(); + let ref path = Self::path(street); + let ref mut file = File::create(path).expect(&format!("touch {}", path)); + use byteorder::WriteBytesExt; + use byteorder::BE; + use std::fs::File; + use std::io::Write; + log::info!("{:<32}{:<32}", "saving metric", path); + file.write_all(Self::header()).expect("header"); + for (pair, distance) in self.0.iter() { + file.write_u16::(N_FIELDS).unwrap(); + file.write_u32::(size_of::() as u32).unwrap(); + file.write_i64::(i64::from(*pair)).unwrap(); + file.write_u32::(size_of::() as u32).unwrap(); + file.write_f32::(*distance).unwrap(); + } + file.write_u16::(Self::footer()).expect("trailer"); + } + fn grow(_: Street) -> Self { + unreachable!("metric must be learned from kmeans clustering") + } +} +impl From> for Metric { + fn from(metric: BTreeMap) -> Self { + let max = metric.values().copied().fold(f32::MIN_POSITIVE, f32::max); + Self( + metric + .into_iter() + .map(|(index, distance)| (index, distance / max)) + .collect(), + ) + } +} +#[cfg(test)] +mod tests { + use super::*; + use crate::cards::street::Street; + use crate::clustering::emd::EMD; + use crate::save::upload::Table; + use crate::Arbitrary; + + #[ignore] + #[test] + fn persistence() { + let street = Street::Rive; + let emd = EMD::random(); + let save = emd.metric(); + save.save(); + let load = Metric::load(street); + std::iter::empty() + .chain(save.0.iter().zip(load.0.iter())) + .chain(load.0.iter().zip(save.0.iter())) + .all(|((s1, l1), (s2, l2))| s1 == s2 && l1 == l2); + } +} diff --git a/src/clustering/mod.rs b/src/clustering/mod.rs new file mode 100644 index 00000000..21fa218b --- /dev/null +++ b/src/clustering/mod.rs @@ -0,0 +1,13 @@ +pub mod abstraction; +pub mod emd; +pub mod equity; +pub mod heuristic; +pub mod histogram; +pub mod layer; +pub mod lookup; +pub mod metric; +pub mod pair; +pub mod potential; +pub mod progress; +pub mod sinkhorn; +pub mod transitions; diff --git a/src/clustering/pair.rs b/src/clustering/pair.rs new file mode 100644 index 00000000..f945b4bf --- /dev/null +++ b/src/clustering/pair.rs @@ -0,0 +1,24 @@ +use crate::clustering::abstraction::Abstraction; +use crate::transport::support::Support; + +/// A unique identifier for a pair of abstractions. +#[derive(Default, Copy, Clone, Hash, Eq, PartialEq, PartialOrd, Ord, Debug)] +pub struct Pair(u64); + +impl From<(&Abstraction, &Abstraction)> for Pair { + fn from((a, b): (&Abstraction, &Abstraction)) -> Self { + Self(u64::from(*a) ^ u64::from(*b)) + } +} +impl From for i64 { + fn from(pair: Pair) -> Self { + pair.0 as i64 + } +} +impl From for Pair { + fn from(i: i64) -> Self { + Self(i as u64) + } +} + +impl Support for Pair {} diff --git a/src/clustering/potential.rs b/src/clustering/potential.rs new file mode 100644 index 00000000..7b4f45ab --- /dev/null +++ b/src/clustering/potential.rs @@ -0,0 +1,81 @@ +use super::abstraction::Abstraction; +use super::histogram::Histogram; +use crate::transport::density::Density; +use crate::Entropy; +use crate::Probability; +use std::collections::BTreeMap; +use std::ops::AddAssign; + +/// using this to represent an arbitrary instance of the Kontorovich-Rubinstein +/// potential formulation of the optimal transport problem. +/// this structure can also be treated as a normalized distribution over Abstractions. +pub struct Potential(BTreeMap); + +impl Potential { + /// useful for Heuristic where we don't need to allocate. + /// i guess we don't need to allocate in Sinkhorn either. but it's + /// nbd, + we might want to calaculate deltas between new and old potentials + pub fn iter_mut(&mut self) -> impl Iterator { + self.0.iter_mut() + } + + /// also only useful for Heuristic + pub fn values(&self) -> impl Iterator { + self.0.values() + } + + pub fn increment(&mut self, i: &Abstraction, delta: Entropy) { + self.0 + .get_mut(i) + .expect("fixed abstraction space") + .add_assign(delta) + } + + /// zero potential over the support, in log prob space + pub fn zeroes(h: &Histogram) -> Self { + Self(h.support().copied().map(|x| (x, 0.)).collect()) + } + + /// uniform distribution over the support, in log prob space + pub fn uniform(h: &Histogram) -> Self { + Self( + h.support() + .copied() + .map(|x| (x, h.n())) + .map(|(x, y)| (x, 1. / y as Probability)) + .map(|(x, y)| (x, y.ln() as Entropy)) + .collect::>(), + ) + } + + /// unit normalized distribution over the support + pub fn normalize(h: &Histogram) -> Self { + Self( + h.support() + .copied() + .map(|x| (x, h.density(&x))) + .collect::>(), + ) + } +} + +impl From> for Potential { + fn from(potential: BTreeMap) -> Self { + assert!(potential.len() > 0); + Self(potential) + } +} + +impl Density for Potential { + type S = Abstraction; + fn density(&self, x: &Self::S) -> Entropy { + self.0 + .get(x) + .copied() + .inspect(|p| assert!(p.is_finite(), "density overflow")) + .expect("abstraction in potential") + } + fn support(&self) -> impl Iterator { + self.0.keys() + } +} diff --git a/src/clustering/progress.rs b/src/clustering/progress.rs new file mode 100644 index 00000000..cf90ea9c --- /dev/null +++ b/src/clustering/progress.rs @@ -0,0 +1,41 @@ +#[deprecated] +use std::time::Instant; + +/// A struct to track and display progress of a long-running operation. +pub struct Progress { + total: usize, + check: usize, + ticks: usize, + begin: Instant, + delta: Instant, +} +impl Progress { + pub fn new(total: usize, n: usize) -> Self { + let check = (total / n).min(1); + let now = Instant::now(); + Self { + total, + check, + ticks: 0, + begin: now, + delta: now, + } + } + pub fn tick(&mut self) { + self.ticks += 1; + if self.ticks % self.check == 0 { + let now = Instant::now(); + let total_t = now.duration_since(self.begin); + let delta_t = now.duration_since(self.delta); + self.delta = now; + log::info!( + "progress: {:8.0?} {:>10} {:6.2}% mean {:6.0} last {:6.0}", + total_t, + self.ticks, + self.ticks as f32 / self.total as f32 * 100f32, + self.ticks as f32 / total_t.as_secs_f32(), + self.check as f32 / delta_t.as_secs_f32(), + ); + } + } +} diff --git a/src/clustering/sinkhorn.rs b/src/clustering/sinkhorn.rs new file mode 100644 index 00000000..90b0fa49 --- /dev/null +++ b/src/clustering/sinkhorn.rs @@ -0,0 +1,141 @@ +use super::abstraction::Abstraction; +use super::histogram::Histogram; +use super::metric::Metric; +use super::potential::Potential; +use crate::transport::coupling::Coupling; +use crate::transport::density::Density; +use crate::transport::measure::Measure; +use crate::Energy; +use crate::Entropy; +use std::collections::BTreeMap; + +/// using this to represent an arbitrary instance of the Kontorovich-Rubinstein +/// potential formulation of the optimal transport problem. +pub struct Sinkhorn<'a> { + metric: &'a Metric, + mu: &'a Histogram, + nu: &'a Histogram, + lhs: Potential, + rhs: Potential, +} + +impl Sinkhorn<'_> { + /// calculate ε-minimizing coupling by scaling potentials + fn sinkhorn(&mut self) { + #[allow(unused)] + for t in 0..self.iterations() { + let ref mut next = self.lhs(); + let ref mut prev = self.lhs; + let lhs_err = Self::delta(prev, next); + std::mem::swap(prev, next); + let ref mut next = self.rhs(); + let ref mut prev = self.rhs; + let rhs_err = Self::delta(prev, next); + std::mem::swap(prev, next); + if lhs_err + rhs_err < self.tolerance() { + break; + } + } + } + /// calculate next iteration of LHS and RHS potentials after Sinkhorn scaling + fn lhs(&self) -> Potential { + Potential::from( + self.lhs + .support() + .copied() + .map(|x| (x, self.divergence(&x, &self.mu, &self.rhs))) + .inspect(|(_, dx)| assert!(dx.is_finite(), "lhs entropy overflow")) + .collect::>(), + ) + } + /// calculate next iteration of LHS and RHS potentials after Sinkhorn scaling + fn rhs(&self) -> Potential { + Potential::from( + self.rhs + .support() + .copied() + .map(|x| (x, self.divergence(&x, &self.nu, &self.lhs))) + .inspect(|(_, dx)| assert!(dx.is_finite(), "rhs entropy overflow")) + .collect::>(), + ) + } + /// the coupling formed by joint distribution of LHS and RHS potentials + fn coupling(&self, x: &Abstraction, y: &Abstraction) -> Energy { + (self.lhs.density(x) + self.rhs.density(y) - self.regularization(x, y)).exp() + } + /// update the potential energy on a given side + /// histogram is where a: Abstraction is supported + /// potential is the distribution that is being integrated against + /// so we scale PDF(A::histogram | t) by the mass of the PDF(B::potential | t, x == a) + /// not sure yet why i'm calling it entropy but it's giving partition function. + /// actually now that i think of it this might be KL div / relative entropy + fn divergence(&self, x: &Abstraction, histogram: &Histogram, potential: &Potential) -> Entropy { + histogram.density(x).ln() + - potential + .support() + .map(|y| potential.density(y) - self.regularization(x, y)) + .map(|e| e.exp()) + .map(|e| e.max(Energy::MIN_POSITIVE)) + .sum::() + .ln() + } + /// distance in fixed temperature exponent space + fn regularization(&self, x: &Abstraction, y: &Abstraction) -> Entropy { + self.metric.distance(x, y) / self.temperature() + } + /// stopping criteria + fn delta(prev: &Potential, next: &Potential) -> Energy { + prev.support() + .map(|x| next.density(x).exp() - prev.density(x).exp()) + .map(|e| e.abs()) + .sum::() + } + /// hyperparameter that determines strength of entropic regularization. incorrect units but whatever + const fn temperature(&self) -> Entropy { + crate::SINKHORN_TEMPERATURE + } + /// hyperparameter that determines maximum number of iterations + const fn iterations(&self) -> usize { + crate::SINKHORN_ITERATIONS + } + /// hyperparameter that determines stopping criteria + const fn tolerance(&self) -> Energy { + crate::SINKHORN_TOLERANCE + } +} + +impl Coupling for Sinkhorn<'_> { + type X = Abstraction; + type Y = Abstraction; + type P = Potential; + type Q = Potential; + type M = Metric; + + fn minimize(mut self) -> Self { + self.sinkhorn(); + self + } + fn flow(&self, x: &Self::X, y: &Self::Y) -> Energy { + self.coupling(x, y) * self.metric.distance(x, y) + } + fn cost(&self) -> Energy { + self.lhs + .support() + .flat_map(|x| self.rhs.support().map(move |y| (x, y))) + .map(|(x, y)| self.flow(x, y)) + .inspect(|x| assert!(x.is_finite())) + .sum::() + } +} + +impl<'a> From<(&'a Histogram, &'a Histogram, &'a Metric)> for Sinkhorn<'a> { + fn from((mu, nu, metric): (&'a Histogram, &'a Histogram, &'a Metric)) -> Self { + Self { + metric, + mu, + nu, + lhs: Potential::uniform(mu), + rhs: Potential::uniform(nu), + } + } +} diff --git a/src/clustering/transitions.rs b/src/clustering/transitions.rs new file mode 100644 index 00000000..27d52ebc --- /dev/null +++ b/src/clustering/transitions.rs @@ -0,0 +1,135 @@ +use crate::cards::street::Street; +use crate::clustering::abstraction::Abstraction; +use crate::clustering::histogram::Histogram; +use std::collections::BTreeMap; +use std::mem::size_of; +use std::u16; + +pub struct Decomp(BTreeMap); + +impl From> for Decomp { + fn from(map: BTreeMap) -> Self { + Self(map) + } +} + +#[cfg(feature = "native")] +impl crate::save::upload::Table for Decomp { + fn name() -> String { + "transitions".to_string() + } + fn grow(street: Street) -> Self { + unreachable!("you have no business making transition table from scratch {street}") + } + fn columns() -> &'static [tokio_postgres::types::Type] { + &[ + tokio_postgres::types::Type::INT8, + tokio_postgres::types::Type::INT8, + tokio_postgres::types::Type::FLOAT4, + ] + } + fn sources() -> Vec { + Street::all() + .iter() + .rev() + .copied() + .map(Self::path) + .collect() + } + fn creates() -> String { + " + CREATE TABLE IF NOT EXISTS transitions ( + prev BIGINT, + next BIGINT, + dx REAL + );" + .to_string() + } + fn indices() -> String { + " + CREATE INDEX IF NOT EXISTS idx_transitions_dx ON transitions(dx); + CREATE INDEX IF NOT EXISTS idx_transitions_prev_dx ON transitions(prev, dx); + CREATE INDEX IF NOT EXISTS idx_transitions_next_dx ON transitions(next, dx); + CREATE INDEX IF NOT EXISTS idx_transitions_prev_next ON transitions(prev, next); + CREATE INDEX IF NOT EXISTS idx_transitions_next_prev ON transitions(next, prev); + " + .to_string() + } + fn copy() -> String { + " + COPY transitions ( + prev, + next, + dx + ) FROM STDIN BINARY + " + .to_string() + } + fn load(street: Street) -> Self { + let ref path = Self::path(street); + log::info!("{:<32}{:<32}", "loading transitions", path); + use byteorder::ReadBytesExt; + use byteorder::BE; + use std::fs::File; + use std::io::BufReader; + use std::io::Read; + use std::io::Seek; + use std::io::SeekFrom; + let ref mass = street.n_children() as f32; + let ref file = File::open(path).expect(&format!("open {}", path)); + let mut decomp = BTreeMap::new(); + let mut reader = BufReader::new(file); + let ref mut buffer = [0u8; 2]; + reader.seek(SeekFrom::Start(19)).expect("seek past header"); + while reader.read_exact(buffer).is_ok() { + match u16::from_be_bytes(buffer.clone()) { + 3 => { + reader.read_u32::().expect("from abstraction"); + let from = reader.read_i64::().expect("read from abstraction"); + reader.read_u32::().expect("into abstraction"); + let into = reader.read_i64::().expect("read into abstraction"); + reader.read_u32::().expect("weight"); + let weight = reader.read_f32::().expect("read weight"); + decomp + .entry(Abstraction::from(from)) + .or_insert_with(Histogram::default) + .set(Abstraction::from(into), (weight * mass) as usize); + continue; + } + 0xFFFF => break, + n => panic!("unexpected number of fields: {}", n), + } + } + Self(decomp) + } + fn save(&self) { + const N_FIELDS: u16 = 3; + let street = self + .0 + .keys() + .next() + .copied() + .unwrap_or_else(|| Abstraction::from(0f32)) + .street(); + let ref path = Self::path(street); + let ref mut file = File::create(path).expect(&format!("touch {}", path)); + use byteorder::WriteBytesExt; + use byteorder::BE; + use std::fs::File; + use std::io::Write; + log::info!("{:<32}{:<32}", "saving transition", path); + file.write_all(Self::header()).expect("header"); + for (from, histogram) in self.0.iter() { + for into in histogram.support() { + file.write_u16::(N_FIELDS).unwrap(); + file.write_u32::(size_of::() as u32).unwrap(); + file.write_i64::(i64::from(*from)).unwrap(); + file.write_u32::(size_of::() as u32).unwrap(); + file.write_i64::(i64::from(*into)).unwrap(); + file.write_u32::(size_of::() as u32).unwrap(); + file.write_f32::(histogram.density(into)).unwrap(); + } + } + file.write_u16::(Self::footer()).expect("trailer"); + } +} diff --git a/src/evaluation/evaluation.rs b/src/evaluation/evaluation.rs deleted file mode 100644 index 97f855ca..00000000 --- a/src/evaluation/evaluation.rs +++ /dev/null @@ -1,194 +0,0 @@ -/// we can evaluate a vector of cards lazily by chaining find_* hand rank methods, -/// or we can use ~500MB of memory to store a table of all uniquely evaluated hands. -/// this is a strong tradeoff between space and time complexity. -/// i'll maybe precalculate results and implement LookupEvaluator later - -pub trait Evaluator { - fn strength(cards: Vec<&Card>) -> Strength; -} - -pub struct LazyEvaluator { - hand_set: u32, // which ranks are in the hand - suit_set: [u32; 4], // which ranks are in suits are in the hand - rank_counts: [u8; 13], // how many i ranks are in the hand. neglect suit - suit_counts: [u8; 4], // how many i suits are in the hand. neglect rank -} - -impl Evaluator for LazyEvaluator { - fn strength(cards: Vec<&Card>) -> Strength { - let this = Self::new(&cards); - let best_hand = this.find_best_hand(); - let kickers = this.find_kickers(best_hand); - Strength::new(best_hand, kickers) - } -} - -impl LazyEvaluator { - fn new(cards: &Vec<&Card>) -> Self { - Self { - hand_set: Self::u32_hand(cards), - suit_set: Self::u32_suit(cards), - rank_counts: Self::rank_counts(cards), - suit_counts: Self::suit_counts(cards), - } - } - - fn find_best_hand(&self) -> BestHand { - self.find_flush() - .or_else(|| self.find_4_oak()) - .or_else(|| self.find_3_oak_2_oak()) - .or_else(|| self.find_straight()) - .or_else(|| self.find_3_oak()) - .or_else(|| self.find_2_oak_2_oak()) - .or_else(|| self.find_2_oak()) - .or_else(|| self.find_1_oak()) - .unwrap() - } - fn find_kickers(&self, strength: BestHand) -> Kickers { - let n = match strength { - BestHand::HighCard(_) => 4, - BestHand::OnePair(_) => 3, - BestHand::ThreeOAK(_) => 2, - BestHand::FourOAK(_) => 1, - BestHand::TwoPair(_, _) => 1, - _ => return Kickers(Vec::new()), - }; - Kickers( - self.rank_counts - .iter() - .enumerate() - .rev() - .filter(|(_, x)| **x > 0) - .filter(|(r, _)| *r != strength.primary() as usize) - .filter(|(r, _)| *r != strength.secondary() as usize) - .map(|(i, _)| Rank::from(i as u8)) - .take(n) - .collect::>(), - ) - } - - fn find_flush(&self) -> Option { - self.find_suit_of_flush().and_then(|suit| { - self.find_rank_of_straight_flush(suit) - .map(BestHand::StraightFlush) - .or_else(|| Some(BestHand::Flush(Rank::from(self.suit_set[suit as usize])))) - }) - } - fn find_straight(&self) -> Option { - self.find_rank_of_straight(self.hand_set) - .map(|rank| BestHand::Straight(rank)) - } - fn find_3_oak_2_oak(&self) -> Option { - self.find_rank_of_n_oak(3).and_then(|triple| { - self.find_rank_of_n_oak_below(2, triple as usize) - .map(|couple| BestHand::FullHouse(triple, couple)) - }) - } - fn find_2_oak_2_oak(&self) -> Option { - self.find_rank_of_n_oak(2).and_then(|high| { - self.find_rank_of_n_oak_below(2, high as usize) - .map(|next| BestHand::TwoPair(high, next)) - .or_else(|| Some(BestHand::OnePair(high))) - }) - } - fn find_4_oak(&self) -> Option { - self.find_rank_of_n_oak(4) - .map(|rank| BestHand::FourOAK(rank)) - } - fn find_3_oak(&self) -> Option { - self.find_rank_of_n_oak(3) - .map(|rank| BestHand::ThreeOAK(rank)) - } - fn find_2_oak(&self) -> Option { - // lowkey unreachable because TwoPair short circuits - self.find_rank_of_n_oak(2) - .map(|rank| BestHand::OnePair(rank)) - } - fn find_1_oak(&self) -> Option { - self.find_rank_of_n_oak(1) - .map(|rank| BestHand::HighCard(rank)) - } - - fn find_suit_of_flush(&self) -> Option { - self.suit_counts - .iter() - .position(|&n| n >= 5) - .map(|i| Suit::from(i as u8)) - } - fn find_rank_of_straight_flush(&self, suit: Suit) -> Option { - let u32_flush = self.suit_set[suit as usize]; - self.find_rank_of_straight(u32_flush) - } - fn find_rank_of_straight(&self, u32_cards: u32) -> Option { - let mut mask = u32_cards; - mask &= mask << 1; - mask &= mask << 1; - mask &= mask << 1; - mask &= mask << 1; - if mask.count_ones() > 0 { - return Some(Rank::from(mask)); - } else if Self::wheel() == (Self::wheel() & u32_cards) { - return Some(Rank::Five); - } else { - return None; - } - } - fn find_rank_of_n_oak(&self, n: u8) -> Option { - self.find_rank_of_n_oak_below(n, 13) - } - fn find_rank_of_n_oak_below(&self, n: u8, high: usize) -> Option { - self.rank_counts - .iter() - .take(high) - .rev() - .position(|&r| r >= n) - .map(|i| high - i - 1) - .map(|r| Rank::from(r as u8)) - } - - fn rank_counts(cards: &Vec<&Card>) -> [u8; 13] { - let mut rank_counts = [0; 13]; - cards - .iter() - .map(|c| c.rank()) - .map(|r| r as usize) - .for_each(|r| rank_counts[r] += 1); - rank_counts - } - fn suit_counts(cards: &Vec<&Card>) -> [u8; 4] { - let mut suit_counts = [0; 4]; - cards - .iter() - .map(|c| c.suit()) - .map(|s| s as usize) - .for_each(|s| suit_counts[s] += 1); - suit_counts - } - fn u32_hand(cards: &Vec<&Card>) -> u32 { - let mut u32_hand = 0; - cards - .iter() - .map(|c| c.rank()) - .map(|r| u32::from(r)) - .for_each(|r| u32_hand |= r); - u32_hand - } - fn u32_suit(cards: &Vec<&Card>) -> [u32; 4] { - let mut u32_suit = [0; 4]; - cards - .iter() - .map(|c| (c.suit(), c.rank())) - .map(|(s, r)| (s as usize, u32::from(r))) - .for_each(|(s, r)| u32_suit[s] |= r); - u32_suit - } - - fn wheel() -> u32 { - 0b00000000000000000001000000001111 - } -} - -use super::strength::{BestHand, Kickers, Strength}; -use crate::cards::card::Card; -use crate::cards::rank::Rank; -use crate::cards::suit::Suit; diff --git a/src/evaluation/mod.rs b/src/evaluation/mod.rs deleted file mode 100644 index b80d73d0..00000000 --- a/src/evaluation/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod evaluation; -pub mod showdown; -pub mod strength; diff --git a/src/evaluation/showdown.rs b/src/evaluation/showdown.rs deleted file mode 100644 index 5759a286..00000000 --- a/src/evaluation/showdown.rs +++ /dev/null @@ -1,105 +0,0 @@ -// ephemeral data structure that is used to calculate the results of a hand by iterating over hand.actions to calculate side pots, handling every edge case with generalized zero-cost logic -pub struct Showdown { - payouts: Vec, - next_stake: u32, - prev_stake: u32, - next_strength: Strength, -} - -impl Showdown { - pub fn concede(mut payouts: Vec) -> Vec { - let reward = payouts.iter().map(|p| p.risked).sum::(); - let winner = payouts - .iter_mut() - .find(|p| p.status != BetStatus::Folded) - .unwrap(); - winner.reward = reward; - payouts - } - pub fn settle(payouts: Vec) -> Vec { - let mut this = Self::new(payouts); - 'strength: while let Some(strength) = this.next_strength() { - this.next_strength = strength; - 'stake: while let Some(stake) = this.next_stake() { - this.next_stake = stake; - this.distribute(); - if this.is_complete() { - break 'strength; - } - } - } - this.payouts - } - - fn new(payouts: Vec) -> Self { - let next_rank = Strength::new(BestHand::MAX, Kickers(Vec::new())); - let next_stake = u32::MIN; - let prev_stake = u32::MIN; - Self { - payouts, - next_strength: next_rank, - next_stake, - prev_stake, - } - } - - fn is_complete(&self) -> bool { - let staked = self.payouts.iter().map(|p| p.risked).sum::(); - let reward = self.payouts.iter().map(|p| p.reward).sum::(); - staked == reward - } - - fn next_strength(&mut self) -> Option { - self.payouts - .iter() - .filter(|p| p.strength < self.next_strength) - .filter(|p| p.status != BetStatus::Folded) - .map(|p| p.strength.clone()) //? can we copy, rather than clone, the kickers - .max() - } - - fn next_stake(&mut self) -> Option { - self.prev_stake = self.next_stake; - self.payouts - .iter() - .filter(|p| p.strength == self.next_strength) - .filter(|p| p.risked > self.prev_stake) - .filter(|p| p.status != BetStatus::Folded) - .map(|p| p.risked) - .min() - } - - fn winnings(&self) -> u32 { - self.payouts - .iter() - .map(|p| p.risked) - .map(|s| std::cmp::min(s, self.next_stake)) - .map(|s| s.saturating_sub(self.prev_stake)) - .sum() - } - - fn winners(&mut self) -> Vec<&mut Payout> { - self.payouts - .iter_mut() - .filter(|p| p.strength == self.next_strength) - .filter(|p| p.risked > self.prev_stake) - .filter(|p| p.status != BetStatus::Folded) - .collect() - } - - fn distribute(&mut self) { - let winnings = self.winnings(); - let mut winners = self.winners(); - let share = winnings / winners.len() as u32; - for winner in winners.iter_mut() { - winner.reward += share; - } - let remainder = winnings as usize % winners.len(); - for winner in winners.iter_mut().take(remainder) { - winner.reward += 1; - } - } -} - -use crate::evaluation::strength::{BestHand, Kickers, Strength}; -use crate::gameplay::{payout::Payout, seat::BetStatus}; diff --git a/src/evaluation/strength.rs b/src/evaluation/strength.rs deleted file mode 100644 index d483d35c..00000000 --- a/src/evaluation/strength.rs +++ /dev/null @@ -1,155 +0,0 @@ -#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd)] -pub enum BestHand { - HighCard(Rank), // 4 kickers - OnePair(Rank), // 3 kickers - TwoPair(Rank, Rank), // 1 kickers - ThreeOAK(Rank), // 2 kickers - Straight(Rank), // 0 kickers - Flush(Rank), // 0 kickers - FullHouse(Rank, Rank), // 0 kickers - FourOAK(Rank), // 1 kickers - StraightFlush(Rank), // 0 kickers - MUCK, - MAX, -} -#[derive(Debug, Clone, Eq, PartialEq, PartialOrd)] -pub struct Strength { - hand: BestHand, - kickers: Kickers, -} -#[derive(Debug, Clone, Eq, PartialEq, PartialOrd)] -pub struct Kickers(pub Vec); - -impl Strength { - pub fn new(hand: BestHand, kickers: Kickers) -> Self { - Strength { hand, kickers } - } - pub fn rank(&self) -> Rank { - match self.hand { - BestHand::StraightFlush(r, ..) - | BestHand::FullHouse(r, ..) - | BestHand::TwoPair(r, ..) - | BestHand::Straight(r, ..) - | BestHand::ThreeOAK(r, ..) - | BestHand::HighCard(r, ..) - | BestHand::OnePair(r, ..) - | BestHand::FourOAK(r, ..) - | BestHand::Flush(r, ..) => r, - BestHand::MUCK | BestHand::MAX => unreachable!(), - } - } - pub fn secondary(&self) -> Rank { - match self.hand { - BestHand::TwoPair(_, r) | BestHand::FullHouse(_, r) => r, - _ => self.rank(), - } - } - pub fn kickers(&self) -> Kickers { - self.kickers.clone() - } -} - -impl BestHand { - pub fn primary(&self) -> Rank { - match self { - BestHand::StraightFlush(r, ..) - | BestHand::FullHouse(r, ..) - | BestHand::TwoPair(r, ..) - | BestHand::Straight(r, ..) - | BestHand::ThreeOAK(r, ..) - | BestHand::HighCard(r, ..) - | BestHand::OnePair(r, ..) - | BestHand::FourOAK(r, ..) - | BestHand::Flush(r, ..) => *r, - BestHand::MUCK | BestHand::MAX => unreachable!(), - } - } - pub fn secondary(&self) -> Rank { - match self { - BestHand::TwoPair(_, r) | BestHand::FullHouse(_, r) => *r, - x => x.primary(), - } - } -} - -impl Ord for BestHand { - fn cmp(&self, other: &Self) -> Ordering { - Ordering::Equal - .then_with(|| u8::from(self).cmp(&u8::from(other))) - .then_with(|| self.primary().cmp(&other.primary())) - .then_with(|| self.secondary().cmp(&other.secondary())) - } -} -impl Ord for Kickers { - fn cmp(&self, other: &Self) -> Ordering { - self.0 - .iter() - .zip(other.0.iter()) - .map(|(a, b)| a.cmp(b)) - .find(|&x| x != Ordering::Equal) - .unwrap_or(Ordering::Equal) - } -} - -impl Ord for Strength { - fn cmp(&self, other: &Self) -> Ordering { - Ordering::Equal - .then_with(|| self.hand.cmp(&&other.hand)) - .then_with(|| self.kickers.cmp(&other.kickers)) - } -} - -impl Display for BestHand { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - BestHand::MUCK => write!(f, ""), - BestHand::MAX => unreachable!(), - BestHand::FullHouse(r1, r2) => write!(f, "FullHouse {}, {}", r1, r2), - BestHand::TwoPair(r1, r2) => write!(f, "TwoPair {}, {}", r1, r2), - BestHand::HighCard(r) => write!(f, "HighCard {}", r), - BestHand::OnePair(r) => write!(f, "OnePair {}", r), - BestHand::ThreeOAK(r) => write!(f, "ThreeOfAKind {}", r), - BestHand::Straight(r) => write!(f, "Straight {}", r), - BestHand::FourOAK(r) => write!(f, "FourOfAKind {}", r), - BestHand::Flush(r) => write!(f, "Flush {}", r), - BestHand::StraightFlush(r) => write!(f, "StraightFlush {}", r), - } - } -} - -impl Display for Kickers { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - for rank in &self.0 { - write!(f, "{} ", rank)?; - } - Ok(()) - } -} - -impl Display for Strength { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{:<18}", self.hand) - } -} - -impl From<&BestHand> for u8 { - fn from(strength: &BestHand) -> u8 { - match strength { - BestHand::MUCK => u8::MIN, - BestHand::MAX => u8::MAX, - BestHand::HighCard(_) => 1, - BestHand::OnePair(_) => 2, - BestHand::TwoPair(_, _) => 3, - BestHand::ThreeOAK(_) => 4, - BestHand::Straight(_) => 5, - BestHand::Flush(_) => 6, - BestHand::FullHouse(_, _) => 7, - BestHand::FourOAK(_) => 8, - BestHand::StraightFlush(_) => 9, - } - } -} - -use crate::cards::rank::Rank; -use std::cmp::Ordering; -use std::fmt::Display; diff --git a/src/gameplay/action.rs b/src/gameplay/action.rs index 4db08047..d0f02fa6 100644 --- a/src/gameplay/action.rs +++ b/src/gameplay/action.rs @@ -1,30 +1,176 @@ -#[derive(Debug, Clone, Copy)] +use crate::cards::card::Card; +use crate::cards::hand::Hand; +use crate::Chips; + +const MASK: u32 = 0xFF; +const BITS: u32 = MASK.count_ones(); + +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, Ord, PartialOrd)] pub enum Action { - Draw(Card), - Check(usize), - Fold(usize), - Call(usize, u32), - Blind(usize, u32), - Raise(usize, u32), - Shove(usize, u32), -} - -impl Display for Action { - fn fmt(&self, f: &mut Formatter) -> Result { - match self { - Action::Draw(card) => write!(f, "{}", format!("DEAL {}", card).white()), - Action::Check(id) => write!(f, "{id} {}", "CHECK".cyan()), - Action::Fold(id) => write!(f, "{id} {}", "FOLD".red()), - Action::Blind(id, amount) => write!(f, "{id} {}", format!("BLIND {}", amount).white()), - Action::Call(id, amount) => write!(f, "{id} {}", format!("CALL {}", amount).yellow()), - Action::Raise(id, amount) => write!(f, "{id} {}", format!("RAISE {}", amount).green()), - Action::Shove(id, amount) => { - write!(f, "{id} {}", format!("SHOVE {}", amount).magenta()) + Draw(Hand), + Fold, + Call(Chips), + Check, + Raise(Chips), + Shove(Chips), + Blind(Chips), +} + +impl Action { + pub fn is_choice(&self) -> bool { + !self.is_chance() + } + pub fn is_chance(&self) -> bool { + matches!(self, Action::Draw(_)) + } + pub fn is_aggro(&self) -> bool { + matches!(self, Action::Raise(_) | Action::Shove(_)) + } + pub fn is_shove(&self) -> bool { + matches!(self, Action::Shove(_)) + } + pub fn is_raise(&self) -> bool { + matches!(self, Action::Raise(_)) + } + pub fn is_blind(&self) -> bool { + matches!(self, Action::Blind(_)) + } +} +impl From for String { + fn from(action: Action) -> Self { + match action { + Action::Fold => format!("FOLD"), + Action::Check => format!("CHECK"), + Action::Draw(card) => format!("DEAL {}", card), + Action::Call(amount) => format!("CALL {}", amount), + Action::Blind(amount) => format!("BLIND {}", amount), + Action::Raise(amount) => format!("RAISE {}", amount), + Action::Shove(amount) => format!("SHOVE {}", amount), + } + } +} + +impl From for Action { + fn from(value: u32) -> Self { + let kind = value & MASK; // Use lowest 8 bits for action type + let data = value >> BITS; // Shift right by 8 bits for data + let bets = data as Chips; + match kind { + 0 => Action::Fold, + 1 => Action::Check, + 2 => Action::Call(bets), + 3 => Action::Raise(bets), + 4 => Action::Shove(bets), + 5 => Action::Blind(bets), + 6 => Action::Draw( + [0, 1, 2] + .iter() + .map(|i| BITS * i) + .map(|r| data >> r) + .map(|x| x & MASK) + .filter(|&x| x > 0) + .map(|x| x as u8 - 1) + .map(|c| Card::from(c)) + .map(|c| Hand::from(c)) + .fold(Hand::empty(), Hand::add), + ), + _ => panic!("at the disco"), + } + } +} + +impl From for u32 { + fn from(action: Action) -> Self { + match action { + Action::Fold => 0, + Action::Check => 1, + Action::Call(bets) => 2 | ((bets as u32) << BITS), + Action::Raise(bets) => 3 | ((bets as u32) << BITS), + Action::Shove(bets) => 4 | ((bets as u32) << BITS), + Action::Blind(bets) => 5 | ((bets as u32) << BITS), + Action::Draw(hand) => { + 6 | (hand + .into_iter() + .take(3) + .map(|c| u8::from(c)) + .map(|c| c as u32 + 1) + .enumerate() + .map(|(i, x)| x << (i as u32 * BITS)) + .fold(0u32, |hand, card| hand | card) + << BITS) } } } } +impl TryFrom<&str> for Action { + type Error = &'static str; + fn try_from(s: &str) -> Result { + let parts: Vec<&str> = s.split_whitespace().collect(); + match parts[0].to_uppercase().as_str() { + "CHECK" => Ok(Action::Check), + "FOLD" => Ok(Action::Fold), + "CALL" => parts + .get(1) + .and_then(|n| n.parse().ok()) + .map(Action::Call) + .ok_or("invalid call amount"), + "RAISE" => parts + .get(1) + .and_then(|n| n.parse().ok()) + .map(Action::Raise) + .ok_or("invalid raise amount"), + "SHOVE" => parts + .get(1) + .and_then(|n| n.parse().ok()) + .map(Action::Shove) + .ok_or("invalid shove amount"), + "BLIND" => parts + .get(1) + .and_then(|n| n.parse().ok()) + .map(Action::Blind) + .ok_or("invalid blind amount"), + "DEAL" => Hand::try_from(parts[1..].join(" ").as_str()) + .map(Action::Draw) + .map_err(|_| "invalid deal cards"), + _ => Err("invalid action type"), + } + } +} +impl std::fmt::Display for Action { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + String::from(*self).fmt(f) + } +} -use crate::cards::card::Card; -use colored::*; -use std::fmt::{Display, Formatter, Result}; +#[cfg(feature = "native")] +impl From for colored::Color { + fn from(action: Action) -> Self { + match action { + Action::Fold => colored::Color::Red, + Action::Check => colored::Color::Yellow, + Action::Call(_) => colored::Color::Green, + Action::Raise(_) => colored::Color::Green, + Action::Shove(_) => colored::Color::Green, + Action::Blind(_) => colored::Color::White, + Action::Draw(_) => colored::Color::White, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bijective_u32() { + for action in [ + Action::Raise(1), + Action::Blind(2), + Action::Call(32767), + Action::Shove(1738), + Action::Draw(Hand::try_from("2c Th As").unwrap()), + ] { + assert!(action == Action::from(u32::from(action))); + } + } +} diff --git a/src/gameplay/deal.rs b/src/gameplay/deal.rs new file mode 100644 index 00000000..7b3c79bc --- /dev/null +++ b/src/gameplay/deal.rs @@ -0,0 +1,17 @@ +use crate::cards::board::Board; +use crate::cards::card::Card; +use crate::cards::hand::Hand; + +struct Deal(Vec); + +impl From for Board { + fn from(deal: Deal) -> Board { + Board::from(Hand::from(deal)) + } +} + +impl From for Hand { + fn from(deal: Deal) -> Hand { + Hand::from(deal.0) + } +} diff --git a/src/gameplay/engine.rs b/src/gameplay/engine.rs deleted file mode 100644 index cbc53236..00000000 --- a/src/gameplay/engine.rs +++ /dev/null @@ -1,80 +0,0 @@ -pub struct Table { - n_hands: u32, - producer: mpsc::Sender, - consumer: mpsc::Receiver, - hand: Hand, - // history: Vec, -} - -impl Table { - pub fn new() -> Self { - let (producer, consumer) = mpsc::channel(64); - Table { - hand: Hand::new(), - n_hands: 0, - producer, - consumer, - } - } - - pub fn play(&mut self) { - while self.has_hands() { - self.begin_hand(); - while self.has_streets() { - self.begin_street(); - while self.has_turns() { - self.end_turn(); - } - self.end_street(); - } - self.end_hand(); - } - } - - pub fn gain_seat(&mut self, stack: u32, actor: Rc) { - self.hand.head.gain_seat(stack, actor); - } - pub fn drop_seat(&mut self, position: usize) { - self.hand.head.drop_seat(position); - } - - fn begin_street(&mut self) { - self.hand.next_street(); - } - fn begin_hand(&mut self) { - println!("\n{}\nHAND {}", "-".repeat(21), self.n_hands); - self.hand.start(); - // std::thread::sleep(std::time::Duration::from_millis(1000)); - } - - fn end_turn(&mut self) { - let seat = self.hand.head.seat_up_next(); - let action = seat.actor.act(seat, &self.hand); - self.hand.apply(action); - // std::thread::sleep(std::time::Duration::from_millis(100)); - } - fn end_street(&mut self) { - self.hand.head.end_street(); - // std::thread::sleep(std::time::Duration::from_millis(500)); - } - fn end_hand(&mut self) { - println!(" {}", self.hand.head.board); - self.n_hands += 1; - self.hand.end(); - // std::thread::sleep(std::time::Duration::from_millis(1000)); - } - - fn has_turns(&self) -> bool { - self.hand.head.has_more_players() - } - fn has_streets(&self) -> bool { - self.hand.head.has_more_streets() - } - fn has_hands(&self) -> bool { - self.hand.head.has_more_hands() // && self.n_hands < 500000 - } -} - -use super::{action::Action, hand::Hand, player::Player}; -use std::rc::Rc; -use tokio::sync::mpsc; diff --git a/src/gameplay/game.rs b/src/gameplay/game.rs new file mode 100644 index 00000000..5ecbbb9d --- /dev/null +++ b/src/gameplay/game.rs @@ -0,0 +1,773 @@ +#![allow(dead_code)] + +use super::action::Action; +use super::seat::Seat; +use super::seat::State; +use super::settlement::Settlement; +use crate::cards::board::Board; +use crate::cards::deck::Deck; +use crate::cards::hand::Hand; +use crate::cards::hole::Hole; +use crate::cards::observation::Observation; +use crate::cards::street::Street; +use crate::cards::strength::Strength; +use crate::gameplay::ply::Turn; +use crate::gameplay::showdown::Showdown; +use crate::Chips; +use crate::N; +use crate::STACK; + +type Position = usize; +/// Rotation represents the memoryless state of the game in between actions. +/// +/// It records both public and private data structs, and is responsible for managing the +/// rotation of players, the pot, and the board. Its immutable methods reveal +/// pure functions representing the rules of how the game may proceed. +/// This full game state will also be our CFR node representation. +#[derive(Debug, Clone, Copy)] +pub struct Game { + seats: [Seat; N], + pot: Chips, + board: Board, // could be [Card; N] + dealer: Position, + ticker: Position, +} + +impl Game { + pub fn base() -> Self { + Self { + pot: Chips::from(0i16), + board: Board::empty(), + seats: [Seat::from(STACK); N], + dealer: 0usize, + ticker: 1usize, + } + } + pub fn deal(mut self) -> Self { + self.deal_cards(); + self + } + pub fn post(mut self) -> Self { + self.act(Action::Blind(self.to_post())); + self.act(Action::Blind(self.to_post())); + self + } + pub fn wipe(mut self, hole: Hole) -> Self { + for seat in self.seats.iter_mut() { + seat.reset_cards(hole); + } + self + } + /// this will start the game at the first decision + /// NOT the first action, which are blinds and hole cards dealt. + /// stack size is always 100 and P1 is always dealer. + /// these should not matter too much in the MCCFR algorithm, + /// as long as we alternate the traverser/paths explored + pub fn root() -> Self { + Self::base().deal().post() + } + pub fn blinds() -> Vec { + vec![Action::Blind(Self::sblind()), Action::Blind(Self::bblind())] + } + pub fn n(&self) -> usize { + N + } + pub fn apply(&self, action: Action) -> Self { + let mut child = self.clone(); + child.act(action); + child + } + // + pub fn pot(&self) -> Chips { + self.pot + } + pub fn board(&self) -> Board { + self.board + } + pub fn turn(&self) -> Turn { + if self.must_stop() { + Turn::Terminal + } else if self.must_deal() { + Turn::Chance + } else { + Turn::Choice(self.actor_idx()) + } + } + pub fn actor(&self) -> &Seat { + self.actor_ref() + } + pub fn sweat(&self) -> Observation { + Observation::from(( + Hand::from(self.actor().cards()), // + Hand::from(self.board()), // + )) + } + pub fn street(&self) -> Street { + self.board.street() + } + pub fn legal(&self) -> Vec { + let mut options = Vec::new(); + if self.must_stop() { + return options; + } + if self.must_deal() { + options.push(Action::Draw(self.deck().deal(self.street()))); + return options; + } + if self.must_post() { + options.push(Action::Blind(Self::sblind())); + return options; + } + if self.may_raise() { + options.push(Action::Raise(self.to_raise())); + } + if self.may_shove() { + options.push(Action::Shove(self.to_shove())); + } + if self.may_call() { + options.push(Action::Call(self.to_call())); + } + if self.may_fold() { + options.push(Action::Fold); + } + if self.may_check() { + options.push(Action::Check); + } + assert!(options.len() > 0); + options + } + + // + pub fn is_allowed(&self, action: &Action) -> bool { + if self.must_stop() { + return false; + } + match action { + Action::Raise(raise) => { + self.may_raise() + && raise.clone() >= self.to_raise() + && raise.clone() <= self.to_shove() - 1 + } + Action::Draw(cards) => { + self.must_deal() + && cards.clone().all(|c| self.deck().contains(&c)) + && cards.count() == self.board().street().n_revealed() + } + Action::Blind(_) => self.must_post(), + _ => self.legal().contains(action), + } + } + + // + fn conclude(&mut self) { + self.give_chips(); + } + fn commence(&mut self) { + assert!(self.seats.iter().all(|s| s.stack() > 0), "game over"); + self.wipe_board(); + self.deal_cards(); + self.move_button(); + self.act(Action::Blind(self.to_post())); + self.act(Action::Blind(self.to_post())); + } + fn give_chips(&mut self) { + log::trace!("::::::::::::::"); + log::trace!("{}", self.board()); + for (_, (settlement, seat)) in self + .settlements() + .iter() + .zip(self.seats.iter_mut()) + .enumerate() + .inspect(|(i, (x, s))| log::trace!("{} {} {:>7} {}", i, s.cards(), s.stack(), x.pnl())) + { + seat.win(settlement.reward); + } + } + fn wipe_board(&mut self) { + self.pot = 0; + self.board.clear(); + assert!(self.street() == Street::Pref); + } + fn move_button(&mut self) { + assert!(self.seats.len() == self.n()); + assert!(self.street() == Street::Pref); + self.dealer += 1; + self.dealer %= self.n(); + self.ticker = self.dealer; + self.next_player(); + } + fn deal_cards(&mut self) { + assert!(self.street() == Street::Pref); + let mut deck = Deck::new(); + for seat in self.seats.iter_mut() { + seat.reset_state(State::Betting); + seat.reset_cards(deck.hole()); + seat.reset_stake(); + seat.reset_spent(); + } + } + + // + fn act(&mut self, a: Action) { + assert!(self.is_allowed(&a)); + match a { + Action::Check => { + self.next_player(); + } + Action::Fold => { + self.fold(); + self.next_player(); + } + Action::Call(chips) + | Action::Blind(chips) + | Action::Raise(chips) + | Action::Shove(chips) => { + self.bet(chips); + self.next_player(); + } + Action::Draw(cards) => { + self.show(cards); + self.next_player(); + self.next_street(); + } + } + } + fn bet(&mut self, bet: Chips) { + assert!(self.actor_ref().stack() >= bet); + self.pot += bet; + self.actor_mut().bet(bet); + if self.actor_ref().stack() == 0 { + self.shove(); + } + } + fn shove(&mut self) { + self.actor_mut().reset_state(State::Shoving); + } + fn fold(&mut self) { + self.actor_mut().reset_state(State::Folding); + } + fn show(&mut self, hand: Hand) { + self.ticker = self.dealer; + self.board.add(hand); + } + fn next_street(&mut self) { + for seat in self.seats.iter_mut() { + seat.reset_stake(); + } + } + fn next_player(&mut self) { + if !self.is_everyone_alright() { + loop { + self.ticker += 1; + match self.actor_ref().state() { + State::Betting => break, + State::Folding => continue, + State::Shoving => continue, + } + } + } + } + + /// we're waiting for showdown or everyone folded + fn must_stop(&self) -> bool { + if self.street() == Street::Rive { + self.is_everyone_alright() + } else { + self.is_everyone_folding() + } + } + /// we're waiting for a card to be revealed + fn must_deal(&self) -> bool { + if self.street() == Street::Rive { + false + } else { + self.is_everyone_alright() + } + } + /// blinds have not yet been posted // TODO some edge case of all in blinds + fn must_post(&self) -> bool { + if self.street() == Street::Pref { + self.pot() < Self::sblind() + Self::bblind() + } else { + false + } + } + + /// all players have acted, the pot is right. + fn is_everyone_alright(&self) -> bool { + self.is_everyone_calling() || self.is_everyone_folding() || self.is_everyone_shoving() + } + /// all players betting are in for the same amount + fn is_everyone_calling(&self) -> bool { + self.is_everyone_touched() && self.is_everyone_matched() + } + /// all players have acted at least once + fn is_everyone_touched(&self) -> bool { + self.ticker > self.n() + if self.street() == Street::Pref { 2 } else { 0 } + } + /// all players betting are in for the effective stake + fn is_everyone_matched(&self) -> bool { + let stake = self.effective_stake(); + self.seats + .iter() + .filter(|s| s.state() == State::Betting) + .all(|s| s.stake() == stake) + } + /// all players betting or shoving are shoving + fn is_everyone_shoving(&self) -> bool { + self.seats + .iter() + .filter(|s| s.state() != State::Folding) + .all(|s| s.state() == State::Shoving) + } + /// there is exactly one player betting or shoving + fn is_everyone_folding(&self) -> bool { + self.seats + .iter() + .filter(|s| s.state() != State::Folding) + .count() + == 1 + } + + // + fn may_fold(&self) -> bool { + self.to_call() > 0 + } + fn may_call(&self) -> bool { + self.may_fold() && self.to_call() < self.to_shove() + } + fn may_check(&self) -> bool { + self.effective_stake() == self.actor_ref().stake() + } + fn may_raise(&self) -> bool { + self.to_raise() < self.to_shove() + } + fn may_shove(&self) -> bool { + self.to_shove() > 0 + } + + // + pub fn to_call(&self) -> Chips { + self.effective_stake() - self.actor_ref().stake() + } + pub fn to_post(&self) -> Chips { + assert!(self.street() == Street::Pref); + match (self.ticker as isize - self.dealer as isize) % self.n() as isize { + 1 => Self::sblind().min(self.actor_ref().stack()), + _ => Self::bblind().min(self.actor_ref().stack()), + } + } + pub fn to_shove(&self) -> Chips { + self.actor_ref().stack() + } + pub fn to_raise(&self) -> Chips { + let (most_large_stake, next_large_stake) = self + .seats + .iter() + .filter(|s| s.state() != State::Folding) + .map(|s| s.stake()) + .fold((0, 0), |(most, next), stake| { + if stake > most { + (stake, most) + } else if stake > next { + (most, stake) + } else { + (most, next) + } + }); + let relative_raise = most_large_stake - self.actor().stake(); + let marginal_raise = most_large_stake - next_large_stake; + let required_raise = std::cmp::max(marginal_raise, Self::bblind()); + relative_raise + required_raise + } + + // + pub fn settlements(&self) -> Vec { + assert!(self.must_stop(), "non terminal game state:\n{}", self); + Showdown::from(self.ledger()).settle() + } + fn ledger(&self) -> Vec { + self.seats + .iter() + .map(|seat| self.entry(seat)) + .collect::>() + } + fn entry(&self, seat: &Seat) -> Settlement { + Settlement { + reward: 0, + risked: seat.spent(), + status: seat.state(), + strength: self.strength(seat), + } + } + fn strength(&self, seat: &Seat) -> Strength { + Strength::from(Hand::add( + Hand::from(seat.cards()), + Hand::from(self.board()), + )) + } + + // + pub fn draw(&self) -> Hand { + self.deck().deal(self.street()) + } + pub fn deck(&self) -> Deck { + let mut removed = Hand::from(self.board); + for seat in self.seats.iter() { + let hole = Hand::from(seat.cards()); + removed = Hand::add(removed, hole); + } + Deck::from(removed.complement()) + } + fn actor_idx(&self) -> Position { + (self.dealer + self.ticker) % self.n() + } + fn actor_ref(&self) -> &Seat { + let index = self.actor_idx(); + self.seats + .get(index) + .expect("index should be in bounds bc modulo") + } + fn actor_mut(&mut self) -> &mut Seat { + let index = self.actor_idx(); + self.seats + .get_mut(index) + .expect("index should be in bounds bc modulo") + } + + // + #[allow(dead_code)] + fn effective_stack(&self) -> Chips { + let mut totals = self + .seats + .iter() + .map(|s| s.stack() + s.stake()) + .collect::>(); + totals.sort_unstable(); + totals.pop().unwrap_or(0); + totals.pop().unwrap_or(0) + } + fn effective_stake(&self) -> Chips { + self.seats + .iter() + .map(|s| s.stake()) + .max() + .expect("non-empty seats") + } + + pub const fn bblind() -> Chips { + crate::B_BLIND + } + pub const fn sblind() -> Chips { + crate::S_BLIND + } +} + +impl From for String { + fn from(game: Game) -> Self { + format!(" @ {:>6} {} {}", game.pot, game.board, game.street()) + } +} + +impl std::fmt::Display for Game { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + for seat in self.seats.iter() { + write!(f, "{}{:<6}", seat.state(), seat.stack())?; + } + #[cfg(feature = "native")] + { + use colored::Colorize; + write!(f, "{}", String::from(*self).bright_green()) + } + #[cfg(not(feature = "native"))] + { + write!(f, "{}", String::from(*self)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_root() { + let game = Game::root(); + assert!(game.ticker != game.dealer); + assert!(game.board().street() == Street::Pref); + assert!(game.actor().state() == State::Betting); + assert!(game.pot() == Game::sblind() + Game::bblind()); + } + + #[test] + fn everyone_folds_pref() { + let game = Game::root(); + let game = game.apply(Action::Fold); + assert!(game.is_everyone_folding() == true); + assert!(game.is_everyone_alright() == true); + assert!(game.is_everyone_calling() == false); + assert!(game.must_deal() == true); // ambiguous + assert!(game.must_stop() == true); + } + #[test] + fn everyone_folds_flop() { + let game = Game::root(); + let flop = game.deck().deal(Street::Pref); + let game = game.apply(Action::Call(1)); + let game = game.apply(Action::Check); + let game = game.apply(Action::Draw(flop)); + let game = game.apply(Action::Raise(10)); + let game = game.apply(Action::Fold); + assert!(game.is_everyone_folding() == true); + assert!(game.is_everyone_alright() == true); // fail + assert!(game.is_everyone_calling() == false); + assert!(game.must_deal() == true); // ambiguous + assert!(game.must_stop() == true); + } + #[test] + fn history_of_checks() { + // Blinds + let game = Game::root(); + assert!(game.board().street() == Street::Pref); + assert!(game.pot() == 3); + assert!(game.must_post() == false); + assert!(game.must_stop() == false); + assert!(game.must_deal() == false); + assert!(game.is_everyone_alright() == false); + assert!(game.is_everyone_calling() == false); + assert!(game.is_everyone_touched() == false); + assert!(game.is_everyone_matched() == false); + + // SmallB Preflop + let game = game.apply(Action::Call(1)); + assert!(game.board().street() == Street::Pref); + assert!(game.pot() == 4); // + assert!(game.must_post() == false); + assert!(game.must_stop() == false); + assert!(game.must_deal() == false); + assert!(game.is_everyone_alright() == false); + assert!(game.is_everyone_calling() == false); + assert!(game.is_everyone_touched() == false); + assert!(game.is_everyone_matched() == true); // + + // Dealer Preflop + let game = game.apply(Action::Check); + assert!(game.board().street() == Street::Pref); + assert!(game.pot() == 4); + assert!(game.must_post() == false); + assert!(game.must_stop() == false); + assert!(game.must_deal() == true); // + assert!(game.is_everyone_alright() == true); // + assert!(game.is_everyone_calling() == true); // + assert!(game.is_everyone_touched() == true); // + assert!(game.is_everyone_matched() == true); + + // Flop + let flop = game.deck().deal(game.board().street()); + let game = game.apply(Action::Draw(flop)); + assert!(game.board().street() == Street::Flop); // + assert!(game.pot() == 4); + assert!(game.must_post() == false); + assert!(game.must_stop() == false); + assert!(game.must_deal() == false); // + assert!(game.is_everyone_alright() == false); // + assert!(game.is_everyone_calling() == false); // + assert!(game.is_everyone_touched() == false); // + assert!(game.is_everyone_matched() == true); + + // SmallB Flop + let game = game.apply(Action::Check); + assert!(game.board().street() == Street::Flop); + assert!(game.pot() == 4); + assert!(game.must_post() == false); + assert!(game.must_stop() == false); + assert!(game.must_deal() == false); + assert!(game.is_everyone_alright() == false); + assert!(game.is_everyone_calling() == false); + assert!(game.is_everyone_touched() == false); + assert!(game.is_everyone_matched() == true); + + // Dealer Flop + let game = game.apply(Action::Check); + assert!(game.board().street() == Street::Flop); + assert!(game.pot() == 4); + assert!(game.must_post() == false); + assert!(game.must_stop() == false); + assert!(game.must_deal() == true); // + assert!(game.is_everyone_alright() == true); // + assert!(game.is_everyone_calling() == true); // + assert!(game.is_everyone_touched() == true); // + assert!(game.is_everyone_matched() == true); + + // Turn + let turn = game.deck().deal(game.board().street()); + let game = game.apply(Action::Draw(turn)); + assert!(game.board().street() == Street::Turn); + assert!(game.pot() == 4); + assert!(game.must_post() == false); + assert!(game.must_stop() == false); + assert!(game.must_deal() == false); // + assert!(game.is_everyone_alright() == false); // + assert!(game.is_everyone_calling() == false); // + assert!(game.is_everyone_touched() == false); // + assert!(game.is_everyone_matched() == true); + + // SmallB Turn + let game = game.apply(Action::Check); + assert!(game.board().street() == Street::Turn); + assert!(game.pot() == 4); + assert!(game.must_post() == false); + assert!(game.must_stop() == false); + assert!(game.must_deal() == false); + assert!(game.is_everyone_alright() == false); + assert!(game.is_everyone_calling() == false); + assert!(game.is_everyone_touched() == false); + assert!(game.is_everyone_matched() == true); + + // Dealer Turn + let game = game.apply(Action::Raise(4)); + assert!(game.board().street() == Street::Turn); + assert!(game.pot() == 8); + assert!(game.must_post() == false); + assert!(game.must_stop() == false); + assert!(game.must_deal() == false); + assert!(game.is_everyone_alright() == false); + assert!(game.is_everyone_calling() == false); + assert!(game.is_everyone_touched() == true); // + assert!(game.is_everyone_matched() == false); // + + // SmallB Turn + let game = game.apply(Action::Call(4)); + assert!(game.board().street() == Street::Turn); + assert!(game.pot() == 12); // + assert!(game.must_post() == false); + assert!(game.must_stop() == false); + assert!(game.must_deal() == true); // + assert!(game.is_everyone_alright() == true); // + assert!(game.is_everyone_calling() == true); // + assert!(game.is_everyone_touched() == true); + assert!(game.is_everyone_matched() == true); + + // River + let rive = game.deck().deal(game.board().street()); + let game = game.apply(Action::Draw(rive)); + assert!(game.board().street() == Street::Rive); // + assert!(game.pot() == 12); + assert!(game.must_post() == false); + assert!(game.must_stop() == false); + assert!(game.must_deal() == false); // + assert!(game.is_everyone_alright() == false); // + assert!(game.is_everyone_calling() == false); // + assert!(game.is_everyone_touched() == false); // + assert!(game.is_everyone_matched() == true); // + + // SmallB River + let game = game.apply(Action::Check); + assert!(game.board().street() == Street::Rive); + assert!(game.pot() == 12); + assert!(game.must_post() == false); + assert!(game.must_stop() == false); + assert!(game.must_deal() == false); + assert!(game.is_everyone_alright() == false); + assert!(game.is_everyone_calling() == false); + assert!(game.is_everyone_touched() == false); + assert!(game.is_everyone_matched() == true); + + // Dealer River + let game = game.apply(Action::Check); + assert!(game.board().street() == Street::Rive); + assert!(game.pot() == 12); + assert!(game.must_post() == false); + assert!(game.must_stop() == true); // + assert!(game.must_deal() == false); + assert!(game.is_everyone_alright() == true); // + assert!(game.is_everyone_calling() == true); // + assert!(game.is_everyone_touched() == true); // + assert!(game.is_everyone_matched() == true); // + } +} + +// odds and tree building stuff +use crate::mccfr::edge::Edge; +use crate::mccfr::odds::Odds; +use crate::Utility; + +impl Game { + /// convert an Edge into an Action by using Game state to + /// determine free parameters (stack size, pot size, etc) + /// + /// NOTE + /// this conversion is not injective, as multiple edges may + /// represent the same action. moreover, we "snap" raises to be + /// within range of legal bet sizes, so sometimes Raise(5:1) yields + /// an identical Game node as Raise(1:1) or Shove. + pub fn actionize(&self, edge: &Edge) -> Action { + let game = self; + match &edge { + Edge::Check => Action::Check, + Edge::Fold => Action::Fold, + Edge::Draw => Action::Draw(game.draw()), + Edge::Call => Action::Call(game.to_call()), + Edge::Shove => Action::Shove(game.to_shove()), + Edge::Raise(odds) => { + let min = game.to_raise(); + let max = game.to_shove(); + let pot = game.pot() as Utility; + let odd = Utility::from(*odds); + let bet = (pot * odd) as Chips; + match bet { + bet if bet >= max => Action::Shove(max), + bet if bet <= min => Action::Raise(min), + _ => Action::Raise(bet), + } + } + } + } + + /// under the game tree constraints parametrized in lib.rs, + /// what are the possible continuations of the Game given its + /// full history? i.e. can we raise, and by how much. + pub fn choices(&self, n: usize) -> Vec { + self.legal() + .into_iter() + .map(|a| self.expand(a, n)) + .flatten() + .collect::>() + } + + /// returns the set of "allowed" raises given the current history + /// we truncate in a few cases: + /// - prevent N-betting explosion of raises + /// - allow for finer-grained exploration in early streets + /// - on the last street, restrict raise amounts so smaller grid + fn raises(&self, n: usize) -> Vec { + if n > crate::MAX_RAISE_REPEATS { + vec![] + } else { + match self.street() { + Street::Pref => Odds::PREF_RAISES.to_vec(), + Street::Flop => Odds::FLOP_RAISES.to_vec(), + _ => match n { + 0 => Odds::LATE_RAISES.to_vec(), + _ => Odds::LAST_RAISES.to_vec(), + }, + } + } + } + + /// generalization of mapping a concrete Action into a set of abstract Vec + /// this is mostly useful for enumerating a set of desired Raises + /// which can be generated however. + /// the contract is that the Actions returned by Game are legal, + /// but the Raise amount can take any value >= the minimum provided by Game. + fn expand(&self, action: Action, n: usize) -> Vec { + match action { + Action::Raise(_) => self + .raises(n) + .into_iter() + .map(Edge::from) + .collect::>(), + _ => vec![Edge::from(action)], + } + } +} diff --git a/src/gameplay/hand.rs b/src/gameplay/hand.rs deleted file mode 100644 index 6e7734ad..00000000 --- a/src/gameplay/hand.rs +++ /dev/null @@ -1,211 +0,0 @@ -#[derive(Debug, Clone)] -pub struct Hand { - pub bblind: u32, - pub sblind: u32, - pub deck: Deck, - pub tail: Node, - pub head: Node, - pub actions: Vec, -} -#[allow(dead_code)] -impl Hand { - pub fn new() -> Self { - Hand { - sblind: 1, - bblind: 2, - actions: Vec::new(), - deck: Deck::new(), - tail: Node::new(), - head: Node::new(), - } - } - pub fn settlement(&self) -> Vec { - let payouts = self.starting_payouts(); - if self.head.are_all_folded() { - Showdown::concede(payouts) - } else { - Showdown::settle(payouts) - } - } - - fn street_bets(&self, street: Street) -> Vec { - let edges = self.street_bounds(); - let range = self.street_range(street, edges); - self.actions[range].to_vec() - } - fn street_bounds(&self) -> Vec { - let mut n_draws = 0usize; - let mut boundaries = Vec::new(); - self.actions - .iter() - .enumerate() - .filter(|(_, a)| match a { - Action::Draw(..) => true, - _ => false, - }) - .for_each(|(i, _)| { - n_draws += 1; - if n_draws >= 3 { - boundaries.push(i); - } - }); - boundaries - } - fn street_range(&self, street: Street, bounds: Vec) -> std::ops::Range { - match street { - Street::Pre => 0..bounds[0], - Street::Flop => bounds[0]..bounds[1], - Street::Turn => bounds[1]..bounds[2], - Street::River => bounds[2]..self.actions.len(), - Street::Showdown => unreachable!(), - } - } - - fn starting_payouts(&self) -> Vec { - let mut payouts = self - .head - .seats - .iter() - .map(|s| self.payout(s)) - .collect::>(); - payouts.sort_by(|a, b| self.order(a, b)); - payouts - } - fn payout(&self, seat: &Seat) -> Payout { - Payout { - reward: 0, - risked: self.risked(seat.position), - status: seat.status, - position: seat.position, - strength: LazyEvaluator::strength(self.cards(seat.position)), - } - } - - pub fn min_raise(&self) -> u32 { - let mut stakes = self - .head - .seats - .iter() - .filter(|s| s.status != BetStatus::Folded) - .map(|s| s.stake) - .collect::>(); - stakes.sort_unstable(); - let last = stakes.pop().unwrap_or(0); - let prev = stakes.pop().unwrap_or(0); - let diff = last - prev; - std::cmp::max(last + diff, last + self.bblind) - } - fn cards(&self, position: usize) -> Vec<&Card> { - let seat = self.head.seat_at_position(position); - let hole = &seat.hole; - let slice_hole = &hole.cards[..]; - let slice_board = &self.head.board.cards[..]; - slice_hole - .iter() - .chain(slice_board.iter()) - .collect::>() - } - fn risked(&self, position: usize) -> u32 { - self.actions - .iter() - .filter(|a| match a { - Action::Call(id_, _) - | Action::Blind(id_, _) - | Action::Raise(id_, _) - | Action::Shove(id_, _) => *id_ == position, - _ => false, - }) - .map(|a| match a { - Action::Call(_, bet) - | Action::Blind(_, bet) - | Action::Raise(_, bet) - | Action::Shove(_, bet) => *bet, - _ => 0, - }) - .sum() - } - fn priority(&self, position: usize) -> usize { - (self.head.seats.len() + position - self.head.after(self.head.dealer)) - % self.head.seats.len() - } - fn order(&self, a: &Payout, b: &Payout) -> std::cmp::Ordering { - let x = self.priority(a.position); - let y = self.priority(b.position); - x.cmp(&y) - } -} - -// mutable implementation reserved for engine or solver -impl Hand { - pub fn apply(&mut self, action: Action) { - self.actions.push(action); - self.head.apply(action); - } - pub fn start(&mut self) { - self.head.begin_hand(); - self.tail = self.head.clone(); - self.actions.clear(); - self.post(self.sblind); - self.post(self.bblind); - self.head.counter = 0; - self.deck = Deck::new(); - } - pub fn post(&mut self, size: u32) { - let pointer = self.head.pointer; - let seat = self.head.seat_at_position_mut(pointer); - let bet = std::cmp::min(size, seat.stack); - if seat.stack <= bet { - seat.status = BetStatus::Shoved; - } - self.apply(Action::Blind(pointer, bet)); - } - pub fn next_street(&mut self) { - self.head.begin_street(); - match self.head.board.street { - Street::Pre => { - for hole in self.head.seats.iter_mut().map(|s| &mut s.hole) { - hole.cards.clear(); - hole.cards.push(self.deck.draw().unwrap()); - hole.cards.push(self.deck.draw().unwrap()); - } - } - Street::Flop => { - let card1 = self.deck.draw().unwrap(); - let card2 = self.deck.draw().unwrap(); - let card3 = self.deck.draw().unwrap(); - self.apply(Action::Draw(card1)); - self.apply(Action::Draw(card2)); - self.apply(Action::Draw(card3)); - println!(" {}", self.head.board) - } - Street::Turn => { - let card = self.deck.draw().unwrap(); - self.apply(Action::Draw(card)); - println!(" {}", self.head.board) - } - Street::River => { - let card = self.deck.draw().unwrap(); - self.apply(Action::Draw(card)); - println!(" {}", self.head.board) - } - Street::Showdown => unreachable!(), - } - } - pub fn end(&mut self) { - let mut payouts = self.settlement(); - payouts.sort_by(|a, b| a.position.cmp(&b.position)); - for payout in payouts { - let seat = self.head.seat_at_position_mut(payout.position); - println!("{}{}", seat, payout); - seat.stack += payout.reward; - } - self.head.prune() - } -} -use super::payout::Payout; -use super::seat::{BetStatus, Seat}; -use super::{action::Action, node::Node}; -use crate::cards::board::Street; -use crate::cards::{card::Card, deck::Deck}; -use crate::evaluation::evaluation::{Evaluator, LazyEvaluator}; -use crate::evaluation::showdown::Showdown; diff --git a/src/gameplay/mod.rs b/src/gameplay/mod.rs index 0b71d2a2..19550ad0 100644 --- a/src/gameplay/mod.rs +++ b/src/gameplay/mod.rs @@ -1,7 +1,7 @@ pub mod action; -pub mod engine; -pub mod hand; -pub mod node; -pub mod payout; -pub mod player; +pub mod deal; +pub mod game; +pub mod ply; pub mod seat; +pub mod settlement; +pub mod showdown; diff --git a/src/gameplay/node.rs b/src/gameplay/node.rs deleted file mode 100644 index e4cccfc5..00000000 --- a/src/gameplay/node.rs +++ /dev/null @@ -1,236 +0,0 @@ -// Node represents the memoryless state of the game in between actions. it records both public and private data structs, and is responsible for managing the rotation of players, the pot, and the board. it's immutable methods reveal pure functions representing the rules of how the game may proceed. -#[derive(Debug, Clone)] -pub struct Node { - pub pot: u32, - pub dealer: usize, - pub counter: usize, - pub pointer: usize, - pub board: Board, - pub seats: Vec, -} - -impl Node { - pub fn new() -> Self { - Node { - seats: Vec::with_capacity(10), - board: Board::new(), - pot: 0, - dealer: 0, - counter: 0, - pointer: 0, - } - } - - pub fn has_more_hands(&self) -> bool { - self.seats.iter().filter(|s| s.stack > 0).count() > 1 - } - pub fn has_more_streets(&self) -> bool { - !(self.are_all_folded() || (self.board.street == Street::Showdown)) - } - pub fn has_more_players(&self) -> bool { - !(self.are_all_folded() || self.are_all_called() || self.are_all_shoved()) - } - - pub fn seat_up_next(&self) -> &Seat { - self.seats.get(self.pointer).unwrap() - } - pub fn seat_at_position(&self, index: usize) -> &Seat { - self.seats.iter().find(|s| s.position == index).unwrap() - } - pub fn seat_at_position_mut(&mut self, index: usize) -> &mut Seat { - self.seats.iter_mut().find(|s| s.position == index).unwrap() - } - pub fn after(&self, index: usize) -> usize { - (index + 1) % self.seats.len() - } - pub fn before(&self, index: usize) -> usize { - (index + self.seats.len() - 1) % self.seats.len() - } - - pub fn effective_stack(&self) -> u32 { - let mut totals = self - .seats - .iter() - .map(|s| s.stack + s.stake) - .collect::>(); - totals.sort(); - totals.pop().unwrap_or(0); - totals.pop().unwrap_or(0) - } - pub fn effective_stake(&self) -> u32 { - self.seats.iter().map(|s| s.stake).max().unwrap() - } - - pub fn are_all_folded(&self) -> bool { - // exactly one player has not folded - self.seats - .iter() - .filter(|s| s.status != BetStatus::Folded) - .count() - == 1 - } - pub fn are_all_shoved(&self) -> bool { - // everyone who isn't folded is all in - self.seats - .iter() - .filter(|s| s.status != BetStatus::Folded) - .all(|s| s.status == BetStatus::Shoved) - } - pub fn are_all_called(&self) -> bool { - // everyone who isn't folded has matched the bet - // or all but one player is all in - let stakes = self.effective_stake(); - let is_first_decision = self.counter == 0; - let is_one_playing = self - .seats - .iter() - .filter(|s| s.status == BetStatus::Playing) - .count() - == 1; - let has_no_decision = is_first_decision && is_one_playing; - let has_all_decided = self.counter > self.seats.len(); - let has_all_matched = self - .seats - .iter() - .filter(|s| s.status == BetStatus::Playing) - .all(|s| s.stake == stakes); - (has_all_decided || has_no_decision) && has_all_matched - } -} - -impl Display for Node { - fn fmt(&self, f: &mut Formatter) -> Result { - writeln!(f, "Pot: {}", self.pot)?; - writeln!(f, "Board: {}", self.board)?; - for seat in &self.seats { - write!(f, "{}", seat)?; - } - Ok(()) - } -} - -// mutable methods are lowkey reserved for the node's owning Hand -- maybe some CFR engine - -impl Node { - pub fn apply(&mut self, action: Action) { - let seat = self.seats.get_mut(self.pointer).unwrap(); - // bets entail pot and stack change - // folds and all-ins entail status change - // choice actions entail rotation & logging, chance action entails board change - match action { - Action::Call(_, bet) - | Action::Blind(_, bet) - | Action::Raise(_, bet) - | Action::Shove(_, bet) => { - self.pot += bet; - seat.stake += bet; - seat.stack -= bet; - } - _ => (), - } - match action { - Action::Fold(..) => seat.status = BetStatus::Folded, - Action::Shove(..) => seat.status = BetStatus::Shoved, - _ => (), - } - match action { - Action::Draw(card) => self.board.push(card), - _ => { - self.rotate(); - println!("{action}"); - } - } - } - pub fn begin_hand(&mut self) { - for seat in self.seats.iter_mut() { - seat.status = BetStatus::Playing; - seat.stake = 0; - } - self.pot = 0; - self.counter = 0; - self.board.cards.clear(); - self.board.street = Street::Pre; - self.dealer = self.after(self.dealer); - self.pointer = self.dealer; - self.rotate(); - } - pub fn begin_street(&mut self) { - self.counter = 0; - self.pointer = match self.board.street { - Street::Pre => self.after(self.after(self.dealer)), - _ => self.dealer, - }; - self.rotate(); - } - pub fn end_street(&mut self) { - for seat in self.seats.iter_mut() { - seat.stake = 0; - } - self.board.street = match self.board.street { - Street::Pre => Street::Flop, - Street::Flop => Street::Turn, - Street::Turn => Street::River, - Street::River => Street::Showdown, - Street::Showdown => unreachable!(), - } - } - fn rotate(&mut self) { - 'left: loop { - if !self.has_more_players() { - return; - } - self.counter += 1; - self.pointer = self.after(self.pointer); - match self.seat_up_next().status { - BetStatus::Playing => return, - BetStatus::Folded | BetStatus::Shoved => continue 'left, - } - } - } - #[allow(dead_code)] - fn rewind(&mut self) { - 'right: loop { - self.counter -= 1; - self.pointer = self.before(self.pointer); - match self.seat_up_next().status { - BetStatus::Playing => return, - BetStatus::Folded | BetStatus::Shoved => continue 'right, - } - } - } - pub fn prune(&mut self) { - if self.seats.iter().any(|s| s.stack == 0) { - for seat in self.seats.iter().filter(|s| s.stack == 0) { - println!("DROP {}", seat); - } - self.seats.retain(|s| s.stack > 0); - for (i, seat) in self.seats.iter_mut().enumerate() { - seat.position = i; - } - } - } - pub fn gain_seat(&mut self, stack: u32, player: Rc) { - let position = self.seats.len(); - let seat = Seat::new(stack, position, player); - println!("ADD {}", &seat); - self.seats.push(seat); - } - pub fn drop_seat(&mut self, position: usize) { - let seat = self.seats.remove(position); - println!("DROP {}", seat); - for (i, seat) in self.seats.iter_mut().enumerate() { - seat.position = i; - } - } -} - -use super::{ - action::Action, - player::Player, - seat::{BetStatus, Seat}, -}; -use crate::cards::board::{Board, Street}; -use std::{ - fmt::{Display, Formatter, Result}, - rc::Rc, -}; diff --git a/src/gameplay/payout.rs b/src/gameplay/payout.rs deleted file mode 100644 index 8d84388b..00000000 --- a/src/gameplay/payout.rs +++ /dev/null @@ -1,28 +0,0 @@ -#[derive(Debug, Clone)] -pub struct Payout { - pub position: usize, - pub strength: Strength, - pub status: BetStatus, - pub risked: u32, - pub reward: u32, -} - -impl Display for Payout { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if self.reward > 0 { - write!( - f, - "{:<6}{}", - format!("+{}", self.reward).green(), - self.strength - ) - } else { - write!(f, " {}", self.strength) - } - } -} - -use super::seat::BetStatus; -use crate::evaluation::strength::Strength; -use colored::Colorize; -use std::fmt::Display; diff --git a/src/gameplay/player.rs b/src/gameplay/player.rs deleted file mode 100644 index 8c5c82f1..00000000 --- a/src/gameplay/player.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub trait Player: Debug { - fn act(&self, seat: &Seat, hand: &Hand) -> Action; -} -use super::{action::Action, hand::Hand, seat::Seat}; -use std::fmt::Debug; diff --git a/src/gameplay/ply.rs b/src/gameplay/ply.rs new file mode 100644 index 00000000..d961adba --- /dev/null +++ b/src/gameplay/ply.rs @@ -0,0 +1,36 @@ +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] +pub enum Turn { + Terminal, + Chance, + Choice(usize), +} + +impl std::fmt::Display for Turn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Choice(c) => write!(f, "P{}", c), + Self::Terminal => write!(f, "XX"), + Self::Chance => write!(f, "??"), + } + } +} + +impl TryFrom<&str> for Turn { + type Error = &'static str; + fn try_from(s: &str) -> Result { + match s { + "XX" => Ok(Self::Terminal), + "??" => Ok(Self::Chance), + turn => { + if turn.starts_with('P') { + turn[1..] + .parse::() + .map(Self::Choice) + .map_err(|_| "invalid player turn") + } else { + Err("invalid ply input") + } + } + } + } +} diff --git a/src/gameplay/seat.rs b/src/gameplay/seat.rs index f80ba02d..d76b81c8 100644 --- a/src/gameplay/seat.rs +++ b/src/gameplay/seat.rs @@ -1,117 +1,98 @@ -#[derive(Debug, Clone)] +use crate::cards::hole::Hole; +use crate::Chips; + +#[derive(Debug, Clone, Copy)] pub struct Seat { - pub position: usize, - pub hole: Hole, - pub actor: Rc, // Weak ? - pub stack: u32, - pub stake: u32, - pub status: BetStatus, + state: State, + stack: Chips, + stake: Chips, + spent: Chips, + /// this field is the only non-public state, but if we're + /// client-side then we can just fill it with clones of our own private cards. + /// with this very natural method of obfuscation, + /// we can use Game struct as normal + cards: Hole, +} + +impl From for Seat { + fn from(stack: Chips) -> Self { + Self::new(stack) + } } + impl Seat { - pub fn new(stack: u32, position: usize, actor: Rc) -> Seat { + fn new(stack: Chips) -> Seat { Seat { - position, stack, + spent: 0, stake: 0, - status: BetStatus::Playing, - hole: Hole::new(), - actor, + state: State::Betting, + cards: Hole::empty(), } } - - pub fn cards(&self) -> &Hole { - &self.hole - } - - pub fn valid_actions(&self, hand: &Hand) -> Vec { - let mut actions = Vec::with_capacity(5); - if self.can_check(hand) { - actions.push(Action::Check(self.position)); - } - if self.can_fold(hand) { - actions.push(Action::Fold(self.position)); - } - if self.can_call(hand) { - actions.push(Action::Call(self.position, self.to_call(hand))); - } - if self.can_shove(hand) { - actions.push(Action::Shove(self.position, self.to_shove(hand))); - } - if self.can_raise(hand) { - actions.push(Action::Raise(self.position, self.min_raise(hand))); - } - actions + pub fn stack(&self) -> Chips { + self.stack } - - pub fn to_shove(&self, hand: &Hand) -> u32 { - std::cmp::min(self.stack, hand.head.effective_stack() - self.stake) + pub fn stake(&self) -> Chips { + self.stake } - pub fn to_call(&self, hand: &Hand) -> u32 { - hand.head.effective_stake() - self.stake + pub fn state(&self) -> State { + self.state } - pub fn min_raise(&self, hand: &Hand) -> u32 { - hand.min_raise() - self.stake + pub fn spent(&self) -> Chips { + self.spent } - pub fn max_raise(&self, hand: &Hand) -> u32 { - self.to_shove(hand) + pub fn cards(&self) -> Hole { + self.cards } - fn can_check(&self, hand: &Hand) -> bool { - self.stake == hand.head.effective_stake() + pub fn win(&mut self, win: Chips) { + self.stack += win; } - fn can_shove(&self, hand: &Hand) -> bool { - self.to_shove(hand) > 0 + pub fn bet(&mut self, bet: Chips) { + self.stack -= bet; + self.stake += bet; + self.spent += bet; } - fn can_fold(&self, hand: &Hand) -> bool { - self.to_call(hand) > 0 + pub fn reset_state(&mut self, state: State) { + self.state = state; } - fn can_raise(&self, hand: &Hand) -> bool { - self.to_shove(hand) >= self.min_raise(hand) + pub fn reset_cards(&mut self, cards: Hole) { + self.cards = cards; } - fn can_call(&self, hand: &Hand) -> bool { - self.can_fold(hand) && self.can_raise(hand) + pub fn reset_stake(&mut self) { + self.stake = 0; + } + pub fn reset_spent(&mut self) { + self.spent = 0; } } -impl Display for Seat { + +impl std::fmt::Display for Seat { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - if self.hole.cards.is_empty() { - return write!( - f, - "{:<3}{} {:>7} ", - self.position, self.status, self.stack - ); - } - let card1 = self.hole.cards.get(0).unwrap(); - let card2 = self.hole.cards.get(1).unwrap(); write!( f, - "{:<3}{} {} {} {:>7} ", - self.position, self.status, card1, card2, self.stack, + "{} {} {}", + self.state, + format!("${:>4}", self.stack), + self.cards ) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BetStatus { - Playing, - Shoved, - Folded, +pub enum State { + Betting, + Shoving, + Folding, } -impl Display for BetStatus { +impl std::fmt::Display for State { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { - BetStatus::Playing => write!(f, "P"), - BetStatus::Shoved => write!(f, "S"), - BetStatus::Folded => write!(f, "{}", "F".red()), + State::Betting => write!(f, "P"), + State::Shoving => write!(f, "S"), + State::Folding => write!(f, "F"), } } } - -use super::{action::Action, hand::Hand, player::Player}; -use crate::cards::hole::Hole; -use colored::Colorize; -use std::{ - fmt::{Debug, Display}, - rc::Rc, -}; diff --git a/src/gameplay/settlement.rs b/src/gameplay/settlement.rs new file mode 100644 index 00000000..26e354b8 --- /dev/null +++ b/src/gameplay/settlement.rs @@ -0,0 +1,38 @@ +use crate::cards::strength::Strength; +use crate::gameplay::seat::State; +use crate::Chips; + +#[derive(Debug, Clone)] +pub struct Settlement { + pub reward: Chips, + pub risked: Chips, + pub status: State, + pub strength: Strength, +} + +impl Settlement { + pub fn pnl(&self) -> Chips { + self.reward - self.risked + } +} + +impl From<(Chips, State, Strength)> for Settlement { + fn from((risked, status, strength): (Chips, State, Strength)) -> Self { + Self { + reward: 0, + risked, + status, + strength, + } + } +} + +impl std::fmt::Display for Settlement { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + if self.reward > 0 { + write!(f, "{:<5}{}", format!("+{}", self.reward), self.strength) + } else { + write!(f, " {}", self.strength) + } + } +} diff --git a/src/gameplay/showdown.rs b/src/gameplay/showdown.rs new file mode 100644 index 00000000..a855f9e3 --- /dev/null +++ b/src/gameplay/showdown.rs @@ -0,0 +1,242 @@ +use crate::cards::kicks::Kickers; +use crate::cards::ranking::Ranking; +use crate::cards::strength::Strength; +use crate::gameplay::seat::State; +use crate::gameplay::settlement::Settlement; +use crate::Chips; + +// ephemeral data structure that is used to calculate the results of a hand by iterating over hand.actions to calculate side pots, handling every edge case with generalized zero-cost logic +pub struct Showdown { + payouts: Vec, + distributing: Chips, + distributed: Chips, + best: Strength, +} + +impl From> for Showdown { + fn from(payouts: Vec) -> Self { + Self { + payouts, + distributing: 0 as Chips, + distributed: 0 as Chips, + best: Strength::from((Ranking::MAX, Kickers::default())), + } + } +} + +impl Showdown { + pub fn settle(mut self) -> Vec { + 'winners: while let Some(strength) = self.strongest() { + self.best = strength; + 'pots: while let Some(amount) = self.remaining() { + self.distributing = amount; + self.distribute(); + if self.is_complete() { + break 'winners; + } else { + continue 'pots; + } + } + } + self.payouts + } + fn strongest(&self) -> Option { + self.payouts + .iter() + .filter(|p| p.strength < self.best) + .filter(|p| p.status != State::Folding) + .map(|p| p.strength) + .max() + } + fn remaining(&mut self) -> Option { + self.distributed = self.distributing; + self.payouts + .iter() + .filter(|p| p.strength == self.best) + .filter(|p| p.risked > self.distributed) + .filter(|p| p.status != State::Folding) + .map(|p| p.risked) + .min() + } + fn winnings(&self) -> Chips { + self.payouts + .iter() + .map(|p| p.risked) + .map(|s| std::cmp::min(s, self.distributing)) + .map(|s| (s - self.distributed).max(0)) + .sum() + } + fn distribute(&mut self) { + let chips = self.winnings(); + let mut winners = self + .payouts + .iter_mut() + .filter(|p| p.status != State::Folding) + .filter(|p| p.strength == self.best) + .filter(|p| p.risked > self.distributed) + .collect::>(); + let n = winners.len(); + let share = chips / n as Chips; + let bonus = chips % n as Chips; + for winner in winners.iter_mut() { + winner.reward += share; + } + for winner in winners.iter_mut().take(bonus as usize) { + winner.reward += 1; + } + } + fn is_complete(&self) -> bool { + let staked = self.payouts.iter().map(|p| p.risked).sum::(); + let reward = self.payouts.iter().map(|p| p.reward).sum::(); + staked == reward + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cards::rank::Rank; + // Define functions for hand strengths + fn ace_high() -> Strength { + Strength::from((Ranking::HighCard(Rank::Ace), Kickers::default())) + } + fn one_pair() -> Strength { + Strength::from((Ranking::OnePair(Rank::Ace), Kickers::default())) + } + fn two_pair() -> Strength { + Strength::from((Ranking::TwoPair(Rank::Ace, Rank::King), Kickers::default())) + } + fn triplets() -> Strength { + Strength::from((Ranking::ThreeOAK(Rank::Ace), Kickers::default())) + } + fn the_nuts() -> Strength { + Strength::from((Ranking::Straight(Rank::Ace), Kickers::default())) + } + + #[test] + fn heads_up_showdown() { + let settlement = Showdown::from(vec![ + Settlement::from((100, State::Betting, ace_high())), + Settlement::from((100, State::Betting, one_pair())), + ]) + .settle(); + assert!(settlement[0].reward == 0); + assert!(settlement[1].reward == 200); + } + + #[test] + fn winners_folded() { + let settlement = Showdown::from(vec![ + Settlement::from((050, State::Folding, the_nuts())), + Settlement::from((100, State::Betting, two_pair())), + Settlement::from((075, State::Folding, the_nuts())), + Settlement::from((100, State::Betting, one_pair())), + ]) + .settle(); + assert!(settlement[0].reward == 0); + assert!(settlement[1].reward == 325); + assert!(settlement[2].reward == 0); + assert!(settlement[3].reward == 0); + } + + #[test] + fn multiway_pot_split() { + let settlement = Showdown::from(vec![ + Settlement::from((100, State::Betting, two_pair())), + Settlement::from((100, State::Betting, two_pair())), + Settlement::from((100, State::Betting, one_pair())), + ]) + .settle(); + assert!(settlement[0].reward == 150); + assert!(settlement[1].reward == 150); + assert!(settlement[2].reward == 0); + } + + #[test] + fn multiway_winner_takes_all() { + let settlement = Showdown::from(vec![ + Settlement::from((200, State::Betting, the_nuts())), + Settlement::from((150, State::Shoving, triplets())), + Settlement::from((200, State::Betting, two_pair())), + Settlement::from((100, State::Shoving, one_pair())), + Settlement::from((050, State::Folding, the_nuts())), + ]) + .settle(); + assert!(settlement[0].reward == 700); + assert!(settlement[1].reward == 0); + assert!(settlement[2].reward == 0); + assert!(settlement[3].reward == 0); + assert!(settlement[4].reward == 0); + } + + #[test] + fn multiway_all_in_with_uneven_stacks() { + let settlement = Showdown::from(vec![ + Settlement::from((150, State::Shoving, the_nuts())), + Settlement::from((200, State::Shoving, triplets())), + Settlement::from((350, State::Shoving, one_pair())), + Settlement::from((050, State::Shoving, ace_high())), + ]) + .settle(); + assert!(settlement[0].reward == 500); + assert!(settlement[1].reward == 100); + assert!(settlement[2].reward == 150); + assert!(settlement[3].reward == 0); + } + + #[test] + fn multiway_all_in_with_side_pot() { + let settlement = Showdown::from(vec![ + Settlement::from((050, State::Shoving, the_nuts())), + Settlement::from((100, State::Shoving, triplets())), + Settlement::from((150, State::Betting, one_pair())), + Settlement::from((150, State::Betting, ace_high())), + ]) + .settle(); + assert!(settlement[0].reward == 200); + assert!(settlement[1].reward == 150); + assert!(settlement[2].reward == 100); + assert!(settlement[3].reward == 0); + } + + #[test] + fn singular_all_in_with_side_pot() { + let settlement = Showdown::from(vec![ + Settlement::from((050, State::Shoving, two_pair())), + Settlement::from((100, State::Betting, one_pair())), + Settlement::from((100, State::Betting, ace_high())), + ]) + .settle(); + assert!(settlement[0].reward == 150); + assert!(settlement[1].reward == 100); + assert!(settlement[2].reward == 0); + } + + #[test] + fn singular_all_in_with_side_pot_split() { + let settlement = Showdown::from(vec![ + Settlement::from((050, State::Shoving, the_nuts())), + Settlement::from((100, State::Betting, two_pair())), + Settlement::from((100, State::Betting, two_pair())), + ]) + .settle(); + assert!(settlement[0].reward == 150); + assert!(settlement[1].reward == 50); + assert!(settlement[2].reward == 50); + } + + #[test] + fn last_man_standing() { + let settlement = Showdown::from(vec![ + Settlement::from((050, State::Folding, the_nuts())), + Settlement::from((100, State::Betting, ace_high())), + Settlement::from((075, State::Folding, the_nuts())), + Settlement::from((025, State::Folding, the_nuts())), + ]) + .settle(); + assert!(settlement[0].reward == 0); + assert!(settlement[1].reward == 250); + assert!(settlement[2].reward == 0); + assert!(settlement[3].reward == 0); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..4b98e0cb --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,120 @@ +#[cfg(feature = "native")] +pub mod analysis; +#[cfg(feature = "native")] +pub mod players; +#[cfg(feature = "native")] +pub mod save; + +pub mod cards; +pub mod clustering; +pub mod gameplay; +pub mod mccfr; +pub mod search; +pub mod translate_action; +pub mod transport; +pub mod wasm; + +/// dimensional analysis types +type Chips = i16; +type Equity = f32; +type Energy = f32; +type Entropy = f32; +type Utility = f32; +type Probability = f32; + +// game tree parameters +const N: usize = 2; +const STACK: Chips = 100; +const B_BLIND: Chips = 2; +const S_BLIND: Chips = 1; +const MAX_RAISE_REPEATS: usize = 3; +const MAX_DEPTH_SUBGAME: usize = 16; + +/// sinkhorn optimal transport parameters +const SINKHORN_TEMPERATURE: Entropy = 0.025; +const SINKHORN_ITERATIONS: usize = 128; +const SINKHORN_TOLERANCE: Energy = 0.001; + +// kmeans clustering parameters +const KMEANS_FLOP_TRAINING_ITERATIONS: usize = 20; +const KMEANS_TURN_TRAINING_ITERATIONS: usize = 24; +const KMEANS_FLOP_CLUSTER_COUNT: usize = 128; +const KMEANS_TURN_CLUSTER_COUNT: usize = 144; +const KMEANS_EQTY_CLUSTER_COUNT: usize = 101; + +// mccfr parameters +const CFR_BATCH_SIZE: usize = 0x100; +const CFR_TREE_COUNT: usize = 0x400000; +const CFR_ITERATIONS: usize = CFR_TREE_COUNT / CFR_BATCH_SIZE; +const CFR_PRUNNING_PHASE: usize = 100_000_000 / CFR_BATCH_SIZE; +const CFR_DISCOUNT_PHASE: usize = 100_000 / CFR_BATCH_SIZE; +const MAIN_TRAINING_ITERATIONS: usize = CFR_ITERATIONS; +const FINE_TRAINING_ITERATIONS: usize = 0x4000; + +// regret matching parameters +const REGRET_MIN: Utility = -3e5; +const REGRET_MAX: Utility = Utility::MAX; +const POLICY_MIN: Probability = Probability::MIN_POSITIVE; + +/// trait for random generation, mainly (strictly?) for testing +pub trait Arbitrary { + fn random() -> Self; +} + +/// progress bar +#[cfg(feature = "native")] +pub fn progress(n: usize) -> indicatif::ProgressBar { + let tick = std::time::Duration::from_secs(60); + let style = "{spinner:.cyan} {elapsed} ~ {percent:>3}% {wide_bar:.cyan}"; + let style = indicatif::ProgressStyle::with_template(style).unwrap(); + let progress = indicatif::ProgressBar::new(n as u64); + progress.set_style(style); + progress.enable_steady_tick(tick); + progress +} + +/// initialize logging and exit on ctrl-c +#[cfg(feature = "native")] +pub fn init() { + tokio::spawn(async move { + tokio::signal::ctrl_c().await.unwrap(); + println!(); + log::warn!("forcing exit"); + std::process::exit(0); + }); + std::fs::create_dir_all("logs").expect("create logs directory"); + let config = simplelog::ConfigBuilder::new() + .set_location_level(log::LevelFilter::Off) + .set_target_level(log::LevelFilter::Off) + .set_thread_level(log::LevelFilter::Off) + .build(); + let time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time moves slow") + .as_secs(); + let file = simplelog::WriteLogger::new( + log::LevelFilter::Debug, + config.clone(), + std::fs::File::create(format!("logs/{}.log", time)).expect("create log file"), + ); + let term = simplelog::TermLogger::new( + log::LevelFilter::Info, + config.clone(), + simplelog::TerminalMode::Mixed, + simplelog::ColorChoice::Auto, + ); + simplelog::CombinedLogger::init(vec![term, file]).expect("initialize logger"); +} + +/// get a database connection and return the client +#[cfg(feature = "native")] +pub async fn db() -> std::sync::Arc { + log::info!("connecting to database"); + let tls = tokio_postgres::tls::NoTls; + let ref url = std::env::var("DB_URL").expect("DB_URL must be set"); + let (client, connection) = tokio_postgres::connect(url, tls) + .await + .expect("database connection failed"); + tokio::spawn(connection); + std::sync::Arc::new(client) +} diff --git a/src/main.rs b/src/main.rs index 7abca7b0..7e50491c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,25 +1,68 @@ -use gameplay::engine::Table; -use players::{human::Human, robot::Robot}; -use std::rc::Rc; +use robopoker::*; -pub mod cards; -pub mod evaluation; -pub mod gameplay; -pub mod players; -pub mod strategy; +/* -#[tokio::main] -async fn main() { - let mut engine = Table::new(); - let human = Rc::new(Human); - let robot = Rc::new(Robot); ++ 2019. Superhuman AI for multiplayer poker. (https://science.sciencemag.org/content/early/2019/07/10/science.aay2400) Science, July 11th. + 2019. Regret Circuits: Composability of Regret Minimizers. (https://arxiv.org/abs/1811.02540). In ICML. + 2019. Stable-Predictive Optimistic Counterfactual Regret Minimization. (https://arxiv.org/pdf/1902.04982.pdf). In ICML. + 2019. Deep Counterfactual Regret Minimization. (https://arxiv.org/pdf/1811.00164.pdf). In NeurIPS. ++ 2019. Solving Imperfect-Information Games via Discounted Regret Minimization (https://arxiv.org/pdf/1809.04040.pdf). In Proceedings of the AAAI Conference on Artificial Intelligence (AAAI). + 2019. Online Convex Optimization for Sequential Decision Processes and Extensive-Form Games (http://www.cs.cmu.edu/~gfarina/2018/laminar-regret-aaai19/). In Proceedings of the AAAI Conference on Artificial Intelligence (AAAI). + 2019. Quasi-Perfect Stackelberg Equilibrium (http://www.cs.cmu.edu/~gfarina/2018/qp-stackelberg-aaai19/). In Proceedings of the AAAI Conference on Artificial Intelligence (AAAI). + 2019. Stable-Predictive Optimistic Counterfactual Regret Minimization (https://arxiv.org/pdf/1902.04982.pdf). arXiv. + 2018. Faster algorithms for extensive-form game solving via improved smoothing functions. (https://rdcu.be/8EyP) Mathematical Programming, Series A. Abstract published in EC-17. + 2018. Near-linear time approximation algorithms for optimal transport via Sinkhorn iteration (https://arxiv.org/abs/1705.09634). In Proc. Neural Information Processing Systems (NeurIPS). ++ 2018. Depth-Limited Solving for Imperfect-Information Games. (https://arxiv.org/pdf/1805.08195.pdf) In Proc. Neural Information Processing Systems (NeurIPS). + 2018. A Unified Framework for Extensive-Form Game Abstraction with Bounds. In NIPS. Early version (http://www.cs.cmu.edu/~ckroer/papers/unified_abstraction_framework_ai_cubed.pdf) in IJCAI-18 AI^3 workshop. + 2018. Practical Exact Algorithm for Trembling-Hand Equilibrium Refinements in Games. (http://www.cs.cmu.edu/~gfarina/2017/trembling-lp-refinements-nips18/) In NeurIPS. + 2018. Solving Large Sequential Games with the Excessive Gap Technique. (https://arxiv.org/abs/1810.03063) In NeurIPS. Also Spotlight presentation. + 2018. Ex Ante Coordination and Collusion in Zero-Sum Multi-Player Extensive-Form Games. (http://www.cs.cmu.edu/~gfarina/2018/collusion-3pla≈yers-nips18/) In NeurIPS. + 2018. Trembling-Hand Perfection in Extensive-Form Games with Commitment. (http://www.cs.cmu.edu/~ckroer/papers/stackelberg_perfection_ijcai18.pdf) In IJCAI. + 2018. Robust Stackelberg Equilibria in Extensive-Form Games and Extension to Limited Lookahead. (http://www.cs.cmu.edu/~ckroer/papers/robust.aaai18.pdf) In Proc. AAAI Conference on AI (AAAI). ++ 2017. Safe and Nested Subgame Solving for Imperfect-Information Games. (https://www.cs.cmu.edu/~noamb/papers/17-NIPS-Safe.pdf) In NIPS. * *Best Paper Award, out of 3,240 submissions. + 2017. Regret Minimization in Behaviorally-Constrained Zero-Sum Games. (http://www.cs.cmu.edu/~sandholm/behavioral.icml17.pdf) In Proc. International Conference on Machine Learning (ICML). ++ 2017. Reduced Space and Faster Convergence in Imperfect-Information Games via Pruning. (http://www.cs.cmu.edu/~sandholm/reducedSpace.icml17.pdf) In ICML. + 2017. Smoothing Method for Approximate Extensive-Form Perfect Equilibrium. (http://www.cs.cmu.edu/~sandholm/smoothingEFPE.ijcai17.pdf) In IJCAI. ArXiv version. (http://arxiv.org/abs/1705.09326) + 2017. Dynamic Thresholding and Pruning for Regret Minimization. (http://www.cs.cmu.edu/~sandholm/dynamicThresholding.aaai17.pdf) In AAAI. + 2016. Imperfect-Recall Abstractions with Bounds in Games. (http://www.cs.cmu.edu/~sandholm/imperfect-recall-abstraction-with-bounds.ec16.pdf) In EC. + 2016. Strategy-Based Warm Starting for Regret Minimization in Games. (http://www.cs.cmu.edu/~sandholm/warmStart.aaai16.withAppendixAndTypoFix.pdf). In AAAI. ++ 2015. Regret-Based Pruning in Extensive-Form Games. (http://www.cs.cmu.edu/~sandholm/cs15-892F15) In NIPS. Extended version. (http://www.cs.cmu.edu/~sandholm/regret-basedPruning.nips15.withAppendix.pdf) + 2015. Simultaneous Abstraction and Equilibrium Finding in Games. (http://www.cs.cmu.edu/~sandholm/simultaneous.ijcai15.pdf) In IJCAI. + 2015. Limited Lookahead in Imperfect-Information Games. (http://www.cs.cmu.edu/~sandholm/limited-look-ahead.ijcai15.pdf) IJCAI. + 2015. Faster First-Order Methods for Extensive-Form Game Solving. (http://www.cs.cmu.edu/~sandholm/faster.ec15.pdf) In EC. + 2015. Hierarchical Abstraction, Distributed Equilibrium Computation, and Post-Processing, with Application to a Champion No-Limit Texas Hold’em Agent. (http://www.cs.cmu.edu/~sandholm/hierarchical.aamas15.pdf) In AAMAS. + 2015. Discretization of Continuous Action Spaces in Extensive-Form Games. (http://www.cs.cmu.edu/~sandholm/discretization.aamas15.fromACM.pdf) In AAMAS. + 2015. Endgame Solving in Large Imperfect-Information Games. (http://www.cs.cmu.edu/~sandholm/endgame.aamas15.fromACM.pdf) In AAMAS. + 2014. Extensive-Form Game Abstraction With Bounds. (http://www.cs.cmu.edu/~sandholm/extensiveGameAbstraction.ec14.pdf) In EC. + 2014. Regret Transfer and Parameter Optimization. (http://www.cs.cmu.edu/~sandholm/regret_transfer.aaai14.pdf) In AAAI. ++ 2014. Potential-Aware Imperfect-Recall Abstraction with Earth Mover’s Distance in Imperfect-Information Games. (http://www.cs.cmu.edu/~sandholm/potential-aware_imperfect-recall.aaai14.pdf) In AAAI. ++ 2013. Action Translation in Extensive-Form Games with Large Action Spaces: Axioms, Paradoxes, and the Pseudo-Harmonic Mapping. (http://www.cs.cmu.edu/~sandholm/reverse%20mapping.ijcai13.pdf) In IJCAI. ++ 2013. A Fast and Optimal Hand Isomorphism Algorithm. (https://www.cs.cmu.edu/~waugh/publications/isomorphism13.pdf). In AAAI. + 2012. Lossy Stochastic Game Abstraction with Bounds. (http://www.cs.cmu.edu/~sandholm/lossyStochasticGameAbstractionWBounds.ec12.pdf) In EC. + 2012. First-Order Algorithm with O(ln(1/epsilon)) Convergence for epsilon-Equilibrium in Two-Person Zero-Sum Games. (http://www.cs.cmu.edu/~sandholm/restart.MathProg12.pdf) Mathematical Programming 133(1-2), 279-298. Subsumes our AAAI-08 20paper. + 2012. Strategy Purification and Thresholding: Effective Non-Equilibrium Approaches for Playing Large Games. (http://www.cs.cmu.edu/~sandholm/StrategyPurification_AAMAS2012_camera_ready_2.pdf) In AAMAS. + 2012. Tartanian5: A Heads-Up No-Limit Texas Hold'em Poker-Playing Program. (http://www.cs.cmu.edu/~sandholm/Tartanian_ACPC12_CR.pdf) Computer Poker Symposium at AAAI. + 2010. Making k-means even faster. (https://epubs.siam.org/doi/pdf/10.1137/1.9781611972801.12) In SIAM. + 2010. Smoothing techniques for computing Nash equilibria of sequential games. (http://www.cs.cmu.edu/~sandholm/proxtreeplex.MathOfOR.pdf) Mathematics of Operations Research 35(2), 494-512. + 2010. Computing Equilibria by Incorporating Qualitative Models (http://www.cs.cmu.edu/~sandholm/qualitative.aamas10.pdf). In AAMAS. Extended version (http://www.cs.cmu.edu/~sandholm/qualitative.TR10.pdf): CMU technical report 20CMU-CS-10-105. + 2010. Speeding Up Gradient-Based Algorithms for Sequential Games (Extended Abstract) (http://www.cs.cmu.edu/~sandholm/speedup.aamas10.pdf). In AAMAS. + 2009. Computing Equilibria in Multiplayer Stochastic Games of Imperfect Information (http://www.cs.cmu.edu/~sandholm/stochgames.ijcai09.pdf). In IJCAI. + 2003. Using the Triangle Inequality to Accelerate-Means (https://cdn.aaai.org/ICML/2003/ICML03-022.pdf) In ICML. - // engine.gain_seat(100, human.clone()); - engine.gain_seat(100, robot.clone()); - engine.gain_seat(100, robot.clone()); - engine.gain_seat(100, robot.clone()); - engine.gain_seat(100, robot.clone()); - engine.gain_seat(100, robot.clone()); +*/ - engine.play(); +#[tokio::main] +async fn main() { + // Behold! + crate::init(); + // The k-means earth mover's distance hand-clustering algorithm. + crate::clustering::layer::Layer::learn(); + // Monte Carlo counter-factual regret minimization. External sampling, alternating regret updates, linear weighting schedules. + crate::mccfr::blueprint::Blueprint::train(); + // Let's upload the data to the database. + crate::save::writer::Writer::save().await.unwrap(); + // Let's support our frontend. + crate::analysis::server::Server::run().await.unwrap(); + // Let's see what we've learned. + crate::analysis::cli::CLI::run().await; } diff --git a/src/mccfr/blueprint.rs b/src/mccfr/blueprint.rs new file mode 100644 index 00000000..d907b129 --- /dev/null +++ b/src/mccfr/blueprint.rs @@ -0,0 +1,209 @@ +use super::counterfactual::Counterfactual; +use super::encoder::Encoder; +use super::info::Info; +use super::node::Node; +use super::partition::Partition; +use super::player::Player; +use super::policy::Policy; +use super::profile::Profile; +use super::recall::Recall; +use super::tree::Branch; +use super::tree::Tree; +use crate::cards::street::Street; +use crate::Arbitrary; +use std::sync::Arc; +use std::sync::RwLock; + +/// this is how we learn the optimal strategy of +/// the abstracted game. with the learned Encoder +/// to abstract all Action and Game objects, we +/// populate and use a Profile to sample Trees, calculate +/// regret and policy updates, then apply the updates to +/// Profile strategies. it's useful to think about the +/// 3 steps of Exploration, RegretEvaluation, and PolicyUpdate. +/// +/// - Tree exploration mutates Profile since it must +/// "witness" all the decision points of the sampled Tree. +/// - Regret & Policy vector evaluations are pure. +/// - Profile updates mutates Profile for obvious reasons. +#[derive(Default)] +pub struct Blueprint { + profile: Arc>, + encoder: Encoder, +} + +impl Blueprint { + /// after training, use the learned Profile to advise + /// a Spot on how to play. + pub fn policy(&self, recall: &Recall) -> Policy { + let bucket = self.encoder.bucket(&recall); // this becomes database lookup on recall.game().sweat(), and the Path's are constructed in memory infalliably + let profile = self.profile.read().unwrap(); + let policy = profile.policy(&bucket); // expand into Result chained calls to database, trying perfect match but weakening index upon every failure + policy + } + + /// here's the training loop. infosets might be generated + /// in parallel later. infosets come pre-filtered + /// for the traverser. regret and policy updates are + /// encapsulated by Profile, but we are yet to impose + /// a learning schedule for regret or policy. + #[cfg(feature = "native")] + pub fn train() { + use crate::save::upload::Table; + if Self::done(Street::random()) { + log::info!("resuming regret minimization"); + Self::load(Street::random()).solve(crate::FINE_TRAINING_ITERATIONS); + } else { + log::info!("starting regret minimization"); + Self::grow(Street::random()).solve(crate::MAIN_TRAINING_ITERATIONS); + } + } + + /// the main training loop. + #[cfg(feature = "native")] + fn solve(self, t: usize) -> Self { + log::info!("beginning training loop"); + use crate::save::upload::Table; + let progress = crate::progress(t * crate::CFR_BATCH_SIZE); + for _ in 0..t { + let counterfactuals = self.simulations(); + let mut profile = self.profile.write().unwrap(); + for counterfactual in counterfactuals { + let ref regret = counterfactual.regret(); + let ref policy = counterfactual.policy(); + let ref bucket = counterfactual.info().node().bucket().clone(); + profile.add_regret(bucket, regret); + profile.add_policy(bucket, policy); + progress.inc(1); + } + { + log::debug!( + "epoch {:<10} touched {:<10}", + profile.next(), + profile.size() + ); + } + } + progress.finish(); + self.profile.read().unwrap().save(); + self + } + + /// compute regret and policy updates for a batch of Trees. + #[cfg(feature = "native")] + fn simulations(&self) -> Vec { + use rayon::iter::IntoParallelIterator; + use rayon::iter::ParallelIterator; + (0..crate::CFR_BATCH_SIZE) + .into_par_iter() // Now we can parallelize the search itself! + .map(|_| self.tree()) + .inspect(|tree| log::trace!("{}", tree)) + .map(Partition::from) + .map(Vec::::from) + .flatten() + .map(|info| self.profile.read().unwrap().counterfactual(info)) + .collect::>() + } + + /// Build the Tree iteratively starting from the root node. + /// This function uses a stack to simulate recursion and builds the tree in a depth-first manner. + fn tree(&self) -> Tree { + let walker = { self.profile.read().unwrap().walker() }; + let mut tree = Tree::empty(walker); + let ref root = tree.plant(self.encoder.seed()); + let mut todo = self.sample(root); + while let Some(branch) = todo.pop() { + let ref node = tree.fork(branch); + let children = self.sample(node); + todo.extend(children); + } + tree + } + + /// the Node is already attached to the Tree. + /// here, we calculate, what Branches + /// would we like to sample from this Node, + /// conditional on its History and on our sampling + /// rules? (i.e. external sampling, probing, full + /// exploration, etc.) + fn sample(&self, node: &Node) -> Vec { + let chance = Player::chance(); + let walker = { self.profile.read().unwrap().walker() }; + let branches = self.encoder.branches(node); + match (branches.len(), node.player()) { + (0, _) => vec![], + (_, p) if p == chance => self.touch_any(branches, node), + (_, p) if p != walker => self.touch_one(branches, node), + (_, p) if p == walker => self.touch_all(branches, node), + _ => panic!("at the disco"), + } + } + + fn touch_any(&self, branches: Vec, node: &Node) -> Vec { + self.profile.read().unwrap().explore_any(branches, node) + } + + fn touch_all(&self, branches: Vec, node: &Node) -> Vec { + let _ = { self.profile.write().unwrap().witness(node, &branches) }; + self.profile.read().unwrap().explore_all(branches, node) + } + + fn touch_one(&self, branches: Vec, node: &Node) -> Vec { + let _ = { self.profile.write().unwrap().witness(node, &branches) }; + self.profile.read().unwrap().explore_one(branches, node) + } +} + +#[cfg(feature = "native")] +impl crate::save::upload::Table for Blueprint { + fn done(street: Street) -> bool { + Profile::done(street) && Encoder::done(street) + } + + fn save(&self) { + self.profile.read().unwrap().save(); + self.encoder.save(); + } + + fn grow(_: Street) -> Self { + // we require an encoder to be trained & loaded + // but not necessarily a profile + Self { + profile: Arc::new(RwLock::new(Profile::default())), + encoder: Encoder::load(Street::random()), + } + } + + fn load(_: Street) -> Self { + // basically the same as grow but w the expectation + // that profile is trained & loaded + Self { + profile: Arc::new(RwLock::new(Profile::load(Street::random()))), + encoder: Encoder::load(Street::random()), + } + } + + fn name() -> String { + unimplemented!() + } + + fn copy() -> String { + unimplemented!() + } + + fn creates() -> String { + unimplemented!() + } + + fn indices() -> String { + unimplemented!() + } + + fn columns() -> &'static [tokio_postgres::types::Type] { + unimplemented!() + } + + fn sources() -> Vec { + unimplemented!() + } +} diff --git a/src/mccfr/bucket.rs b/src/mccfr/bucket.rs new file mode 100644 index 00000000..a137e27e --- /dev/null +++ b/src/mccfr/bucket.rs @@ -0,0 +1,28 @@ +use super::path::Path; +use crate::clustering::abstraction::Abstraction; +use crate::Arbitrary; +use std::hash::Hash; + +/// can't tell whether default bucket makes sense. +/// it requires default absttraction, for one, which +/// i guess would just be P::00, but it can't be derived in [Abstraction] +/// because of the Middle bit hashing we do in [Abstraction] +#[derive(Debug, /* Default, */ Clone, Copy, Eq, Hash, PartialEq, Ord, PartialOrd)] +pub struct Bucket(pub Path, pub Abstraction, pub Path); + +impl From<(Path, Abstraction, Path)> for Bucket { + fn from((past, present, future): (Path, Abstraction, Path)) -> Self { + Self(past, present, future) + } +} +impl std::fmt::Display for Bucket { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}>>{}<<{}", self.0, self.1, self.2) + } +} + +impl Arbitrary for Bucket { + fn random() -> Self { + Self::from((Path::random(), Abstraction::random(), Path::random())) + } +} diff --git a/src/mccfr/counterfactual.rs b/src/mccfr/counterfactual.rs new file mode 100644 index 00000000..7983c9c8 --- /dev/null +++ b/src/mccfr/counterfactual.rs @@ -0,0 +1,31 @@ +use super::info::Info; +use super::policy::Policy; +use super::regret::Regret; + +pub struct Counterfactual { + info: Info, + regret: Regret, + policy: Policy, +} + +impl Counterfactual { + pub fn info(&self) -> &Info { + &self.info + } + pub fn regret(&self) -> &Regret { + &self.regret + } + pub fn policy(&self) -> &Policy { + &self.policy + } +} + +impl From<(Info, Regret, Policy)> for Counterfactual { + fn from((info, regret, policy): (Info, Regret, Policy)) -> Self { + Self { + info, + regret, + policy, + } + } +} diff --git a/src/mccfr/data.rs b/src/mccfr/data.rs new file mode 100644 index 00000000..cf14e941 --- /dev/null +++ b/src/mccfr/data.rs @@ -0,0 +1,50 @@ +use super::bucket::Bucket; +use crate::clustering::abstraction::Abstraction; +use crate::gameplay::game::Game; +use crate::mccfr::player::Player; + +#[derive(Debug)] +pub struct Data { + game: Game, + cluster: Abstraction, + /// this gets populated on the second pass of tree generation + /// because it requires global information as a + /// rank-1 hypergraph quantity + infoset: Option, +} + +impl From<(Game, Abstraction)> for Data { + fn from((game, cluster): (Game, Abstraction)) -> Self { + Self { + game, + cluster, + infoset: None, + } + } +} + +impl Data { + pub fn game(&self) -> &Game { + &self.game + } + pub fn player(&self) -> Player { + Player(self.game().turn()) + } + pub fn bucket(&self) -> &Bucket { + self.infoset.as_ref().expect("bucket assigned") + } + /// upstream of us, our resident Tree is partitioning + /// the Data into buckets containing "global" higher rank + /// information that we can't conveive of. so at compile + /// time we tell ourselves that we will "fill in the blanks" + /// later in the Tree generation and partitioning process. + pub fn assign(&mut self, bucket: Bucket) { + match self.infoset { + None => self.infoset = Some(bucket), + Some(_) => panic!("don't overwrite bucket"), + } + } + pub fn abstraction(&self) -> &Abstraction { + &self.cluster + } +} diff --git a/src/mccfr/discount.rs b/src/mccfr/discount.rs new file mode 100644 index 00000000..01ad628a --- /dev/null +++ b/src/mccfr/discount.rs @@ -0,0 +1,38 @@ +use crate::Utility; + +#[derive(Debug)] +pub struct Discount { + period: usize, // interval between strategy updates. + alpha: f32, // α parameter. controls recency bias. + omega: f32, // ω parameter. controls recency bias. + gamma: f32, // γ parameter. controls recency bias. +} + +impl Discount { + pub const fn default() -> &'static Self { + &Self { + period: 1, + alpha: 1.5, + omega: 0.5, + gamma: 2.0, + } + } + + pub fn policy(&self, t: usize) -> f32 { + (t as f32 / (t as f32 + 1.)).powf(self.gamma) + } + + pub fn regret(&self, t: usize, regret: Utility) -> Utility { + if t % self.period != 0 { + 1. + } else if regret > 0. { + let x = (t as f32 / self.period as f32).powf(self.alpha); + x / (x + 1.) + } else if regret < 0. { + let x = (t as f32 / self.period as f32).powf(self.omega); + x / (x + 1.) + } else { + 1. + } + } +} diff --git a/src/mccfr/edge.rs b/src/mccfr/edge.rs new file mode 100644 index 00000000..39410ecd --- /dev/null +++ b/src/mccfr/edge.rs @@ -0,0 +1,168 @@ +use crate::gameplay::action::Action; +use crate::mccfr::odds::Odds; +use crate::Arbitrary; +use crate::Chips; +use std::hash::Hash; + +#[derive(Debug, Clone, Copy, Hash, Ord, PartialOrd, PartialEq, Eq)] +pub enum Edge { + Draw, + Fold, + Check, + Call, + Raise(Odds), + Shove, +} + +impl Edge { + pub fn is_shove(&self) -> bool { + matches!(self, Edge::Shove) + } + pub fn is_raise(&self) -> bool { + matches!(self, Edge::Raise(_)) + } + pub fn is_chance(&self) -> bool { + matches!(self, Edge::Draw) + } + pub fn is_aggro(&self) -> bool { + self.is_raise() || self.is_shove() + } + pub fn is_choice(&self) -> bool { + !self.is_chance() + } +} + +impl From for Edge { + fn from(action: Action) -> Self { + match action { + Action::Fold => Edge::Fold, + Action::Check => Edge::Check, + Action::Call(_) => Edge::Call, + Action::Draw(_) => Edge::Draw, + Action::Shove(_) => Edge::Shove, + Action::Raise(_) => panic!("raise must be converted from odds"), + Action::Blind(_) => panic!("blinds are not in any MCCFR trees"), + } + } +} +impl From for Edge { + fn from(odds: Odds) -> Self { + Edge::Raise(odds) + } +} + +/// usize bijection +impl From for u8 { + fn from(edge: Edge) -> Self { + match edge { + Edge::Draw => 1, + Edge::Fold => 2, + Edge::Check => 3, + Edge::Call => 4, + Edge::Shove => 5, + Edge::Raise(odds) => { + 6 + Odds::GRID + .iter() + .position(|&o| o == odds) + .expect("invalid odds value") as u8 + } + } + } +} +impl From for Edge { + fn from(value: u8) -> Self { + match value { + 1 => Edge::Draw, + 2 => Edge::Fold, + 3 => Edge::Check, + 4 => Edge::Call, + 5 => Edge::Shove, + i @ 6..=15 => Edge::Raise(Odds::GRID[i as usize - 6]), + _ => unreachable!("invalid edge encoding"), + } + } +} + +/// u64 bijection +impl From for Edge { + fn from(value: u64) -> Self { + // Use first 3 bits for variant tag + match value & 0b111 { + 0 => Self::Draw, + 1 => Self::Fold, + 2 => Self::Check, + 3 => Self::Call, + 4 => Self::Raise(Odds( + ((value >> 3) & 0xFF) as Chips, + ((value >> 11) & 0xFF) as Chips, + )), + 5 => Self::Shove, + _ => unreachable!(), + } + } +} +impl From for u64 { + fn from(edge: Edge) -> Self { + match edge { + Edge::Draw => 0, + Edge::Fold => 1, + Edge::Check => 2, + Edge::Call => 3, + Edge::Raise(Odds(num, den)) => 4 | ((num as u64) << 3) | ((den as u64) << 11), + Edge::Shove => 5, + } + } +} + +impl std::fmt::Display for Edge { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Edge::Draw => write!(f, "{}", "?"), + Edge::Fold => write!(f, "{}", "F"), + Edge::Call => write!(f, "{}", "*"), + Edge::Check => write!(f, "{}", "O"), + Edge::Shove => write!(f, "{}", "!"), + Edge::Raise(odds) => write!(f, "{}", odds), + } + } +} + +#[cfg(test)] +mod bijection_tests { + use super::*; + + #[test] + fn bijective_usize() { + let raise = Odds::GRID.map(Edge::Raise); + let edges = [Edge::Draw, Edge::Fold, Edge::Check, Edge::Call, Edge::Shove]; + assert!(edges + .into_iter() + .chain(raise) + .all(|edge| edge == Edge::from(u8::from(edge)))); + } + + #[test] + fn bijective_u64() { + let raise = Odds::GRID.map(Edge::Raise); + let edges = [Edge::Draw, Edge::Fold, Edge::Check, Edge::Call, Edge::Shove]; + assert!(edges + .into_iter() + .chain(raise) + .all(|edge| edge == Edge::from(u64::from(edge)))); + } +} + +impl Arbitrary for Edge { + fn random() -> Self { + use rand::Rng; + match rand::thread_rng().gen_range(0..6) { + 0 => Self::Draw, + 1 => Self::Fold, + 2 => Self::Check, + 3 => Self::Call, + 4 => Self::Shove, + 5 => Self::Raise(crate::mccfr::odds::Odds::random()), + _ => unreachable!(), + } + } +} diff --git a/src/mccfr/encoder.rs b/src/mccfr/encoder.rs new file mode 100644 index 00000000..aeaf3437 --- /dev/null +++ b/src/mccfr/encoder.rs @@ -0,0 +1,135 @@ +use super::bucket::Bucket; +use super::data::Data; +use super::node::Node; +use super::recall::Recall; +use super::tree::Branch; +use super::tree::Tree; +use crate::cards::isomorphism::Isomorphism; +use crate::cards::street::Street; +use crate::clustering::abstraction::Abstraction; +use crate::clustering::lookup::Lookup; +use crate::gameplay::game::Game; +use crate::Arbitrary; +use std::collections::BTreeMap; + +#[derive(Default)] +pub struct Encoder(BTreeMap); + +impl Encoder { + /// generate a random root Game and use our learned + /// clustering to lookup the corresponding Abstraction. + /// then embed them together into a Data. note that we don't + /// generate the Bucket yet, that happens in Tree, but maybe + /// we should do it here. + pub fn seed(&self) -> Data { + let game = Game::root(); + let info = self.abstraction(&game); + Data::from((game, info)) + } + + /// - use pseudo-harmonic mapping to convert Vec -> Vec -> Path + /// - use learned encoder lookup to convert Game -> Abstraction. + /// - use variant of Node::continuations() to get Vec -> Path + pub fn bucket(&self, recall: &Recall) -> Bucket { + let game = recall.head(); + let abstraction = self.abstraction(&game); + let bucket = recall.bucket(abstraction); + bucket + } + + /// lookup the Abstraction for a given Game. convert + /// ( Game -> Observation -> Isomorphism ) -> Abstraction + pub fn abstraction(&self, game: &Game) -> Abstraction { + self.0 + .get(&Isomorphism::from(game.sweat())) + .cloned() + .expect(&format!("precomputed abstraction missing {}", game.sweat())) + } + /// unfiltered set of possible children of a Node, + /// conditional on its History (# raises, street granularity). + /// the head Node is attached to the Tree stack-recursively, + /// while the leaf Data is generated here with help from Sampler. + /// Rust's ownership makes this a bit awkward but for very good reason! + /// It has forced me to decouple global (Path) from local (Data) + /// properties of Tree sampling, which makes lots of sense and is stronger model. + /// broadly goes from Edge -> Action -> Game -> Abstraction + pub fn branches(&self, node: &Node) -> Vec { + node.branches() + .into_iter() + .map(|(e, g)| (e, g, self.abstraction(&g))) + .map(|(e, g, x)| (e, Data::from((g, x)))) + .map(|(e, d)| (e, d, node.index())) + .map(|(e, d, n)| Branch(d, e, n)) + .collect() + } + + /// use encoder lookup to convert an unabstracted + /// Recall of a game history into an abstracted Tree. + /// each Game in the sequence converts to a Node, and + /// each Action converts to an Edge. + /// + /// keep in mind that the Recall object is *not* omniscient, + /// so some of the assumptions about the transparent self-play + /// nature of Tree may not hold. + #[allow(unused)] + fn replay(&self, recall: &Recall) -> Tree { + todo!("maybe useful during test-time search?") + } +} + +impl Arbitrary for Encoder { + fn random() -> Self { + const S: usize = 128; + Self( + (0..) + .map(|_| Isomorphism::random()) + .map(|i| (i, Abstraction::random())) + .filter(|(i, a)| i.0.street() == a.street()) + .take(S) + .collect::>() + .into(), + ) + } +} + +#[cfg(feature = "native")] +impl crate::save::upload::Table for Encoder { + fn name() -> String { + Lookup::name() + } + fn columns() -> &'static [tokio_postgres::types::Type] { + Lookup::columns() + } + fn sources() -> Vec { + Lookup::sources() + } + fn creates() -> String { + Lookup::creates() + } + fn indices() -> String { + Lookup::indices() + } + fn copy() -> String { + Lookup::copy() + } + fn load(_: Street) -> Self { + Self( + Street::all() + .iter() + .copied() + .map(Lookup::load) + .map(BTreeMap::from) + .fold(BTreeMap::default(), |mut map, l| { + map.extend(l); + map + }) + .into(), + ) + } + fn save(&self) { + unimplemented!("saving happens at Lookup level. composed of 4 street-level Lookup saves") + } + fn grow(_: Street) -> Self { + unimplemented!("you have no business making an encoding from scratch, learn from kmeans") + } +} diff --git a/src/mccfr/info.rs b/src/mccfr/info.rs new file mode 100644 index 00000000..676f4f0d --- /dev/null +++ b/src/mccfr/info.rs @@ -0,0 +1,46 @@ +use super::data::Data; +use super::edge::Edge; +use super::tree::Tree; +use crate::mccfr::node::Node; +use petgraph::graph::{DiGraph, NodeIndex}; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub struct Info { + roots: Vec, + nodes: Arc, +} + +impl From> for Info { + fn from(nodes: Arc) -> Self { + Self { + roots: vec![], + nodes, + } + } +} + +impl Info { + pub fn add(&mut self, index: NodeIndex) { + self.roots.push(index); + } + pub fn roots(&self) -> Vec { + self.roots + .iter() + .copied() + .map(|i| self.nodes.at(i)) + .collect() + } + pub fn node(&self) -> Node { + self.roots + .iter() + .next() + .copied() + .map(|i| self.nodes.at(i)) + .expect("non-empty infoset") + } + #[allow(dead_code)] + fn graph(&self) -> &DiGraph { + self.nodes.graph() + } +} diff --git a/src/mccfr/memory.rs b/src/mccfr/memory.rs new file mode 100644 index 00000000..7e32dd3b --- /dev/null +++ b/src/mccfr/memory.rs @@ -0,0 +1,49 @@ +use crate::Arbitrary; +use crate::Probability; +use crate::Utility; + +#[derive(Debug, Default, PartialEq, Clone)] +pub struct Memory { + regret: Utility, + policy: Probability, +} + +impl Memory { + pub fn regret(&self) -> Utility { + self.regret + } + pub fn policy(&self) -> Probability { + self.policy + } + pub fn set_regret(&mut self, value: Utility) { + self.regret = value; + } + pub fn set_policy(&mut self, value: Probability) { + self.policy = value; + } + pub fn add_regret(&mut self, discount: f32, value: Utility) { + self.regret *= discount; + self.regret += value; + } + pub fn add_policy(&mut self, discount: f32, value: Probability) { + self.policy *= discount; + self.policy += value; + } +} + +impl From<(f32, f32)> for Memory { + fn from((regret, policy): (f32, f32)) -> Self { + Self { regret, policy } + } +} + +impl Arbitrary for Memory { + fn random() -> Self { + use rand::Rng; + let mut rng = rand::thread_rng(); + Self { + regret: rng.gen(), + policy: rng.gen(), + } + } +} diff --git a/src/mccfr/mod.rs b/src/mccfr/mod.rs new file mode 100644 index 00000000..fe607afb --- /dev/null +++ b/src/mccfr/mod.rs @@ -0,0 +1,21 @@ +pub mod blueprint; +pub mod bucket; +pub mod counterfactual; +pub mod data; +pub mod discount; +pub mod edge; +pub mod encoder; +pub mod info; +pub mod memory; +pub mod node; +pub mod odds; +pub mod partition; +pub mod path; +pub mod phase; +pub mod player; +pub mod policy; +pub mod profile; +pub mod recall; +pub mod regret; +pub mod strategy; +pub mod tree; diff --git a/src/mccfr/node.rs b/src/mccfr/node.rs new file mode 100644 index 00000000..38ec7d1a --- /dev/null +++ b/src/mccfr/node.rs @@ -0,0 +1,199 @@ +use super::bucket::Bucket; +use super::path::Path; +use super::player::Player; +use crate::gameplay::game::Game; +use crate::gameplay::ply::Turn; +use crate::mccfr::data::Data; +use crate::mccfr::edge::Edge; +use crate::Utility; +use petgraph::graph::DiGraph; +use petgraph::graph::NodeIndex; +use petgraph::Direction::Incoming; +use petgraph::Direction::Outgoing; + +/// A Node is a wrapper around a NodeIndex and a &Graph. +/// because they are thin wrappers around an index, they're +/// cheap to Copy. holding reference to Graph is useful +/// for navigational methods. +#[derive(Debug, Clone, Copy)] +pub struct Node<'tree> { + index: NodeIndex, + graph: &'tree DiGraph, +} + +impl<'tree> From<(NodeIndex, &'tree DiGraph)> for Node<'tree> { + fn from((index, graph): (NodeIndex, &'tree DiGraph)) -> Self { + Self { index, graph } + } +} + +impl<'tree> Node<'tree> { + pub fn spawn(&self, index: NodeIndex) -> Node<'tree> { + Self::from((index, self.graph())) + } + pub fn data(&self) -> &Data { + &self + .graph + .node_weight(self.index()) + .expect("valid node index") + } + pub fn bucket(&self) -> &Bucket { + self.data().bucket() + } + pub fn index(&self) -> NodeIndex { + self.index + } + pub fn player(&self) -> Player { + self.data().player() + } + pub fn payoff(&self, player: &Player) -> Utility { + match player { + Player(Turn::Terminal) | Player(Turn::Chance) => unreachable!(), + Player(Turn::Choice(x)) => self + .data() + .game() + .settlements() + .get(*x) + .map(|settlement| settlement.pnl() as f32) + .expect("player index in bounds"), + } + } + + /// navigation methods + + pub fn history(&self) -> Vec<&'tree Edge> { + if let (Some(edge), Some(head)) = (self.incoming(), self.parent()) { + let mut history = head.history(); + history.push(edge); + history + } else { + vec![] + } + } + pub fn outgoing(&self) -> Vec<&'tree Edge> { + self.graph() + .edges_directed(self.index(), Outgoing) + .map(|edge| edge.weight()) + .collect() + } + pub fn incoming(&self) -> Option<&'tree Edge> { + self.graph() + .edges_directed(self.index(), Incoming) + .next() + .map(|edge| edge.weight()) + } + pub fn parent(&self) -> Option> { + self.graph() + .neighbors_directed(self.index(), Incoming) + .next() + .map(|index| self.spawn(index)) + } + pub fn children(&self) -> Vec> { + self.graph() + .neighbors_directed(self.index(), Outgoing) + .map(|index| self.spawn(index)) + .collect() + } + pub fn follow(&self, edge: &Edge) -> Option> { + self.children() + .iter() + .find(|child| edge == child.incoming().unwrap()) + .map(|child| self.spawn(child.index())) + } + pub fn leaves(&self) -> Vec> { + if self.children().is_empty() { + vec![self.clone()] + } else { + self.children() + .iter() + .flat_map(|child| child.leaves()) + .collect() + } + } + pub fn graph(&self) -> &'tree DiGraph { + self.graph + } + + /// this is the bucket that we use to lookup the Policy + /// it is the product of the present abstraction, the + /// history of choices leading up to the node, and the + /// set of available continuations from the node. + pub fn realize(&self) -> Bucket { + let history = Path::from(self.recall()); + let present = self.data().abstraction().clone(); + let choices = self.choices(); + Bucket::from((history, present, choices)) + } + + /// determine the set of branches that could be taken from this node + /// this determines what Bucket we end up in since Tree::attach() + /// uses this to assign Buckets to Data upon insertion + pub fn branches(&self) -> Vec<(Edge, Game)> { + Vec::::from(self.data().bucket().2.clone()) + .into_iter() + .map(|e| (e, self.data().game().actionize(&e))) + .map(|(e, a)| (e.clone(), self.data().game().apply(a))) + .collect() + } + /// returns the set of all possible actions from the current node + /// this is useful for generating a set of children for a given node + /// broadly goes from Node -> Game -> Action -> Edge + fn choices(&self) -> Path { + self.data() + .game() + .choices(self.subgame().iter().filter(|e| e.is_aggro()).count()) + .into() + } + + /// returns the subgame history of the current node + /// within the same Street of action. + /// this should be made lazily in the future + fn subgame(&self) -> Vec { + self.history() + .into_iter() + .take_while(|e| e.is_choice()) + .take(crate::MAX_DEPTH_SUBGAME) + .copied() + .collect() + } + /// we filter out the Draw actions from history + /// and use it to lookup into our big policy + /// lookup table, indexed by Bucket := (Path, Abs, Path) + fn recall(&self) -> Vec { + self.history() + .into_iter() + .take(crate::MAX_DEPTH_SUBGAME) + .copied() + .collect() + } + + /// if we were to play this edge, what would the + /// history: Vec of the resulting Node be? + /// + /// this used to be useful when the approach was + /// to generate a Bucket for a Node>Data at + /// *creation-time*, but now we generate the Bucket at + /// *insertion-time* in Tree::attach()/Tree::insert(). + /// the current contract is that Data will be bucket-less + /// until it gets inserted into a Tree. we could use + /// different types for pre-post insertion, but this works + /// well-enough to have Data own an Option. + #[allow(unused)] + fn extend(&self, edge: &Edge) -> Vec { + self.history() + .into_iter() + .rev() + .chain(std::iter::once(edge)) + .rev() + .take_while(|e| e.is_choice()) + .take(crate::MAX_DEPTH_SUBGAME) + .copied() + .collect() + } +} + +impl std::fmt::Display for Node<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "N{}", self.index().index()) + } +} diff --git a/src/mccfr/odds.rs b/src/mccfr/odds.rs new file mode 100644 index 00000000..fad77b2c --- /dev/null +++ b/src/mccfr/odds.rs @@ -0,0 +1,86 @@ +use crate::Arbitrary; +use crate::Chips; +use crate::Probability; +use crate::Utility; + +/// pot odds for a given raise size, relative to the pot +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, Ord, PartialOrd)] +pub struct Odds(pub Chips, pub Chips); + +impl From for Probability { + fn from(odds: Odds) -> Self { + odds.0 as Probability / odds.1 as Probability + } +} + +impl From<(Chips, Chips)> for Odds { + fn from((a, b): (Chips, Chips)) -> Self { + let (a, b) = Self::gcd(a, b); + Self(a, b) + } +} + +impl Odds { + fn gcd(a: Chips, b: Chips) -> (Chips, Chips) { + let (mut a, mut b) = (a, b); + while b != 0 { + (a, b) = (b, a % b); + } + (a, b) + } + pub fn nearest((a, b): (Chips, Chips)) -> Self { + let odds = a as Utility / b as Utility; + Odds::GRID[Odds::GRID + .map(|o| Probability::from(o)) // pre-sorted + .binary_search_by(|p| p.partial_cmp(&odds).expect("not NaN")) + .unwrap_or_else(|i| i.saturating_sub(1))] + } + pub const GRID: [Self; 10] = Self::PREF_RAISES; + pub const PREF_RAISES: [Self; 10] = [ + Self(1, 4), // 0.25 + Self(1, 3), // 0.33 + Self(1, 2), // 0.50 + Self(2, 3), // 0.66 + Self(3, 4), // 0.75 + Self(1, 1), // 1.00 + Self(3, 2), // 1.50 + Self(2, 1), // 2.00 + Self(3, 1), // 3.00 + Self(4, 1), // 4.00 + ]; + pub const FLOP_RAISES: [Self; 5] = [ + Self(1, 2), // 0.50 + Self(3, 4), // 0.75 + Self(1, 1), // 1.00 + Self(3, 2), // 1.50 + Self(2, 1), // 2.00 + ]; + pub const LATE_RAISES: [Self; 2] = [ + Self(1, 2), // 0.50 + Self(1, 1), // 1.00 + ]; + pub const LAST_RAISES: [Self; 1] = [ + Self(1, 1), // 1.00 + ]; +} + +impl std::fmt::Display for Odds { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let p = Probability::from(*self); + if p > 1.0 { + write!(f, "-{}", (p * 1.0).round() as i32) + } else { + write!(f, "+{}", (1.0 / p).round() as i32) + } + } +} + +impl Arbitrary for Odds { + fn random() -> Self { + use rand::seq::SliceRandom; + Self::GRID + .choose(&mut rand::thread_rng()) + .copied() + .expect("GRID is empty") + } +} diff --git a/src/mccfr/partition.rs b/src/mccfr/partition.rs new file mode 100644 index 00000000..8a933743 --- /dev/null +++ b/src/mccfr/partition.rs @@ -0,0 +1,31 @@ +use super::bucket::Bucket; +use super::tree::Tree; +use crate::mccfr::info::Info; +use std::collections::BTreeMap; +use std::sync::Arc; + +pub struct Partition(BTreeMap); + +impl From for Partition { + fn from(tree: Tree) -> Self { + let mut info = BTreeMap::new(); + let tree = Arc::new(tree); + for node in tree + .all() + .iter() + .filter(|n| n.children().len() > 0) + .filter(|n| n.player() == tree.walker()) + { + info.entry(node.bucket().clone()) + .or_insert_with(|| Info::from(tree.clone())) + .add(node.index()); + } + Self(info) + } +} + +impl From for Vec { + fn from(infosets: Partition) -> Self { + infosets.0.into_values().collect() + } +} diff --git a/src/mccfr/path.rs b/src/mccfr/path.rs new file mode 100644 index 00000000..a6f3a864 --- /dev/null +++ b/src/mccfr/path.rs @@ -0,0 +1,92 @@ +use super::edge::Edge; +use crate::Arbitrary; + +#[derive(Debug, Default, Clone, Copy, Eq, Hash, PartialEq, Ord, PartialOrd)] +pub struct Path(u64); + +impl Arbitrary for Path { + fn random() -> Self { + use rand::Rng; + Self::from(rand::thread_rng().gen::()) + } +} + +/// Vec isomorphism +/// we (un)pack the byte representation of the edges in a Path(u64) sequence +impl From for Vec { + fn from(path: Path) -> Self { + (0..16) + .map(|i| i * 4) + .map(|b| 0xF & (path.0 >> b)) + .map(|bits| bits as u8) + .take_while(|bits| bits != &0) + .map(Edge::from) + .collect() + } +} +impl From> for Path { + fn from(edges: Vec) -> Self { + assert!(edges.len() <= 16); + edges + .into_iter() + .map(u8::from) + .map(|byte| byte as u64) + .enumerate() + .map(|(i, byte)| byte << (i * 4)) + .fold(0u64, |acc, bits| acc | bits) + .into() + } +} + +/// u64 isomorphism +/// trivial unpacking and packing +impl From for Path { + fn from(value: u64) -> Self { + Self(value) + } +} +impl From for u64 { + fn from(path: Path) -> Self { + path.0 + } +} +impl From for i64 { + fn from(path: Path) -> Self { + path.0 as i64 + } +} +impl From for Path { + fn from(value: i64) -> Self { + Self(value as u64) + } +} + +impl std::fmt::Display for Path { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Vec::::from(self.clone()) + .iter() + .try_for_each(|e| write!(f, ".{}", e)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bijective_path_empty() { + let edges = vec![]; + let paths = Vec::::from(Path::from(edges.clone())); + assert_eq!(edges, paths); + } + + #[test] + fn bijective_path_edges() { + let edges = (0..) + .map(|_| Edge::random()) + .take(16) + .collect::>(); + let paths = Vec::::from(Path::from(edges.clone())); + assert_eq!(edges, paths); + } +} diff --git a/src/mccfr/phase.rs b/src/mccfr/phase.rs new file mode 100644 index 00000000..1e8d7278 --- /dev/null +++ b/src/mccfr/phase.rs @@ -0,0 +1,16 @@ +#[derive(PartialEq)] +pub enum Phase { + Discount, + Explore, + Prune, +} + +impl From for Phase { + fn from(epochs: usize) -> Self { + match epochs { + e if e < crate::CFR_DISCOUNT_PHASE => Phase::Discount, + e if e < crate::CFR_PRUNNING_PHASE => Phase::Explore, + _ => Phase::Prune, + } + } +} diff --git a/src/mccfr/player.rs b/src/mccfr/player.rs new file mode 100644 index 00000000..9e398f54 --- /dev/null +++ b/src/mccfr/player.rs @@ -0,0 +1,28 @@ +use crate::gameplay::ply::Turn; +use std::hash::Hash; + +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] +pub struct Player(pub Turn); + +impl Player { + pub const fn chance() -> Self { + Self(Turn::Chance) + } +} + +impl Default for Player { + fn default() -> Self { + Self(Turn::Choice(0)) + } +} + +impl std::fmt::Display for Player { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.0 { + Turn::Chance => write!(f, "??"), + Turn::Choice(0) => write!(f, "P0"), + Turn::Choice(_) => write!(f, "P1"), + Turn::Terminal => write!(f, "END"), + } + } +} diff --git a/src/mccfr/policy.rs b/src/mccfr/policy.rs new file mode 100644 index 00000000..ce860fe0 --- /dev/null +++ b/src/mccfr/policy.rs @@ -0,0 +1,32 @@ +use super::edge::Edge; +use crate::Arbitrary; +use crate::Probability; +use std::collections::BTreeMap; + +/// probability vector over the simplex of edges +pub struct Policy(BTreeMap); + +impl Policy { + pub fn inner(&self) -> &BTreeMap { + &self.0 + } +} + +impl From> for Policy { + fn from(map: BTreeMap) -> Self { + Self(map) + } +} + +impl Arbitrary for Policy { + fn random() -> Self { + use rand::Rng; + let mut rng = rand::thread_rng(); + let n = rng.gen_range(1..=8); + Self::from( + (0..n) + .map(|_| (Edge::random(), rng.gen::())) + .collect::>(), + ) + } +} diff --git a/src/mccfr/profile.rs b/src/mccfr/profile.rs new file mode 100644 index 00000000..db6f0994 --- /dev/null +++ b/src/mccfr/profile.rs @@ -0,0 +1,643 @@ +use super::counterfactual::Counterfactual; +use super::discount::Discount; +use super::memory::Memory; +use super::phase::Phase; +use super::policy::Policy; +use super::regret::Regret; +use super::strategy::Strategy; +use super::tree::Branch; +use crate::cards::street::Street; +use crate::gameplay::ply::Turn; +use crate::mccfr::bucket::Bucket; +use crate::mccfr::edge::Edge; +use crate::mccfr::info::Info; +use crate::mccfr::node::Node; +use crate::mccfr::player::Player; +use crate::Arbitrary; +use crate::Probability; +use crate::Utility; +use rand::rngs::SmallRng; +use rand::Rng; +use rand::SeedableRng; +use std::collections::hash_map::DefaultHasher; +use std::collections::BTreeMap; +use std::hash::Hash; +use std::hash::Hasher; +use std::usize; + +/// this is the meat of our solution. +/// we keep a (Regret, AveragePolicy, CurrentPolicy) +/// for each distinct Bucket(Path, Abstraction) that we visit. +/// we also count how many training epochs we've run so far. +/// i feel like this can be broken up into +/// - Minimizer: handles policy and regret updates by implementing some regret-minimzation subroutine +/// - Profile: stores policy & regret values. used by reference for a lot of calculations, +/// such as Reach, Utility, MinimizerRegretVector, MinimizerPolicyVector, SampleTree, etc. +#[derive(Default)] +pub struct Profile { + iterations: usize, + strategies: BTreeMap, +} + +impl Profile { + /// count of Buckets visited so far + pub fn size(&self) -> usize { + self.strategies.len() + } + /// increment Epoch counter + /// and return current count + pub fn next(&mut self) -> usize { + self.iterations += 1; + self.iterations + } + /// idempotent initialization of Profile + /// at a given Node. + /// + /// if we've already visited this Bucket, + /// then we just want to make sure that + /// the available outgoing Edges are consistent. + /// + /// otherwise, we initialize the strategy + /// at this Node with uniform distribution + /// over its outgoing Edges . + pub fn witness(&mut self, node: &Node, children: &Vec) { + let bucket = node.bucket(); + let n = children.len(); + let uniform = 1. / n as Probability; + // this asssertion needs to relax once i reintroduce pruning\ + // some (incoming, children) branches will be permanently + // pruned, both in the Profile and when sampling children + // in this case we have to reasses "who" is expected to + // have "what" edges on "which when" epochs + assert!( + Vec::::from(bucket.2.clone()) + == children + .iter() + .map(|b| b.edge()) + .copied() + .collect::>() + ); + match self.strategies.get(bucket) { + Some(_) => return, + None => { + for edge in children.iter().map(|b| b.edge()) { + let mut memory = Memory::default(); + memory.set_policy(uniform); + self.strategies + .entry(bucket.clone()) + .or_insert_with(Strategy::default) + .entry(edge.clone()) + .or_insert(memory); + } + } + } + } + /// using our current strategy Profile, + /// compute the regret vector + /// by calculating the marginal Utitlity + /// missed out on for not having followed + /// every walkable Edge at this Infoset/Node/Bucket + pub fn regret_vector(&self, infoset: &Info) -> BTreeMap { + assert!(infoset.node().player() == self.walker()); + log::trace!("regret vector @ {}", infoset.node().bucket()); + infoset + .node() + .outgoing() + .into_iter() + .map(|a| (a.clone(), self.immediate_regret(infoset, a))) + .map(|(a, r)| (a, r.max(crate::REGRET_MIN))) + .map(|(a, r)| (a, r.min(crate::REGRET_MAX))) + .inspect(|(a, r)| log::trace!("{:16} ! {:>10 }", format!("{:?}", a), r)) + .inspect(|(_, r)| assert!(!r.is_nan())) + .inspect(|(_, r)| assert!(!r.is_infinite())) + .collect::>() + } + /// using our current regret Profile, + /// compute a new strategy vector + /// by following a given Edge + /// proportionally to how much regret we felt + /// for not having followed that Edge in the past. + pub fn policy_vector(&self, infoset: &Info) -> BTreeMap { + assert!(infoset.node().player() == self.walker()); + log::trace!("policy vector @ {}", infoset.node().bucket()); + let regrets = infoset + .node() + .outgoing() + .into_iter() + .map(|action| (action.clone(), self.cumulated_regret(infoset, action))) + .map(|(a, r)| (a, r.max(crate::POLICY_MIN))) + .collect::>(); + let sum = regrets.values().sum::(); + let policy = regrets + .into_iter() + .map(|(a, r)| (a, r / sum)) + .inspect(|(a, p)| log::trace!("{:16} ~ {:>5.03}", format!("{:?}", a), p)) + .inspect(|(_, p)| assert!(*p >= 0.)) + .inspect(|(_, p)| assert!(*p <= 1.)) + .collect::>(); + policy + } + + /// update regret vector for a given Bucket + pub fn add_regret(&mut self, bucket: &Bucket, regrets: &Regret) { + log::trace!("update regret @ {}", bucket); + let t = self.epochs(); + let phase = self.phase(); + let discount = Discount::default(); + let strategy = self + .strategies + .get_mut(bucket) + .expect("bucket been witnessed"); + for (action, ®ret) in regrets.inner() { + let decision = strategy.get_mut(action).expect("action been witnessed"); + let discount = match phase { + Phase::Discount => discount.regret(t, regret), + Phase::Explore => 1., + Phase::Prune => 1., + }; + decision.add_regret(discount, regret); + log::trace!("{} : {}", action, decision.regret()); + } + } + /// update policy vector for a given Bucket + pub fn add_policy(&mut self, bucket: &Bucket, policy: &Policy) { + log::trace!("update policy @ {}", bucket); + let t = self.epochs(); + let discount = Discount::default(); + let strategy = self + .strategies + .get_mut(bucket) + .expect("bucket been witnessed"); + for (action, &policy) in policy.inner() { + let discount = discount.policy(t); + let decision = strategy.get_mut(action).expect("action been witnessed"); + decision.add_policy(discount, policy); + log::trace!("{} : {}", action, decision.policy()); + } + } + + /// public metadata + + /// how many Epochs have we traversed the Tree so far? + /// + /// the online nature of the CFR training algorithm + /// makes this value intrinsic to the learned Profile + /// weights, hence the tight coupling. + /// training can be paused, exported, imported, resumed. + /// division by 2 is used to allow each player + /// one iteration to walk the Tree in a single Epoch + pub fn epochs(&self) -> usize { + self.iterations + } + /// derive current phase from Epoch count + pub fn phase(&self) -> Phase { + Phase::from(self.epochs()) + } + /// which player is traversing the Tree on this Epoch? + /// used extensively in assertions and utility calculations + pub fn walker(&self) -> Player { + match self.iterations % 2 { + 0 => Player(Turn::Choice(0)), + _ => Player(Turn::Choice(1)), + } + } + /// full set of available actions and their weights (not Probabilities) + pub fn policy(&self, bucket: &Bucket) -> Policy { + self.strategies + .get(bucket) + .expect("bucket must exist") + .policy() + } + /// absolute Probability. only used for Tree sampling in Monte Carlo Trainer. + pub fn weight(&self, bucket: &Bucket, edge: &Edge) -> Probability { + self.strategies + .get(bucket) + .expect("bucket must exist") + .weight(edge) + } + /// generate seed for PRNG. using hashing yields for deterministic, reproducable sampling + /// for our Monte Carlo sampling. + pub fn rng(&self, node: &Node) -> SmallRng { + let ref mut hasher = DefaultHasher::new(); + self.epochs().hash(hasher); + node.bucket().hash(hasher); + SmallRng::seed_from_u64(hasher.finish()) + } + + /// full exploration of my decision space Edges + pub fn explore_all(&self, choices: Vec, _: &Node) -> Vec { + choices + .into_iter() + .inspect(|Branch(_, edge, _)| assert!(edge.is_choice())) + .collect() + } + /// uniform sampling of chance Edge + pub fn explore_any(&self, choices: Vec, head: &Node) -> Vec { + let n = choices.len(); + let mut choices = choices; + let ref mut rng = self.rng(head); + let choice = rng.gen_range(0..n); + let chosen = choices.remove(choice); + assert!(chosen.1.is_chance()); + vec![chosen] + } + /// Profile-weighted sampling of opponent Edge + pub fn explore_one(&self, mut choices: Vec, head: &Node) -> Vec { + use rand::distributions::WeightedIndex; + use rand::prelude::Distribution; + let ref mut rng = self.rng(head); + let ref bucket = head.bucket(); + let policy = choices + .iter() + .map(|Branch(_, edge, _)| self.weight(bucket, edge)) + .collect::>(); + let choice = WeightedIndex::new(policy) + .expect("at least one policy > 0") + .sample(rng); + let chosen = choices.remove(choice); + assert!(chosen.1.is_choice()); + vec![chosen] + } + + /// counterfactual regret calculations + + /// compute regret and policy vectors for a given infoset + pub fn counterfactual(&self, info: Info) -> Counterfactual { + let regret = Regret::from(self.regret_vector(&info)); + let policy = Policy::from(self.policy_vector(&info)); + Counterfactual::from((info, regret, policy)) + } + + /// historically, + /// upon visiting any Node inthis Infoset, + /// how much cumulative Utility have we missed out on + /// for not having followed this Edge? + fn cumulated_regret(&self, infoset: &Info, edge: &Edge) -> Utility { + assert!(infoset.node().player() == self.walker()); + let node = infoset.node(); + let bucket = node.bucket(); + self.strategies + .get(bucket) + .expect("bucket has been witnessed") + .get(edge) + .expect("action has been witnessed") + .regret() + / self.epochs() as Utility + } + /// conditional on being in this Infoset, + /// distributed across all its head Nodes, + /// with paths weighted according to our Profile: + /// if we follow this Edge 100% of the time, + /// what is the expected marginal increase in Utility? + fn immediate_regret(&self, infoset: &Info, edge: &Edge) -> Utility { + assert!(infoset.node().player() == self.walker()); + infoset + .roots() + .iter() + .map(|head| self.gain(head, edge)) + .sum::() + //? HOIST + // calculate self.profiled_value(head) + // in the outer scop + } + + /// utility calculations + /// utility calculations + /// utility calculations + + /// if at this given head Node, + /// we diverged from our Profile strategy + /// by "playing toward" this Infoset + /// and following this Edge 100% of the time, + /// what is the expected marginal increase in Utility? + fn gain(&self, head: &Node, edge: &Edge) -> Utility { + assert!(head.player() == self.walker()); + let expected = self.expected_value(head); + let cfactual = self.cfactual_value(head, edge); + cfactual - expected + //? HOIST + // could hoist this outside of action/edge loop. + // label each Node with EV + // then use that memoized value for CFV + // memoize via Cell> + } + /// assuming we start at root Node, + /// and that we sample the Tree according to Profile, + /// how much Utility do we expect upon + /// visiting this Node? + fn expected_value(&self, head: &Node) -> Utility { + assert!(head.player() == self.walker()); + self.profiled_reach(head) + * head + .leaves() + .iter() + .map(|leaf| self.terminal_value(head, leaf)) + .sum::() + } + /// if, + /// counterfactually, + /// we had intended to get ourselves in this infoset, + /// then what would be the expected Utility of this leaf? + fn cfactual_value(&self, head: &Node, edge: &Edge) -> Utility { + assert!(head.player() == self.walker()); + self.external_reach(head) + * head + .follow(edge) + .expect("valid edge to follow") + .leaves() + .iter() + .map(|leaf| self.terminal_value(head, leaf)) + .sum::() + } + /// assuming we start at a given head Node, + /// and that we sample the tree according to Profile, + /// how much Utility does + /// this leaf Node backpropagate up to us? + fn terminal_value(&self, head: &Node, leaf: &Node) -> Utility { + assert!(head.player() == self.walker()); + assert!(leaf.children().len() == 0); + let probability = self.relative_reach(head, leaf); + let conditional = self.external_reach(leaf); + let walker = self.walker(); + let reward = leaf.payoff(&walker); + log::trace!("R{:<9} I{:<9} P{:<9}", reward, conditional, probability); + reward * probability / conditional + } + + /// reach calculations + /// reach calculations + /// reach calculations + + /// given a Node on a Tree, + /// what is the Probability + /// that flows forward through this given Edge? + /// note that we assume + /// - Tree is sampled according to external sampling rules + /// - we've visited this Infoset at least once, while sampling the Tree + fn reach(&self, head: &Node, edge: &Edge) -> Probability { + if Player::chance() == head.player() { + 1. + } else { + let ref bucket = head.bucket(); + let policy = self.weight(bucket, edge); + policy + } + } + /// if, + /// counterfactually, + /// we had intended to get ourselves in this infoset, + /// then what would be the Probability of us being + /// in this infoset? that is, assuming our opponents + /// played according to distributions from Profile, + /// but we did not. + /// + /// this function also serves as a form of importance sampling. + /// MCCFR requires we adjust our reach in counterfactual + /// regret calculation to account for the under- and over-sampling + /// of regret across different Infosets. + fn external_reach(&self, node: &Node) -> Probability { + if let (Some(parent), Some(incoming)) = (node.parent(), node.incoming()) { + if parent.player() == self.walker() { + self.external_reach(&parent) + } else { + self.external_reach(&parent) * self.reach(&parent, incoming) + } + } else { + 1. + } + } + /// if we were to play by the Profile, + /// up to this Node in the Tree, + /// then what is the probability of visiting this Node? + fn profiled_reach(&self, node: &Node) -> Probability { + if let (Some(parent), Some(incoming)) = (node.parent(), node.incoming()) { + self.profiled_reach(&parent) * self.reach(&parent, incoming) + } else { + 1. + } + } + /// conditional on being in a given Infoset, + /// what is the Probability of + /// visiting this particular leaf Node, + /// given the distribution offered by Profile? + fn relative_reach(&self, root: &Node, leaf: &Node) -> Probability { + if root.bucket() == leaf.bucket() { + 1. + } else if let (Some(parent), Some(incoming)) = (leaf.parent(), leaf.incoming()) { + self.relative_reach(root, &parent) * self.reach(&parent, incoming) + } else { + unreachable!("tail must have parent") + } + } +} + +impl Arbitrary for Profile { + fn random() -> Self { + Self { + iterations: 0, + strategies: (0..100) + .map(|_| (Bucket::random(), Strategy::random())) + .collect(), + } + } +} + +impl std::fmt::Display for Profile { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + f, + "{}", + self.strategies + .iter() + .map(|(bucket, strategies)| { + format!( + "{}\n{}", + bucket, + strategies + .iter() + .map(|(edge, _)| format!( + " ├─{}: {:.2}", + edge, + self.weight(bucket, edge) + )) + .collect::>() + .join("\n") + ) + }) + .collect::>() + .join("\n") + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cards::street::Street; + use crate::save::upload::Table; + use crate::Arbitrary; + + #[test] + #[ignore] + /// we don't run this test because we don't want to overwrite + /// an existing blueprint profile, and we no longer use any + /// arguments to the save function to write to a temporary name + /// and delete the file + fn persistence() { + let save = Profile::random(); + let load = Profile::load(Street::random()); + assert!(std::iter::empty() + .chain(save.strategies.iter().zip(load.strategies.iter())) + .chain(load.strategies.iter().zip(save.strategies.iter())) + .all(|((s1, l1), (s2, l2))| s1 == s2 && l1 == l2)); + } +} + +#[cfg(feature = "native")] +impl crate::save::upload::Table for Profile { + fn name() -> String { + "blueprint".to_string() + } + fn columns() -> &'static [tokio_postgres::types::Type] { + &[ + tokio_postgres::types::Type::INT8, + tokio_postgres::types::Type::INT8, + tokio_postgres::types::Type::INT8, + tokio_postgres::types::Type::INT8, + tokio_postgres::types::Type::FLOAT4, + tokio_postgres::types::Type::FLOAT4, + ] + } + fn sources() -> Vec { + vec![Self::path(Street::random())] + } + fn path(_: Street) -> String { + format!( + "{}/pgcopy/{}", + std::env::current_dir() + .unwrap_or_default() + .to_string_lossy() + .into_owned(), + Self::name() + ) + } + fn grow(_: Street) -> Self { + unreachable!("must be learned in MCCFR minimization") + } + fn copy() -> String { + "COPY blueprint ( + past, + present, + future, + edge, + policy, + regret + ) + FROM STDIN BINARY + " + .to_string() + } + fn creates() -> String { + " + CREATE TABLE IF NOT EXISTS blueprint ( + edge BIGINT, + past BIGINT, + present BIGINT, + future BIGINT, + policy REAL, + regret REAL + ); + " + .to_string() + } + fn indices() -> String { + " + CREATE INDEX IF NOT EXISTS idx_blueprint_bucket ON blueprint (present, past, future); + CREATE INDEX IF NOT EXISTS idx_blueprint_future ON blueprint (future); + CREATE INDEX IF NOT EXISTS idx_blueprint_present ON blueprint (present); + CREATE INDEX IF NOT EXISTS idx_blueprint_edge ON blueprint (edge); + CREATE INDEX IF NOT EXISTS idx_blueprint_past ON blueprint (past); + " + .to_string() + } + fn load(_: Street) -> Self { + let ref path = Self::path(Street::random()); + log::info!("{:<32}{:<32}", "loading blueprint", path); + use crate::clustering::abstraction::Abstraction; + use crate::mccfr::path::Path; + use byteorder::ReadBytesExt; + use byteorder::BE; + use std::fs::File; + use std::io::BufReader; + use std::io::Read; + use std::io::Seek; + use std::io::SeekFrom; + let file = File::open(path).expect("open file"); + let mut strategies = BTreeMap::new(); + let mut reader = BufReader::new(file); + let ref mut buffer = [0u8; 2]; + reader.seek(SeekFrom::Start(19)).expect("seek past header"); + while reader.read_exact(buffer).is_ok() { + match u16::from_be_bytes(buffer.clone()) { + 6 => { + reader.read_u32::().expect("past path length"); + let history = Path::from(reader.read_u64::().expect("history")); + reader.read_u32::().expect("abstraction length"); + let present = Abstraction::from(reader.read_u64::().expect("abstraction")); + reader.read_u32::().expect("future path length"); + let choices = Path::from(reader.read_u64::().expect("choices")); + reader.read_u32::().expect("edge length"); + let edge = Edge::from(reader.read_u64::().expect("read edge")); + reader.read_u32::().expect("regret length"); + let regret = reader.read_f32::().expect("read regret"); + reader.read_u32::().expect("policy length"); + let policy = reader.read_f32::().expect("read policy"); + let bucket = Bucket::from((history, present, choices)); + let memory = strategies + .entry(bucket) + .or_insert_with(Strategy::default) + .entry(edge) + .or_insert_with(Memory::default); + memory.set_regret(regret); + memory.set_policy(policy); + continue; + } + 0xFFFF => break, + n => panic!("unexpected number of fields: {}", n), + } + } + Self { + strategies, + iterations: 0, + } + } + fn save(&self) { + const N_FIELDS: u16 = 6; + let ref path = Self::path(Street::random()); + let ref mut file = File::create(path).expect(&format!("touch {}", path)); + use byteorder::WriteBytesExt; + use byteorder::BE; + use std::fs::File; + use std::io::Write; + log::info!("{:<32}{:<32}", "saving blueprint", path); + file.write_all(Self::header()).expect("header"); + for (bucket, strategy) in self.strategies.iter() { + for (edge, memory) in strategy.iter() { + file.write_u16::(N_FIELDS).unwrap(); + file.write_u32::(size_of::() as u32).unwrap(); + file.write_u64::(u64::from(bucket.0)).unwrap(); + file.write_u32::(size_of::() as u32).unwrap(); + file.write_u64::(u64::from(bucket.1)).unwrap(); + file.write_u32::(size_of::() as u32).unwrap(); + file.write_u64::(u64::from(bucket.2)).unwrap(); + file.write_u32::(size_of::() as u32).unwrap(); + file.write_u64::(u64::from(edge.clone())).unwrap(); + file.write_u32::(size_of::() as u32).unwrap(); + file.write_f32::(memory.regret()).unwrap(); + file.write_u32::(size_of::() as u32).unwrap(); + file.write_f32::(memory.policy()).unwrap(); + } + } + file.write_u16::(Self::footer()).expect("trailer"); + } +} diff --git a/src/mccfr/recall.rs b/src/mccfr/recall.rs new file mode 100644 index 00000000..dd36b95a --- /dev/null +++ b/src/mccfr/recall.rs @@ -0,0 +1,127 @@ +use crate::cards::card::Card; +use crate::cards::hole::Hole; +use crate::cards::observation::Observation; +use crate::gameplay::action::Action; +use crate::gameplay::game::Game; +use crate::gameplay::ply::Turn; + +/// a complete representation of perfect recall game history +/// from the perspective of the hero. intended use is for +/// the path to be populated only with choice actions, +/// since we internally keep track of chance actions +/// by conditioning on the observed cards. +/// +/// note that this struct implicitly assumes: +/// - default stacks +/// - default blinds +/// - default dealer position +/// - unordered private and community cards +#[derive(Debug, Clone)] +pub struct Recall { + hero: Turn, + seen: Observation, // could be replaced by Hole + Board + BetHistory(Vec) + path: Vec, +} + +impl From<(Turn, Observation, Vec)> for Recall { + fn from((hero, seen, path): (Turn, Observation, Vec)) -> Self { + Self { hero, seen, path } + } +} + +impl Recall { + pub fn new(seen: Observation, hero: Turn) -> Self { + Self { + seen, + hero, + path: Game::blinds(), + } + } + + pub fn root(&self) -> Game { + Game::root().wipe(Hole::from(self.seen)) + } + + pub fn head(&self) -> Game { + self.path + .iter() + .cloned() + .fold(self.root(), |game, action| game.apply(action)) + } + + pub fn undo(&mut self) { + if self.can_rewind() { + self.path.pop(); + } + while self.can_revoke() { + self.path.pop(); + } + } + pub fn push(&mut self, action: Action) { + if self.can_extend(&action) { + self.path.push(action); + } + while self.can_reveal() { + let street = self.head().street(); + let reveal = self + .seen + .public() + .clone() + .skip(street.n_observed()) + .take(street.n_revealed()) + .collect::>() + .into(); + self.path.push(Action::Draw(reveal)); + } + } + + pub fn can_extend(&self, action: &Action) -> bool { + self.head().is_allowed(action) + } + pub fn can_rewind(&self) -> bool { + self.path.iter().any(|a| !a.is_blind()) + } + pub fn can_lookup(&self) -> bool { + true + && self.head().turn() == self.hero // is it our turn right now? + && self.head().street() == self.seen.street() // have we exhausted info from Obs? + } + pub fn can_reveal(&self) -> bool { + true + && self.head().turn() == Turn::Chance // is it time to reveal the next card? + && self.head().street() < self.seen.street() // would revealing double-deal? + } + pub fn can_revoke(&self) -> bool { + matches!(self.path.last().expect("empty path"), Action::Draw(_)) + } + + /// lossy conversion from granular Action to coarse Edge. + /// we depend on the pot size as of the Game state where + /// the Action is applied, and always compare the size of the + /// Action::Raise(_) to the pot to yield an [Odds] value. + fn pseudoharmonics(&self) -> Path { + todo!( + "use pseudo-harmonic +mapping to + convert history: + Recall -> Vec<(Game, Action)> -> Vec -> Path" + ) + } + + fn choices(&self) -> Path { + Path::from( + self.head() + .choices(self.path.iter().filter(|a| a.is_aggro()).count()), + ) + } + pub fn bucket(&self, abstraction: Abstraction) -> Bucket { + let present = abstraction; + let history = Path::from(self.pseudoharmonics()); + let choices = Path::from(self.choices()); + Bucket::from((history, present, choices)) + } +} + +use crate::clustering::abstraction::Abstraction; +use crate::mccfr::bucket::Bucket; +use crate::mccfr::path::Path; diff --git a/src/mccfr/regret.rs b/src/mccfr/regret.rs new file mode 100644 index 00000000..68676552 --- /dev/null +++ b/src/mccfr/regret.rs @@ -0,0 +1,17 @@ +use super::edge::Edge; +use crate::Utility; +use std::collections::BTreeMap; + +pub struct Regret(BTreeMap); + +impl Regret { + pub fn inner(&self) -> &BTreeMap { + &self.0 + } +} + +impl From> for Regret { + fn from(map: BTreeMap) -> Self { + Self(map) + } +} diff --git a/src/mccfr/strategy.rs b/src/mccfr/strategy.rs new file mode 100644 index 00000000..9e6f7e2f --- /dev/null +++ b/src/mccfr/strategy.rs @@ -0,0 +1,53 @@ +use super::edge::Edge; +use super::memory::Memory; +use super::policy::Policy; +use crate::Arbitrary; +use crate::Probability; +use std::collections::BTreeMap; + +#[derive(Debug, Default, Clone, PartialEq)] +pub struct Strategy(BTreeMap); + +impl Strategy { + pub fn policy(&self) -> Policy { + Policy::from( + self.0 + .iter() + .map(|(edge, memory)| (edge.clone(), memory.policy())) + .collect::>(), + ) + } + pub fn weight(&self, edge: &Edge) -> Probability { + let denom = self.0.values().map(|s| s.policy()).sum::(); + let numer = self.0.get(edge).expect("edge in infoset").policy(); + numer / denom + } + pub fn get(&self, edge: &Edge) -> Option<&Memory> { + self.0.get(edge) + } + pub fn get_mut(&mut self, edge: &Edge) -> Option<&mut Memory> { + self.0.get_mut(edge) + } + + pub fn keys(&self) -> std::collections::btree_map::Keys { + self.0.keys() + } + pub fn entry(&mut self, edge: Edge) -> std::collections::btree_map::Entry { + self.0.entry(edge) + } + pub fn values(&self) -> std::collections::btree_map::Values { + self.0.values() + } + pub fn iter(&self) -> std::collections::btree_map::Iter { + self.0.iter() + } +} + +impl Arbitrary for Strategy { + fn random() -> Self { + use rand::Rng; + let mut rng = rand::thread_rng(); + let n = rng.gen_range(1..=8); + Self((0..n).map(|_| (Edge::random(), Memory::random())).collect()) + } +} diff --git a/src/mccfr/tree.rs b/src/mccfr/tree.rs new file mode 100644 index 00000000..b3c74f37 --- /dev/null +++ b/src/mccfr/tree.rs @@ -0,0 +1,105 @@ +use super::data::Data; +use super::player::Player; +use crate::mccfr::edge::Edge; +use crate::mccfr::node::Node; +use petgraph::graph::DiGraph; +use petgraph::graph::NodeIndex; +use std::fmt::Formatter; +use std::fmt::Result; + +pub struct Branch(pub Data, pub Edge, pub NodeIndex); +impl Branch { + pub fn edge(&self) -> &Edge { + &self.1 + } +} + +/// Represents the game tree structure used in Monte Carlo Counterfactual Regret Minimization (MCCFR). +/// +/// The `Tree` struct contains two main components: +/// 1. A directed graph (`DiGraph`) representing the game tree, where nodes are game states and edges are actions. +/// 2. A mapping from `Bucket`s to `Info`sets, which groups similar game states together. +#[derive(Debug, Default)] +pub struct Tree(DiGraph, Player); + +impl Tree { + pub fn all(&self) -> Vec { + self.0.node_indices().map(|n| self.at(n)).collect() + } + pub fn at(&self, index: NodeIndex) -> Node { + Node::from((index, &self.0)) + } + pub fn empty(player: Player) -> Self { + Self(DiGraph::with_capacity(0, 0), player) + } + pub fn walker(&self) -> Player { + self.1 + } + pub fn graph(&self) -> &DiGraph { + &self.0 + } + + /// special insertion logic for the root node + /// which, without a parent Branch, has slightly different + /// bucket calculation logic. + pub fn plant(&mut self, seed: Data) -> Node { + let i = self.0.add_node(seed); + let bucket = self.at(i).realize(); + self.0 + .node_weight_mut(i) + .map(|data| data.assign(bucket)) + .inspect(|_| log::trace!("SEED {}", bucket)) + .expect("root index in tree"); + self.at(i) + } + + /// attach a Branch to the Tree + /// assuming that the Node has already been ::inserted() + pub fn fork(&mut self, branch: Branch) -> Node { + let leaf = self.0.add_node(branch.0); + let edge = branch.1; + let root = branch.2; + self.0.add_edge(root, leaf, edge); + let bucket = self.at(leaf).realize(); + self.0 + .node_weight_mut(leaf) + .map(|data| data.assign(bucket)) + .inspect(|_| log::trace!("{}", bucket)) + .expect("node index in tree"); + self.at(leaf) + } + + /// display the Tree in a human-readable format + /// be careful because it's really big and recursive + fn display(&self, f: &mut Formatter, index: NodeIndex, prefix: &str) -> Result { + if index == NodeIndex::new(0) { + writeln!(f, "\nROOT {}", self.at(index).bucket())?; + } + let mut children = self + .0 + .neighbors_directed(index, petgraph::Outgoing) + .collect::>(); + let n = children.len(); + children.sort(); + for (i, child) in children.into_iter().enumerate() { + let last = i == n - 1; + let stem = if last { "└" } else { "├" }; + let gaps = if last { " " } else { "│ " }; + let node = self.at(child); + let head = node.bucket(); + let edge = self + .0 + .edge_weight(self.0.find_edge(index, child).unwrap()) + .unwrap(); + writeln!(f, "{}{}──{} → {}", prefix, stem, edge, head)?; + self.display(f, child, &format!("{}{}", prefix, gaps))?; + } + Ok(()) + } +} + +impl std::fmt::Display for Tree { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.display(f, NodeIndex::new(0), "") + } +} diff --git a/src/players/human.rs b/src/players/human.rs index ef08c1e9..6cfc8bf2 100644 --- a/src/players/human.rs +++ b/src/players/human.rs @@ -1,18 +1,42 @@ +#![allow(dead_code)] +use crate::gameplay::action::Action; +use crate::gameplay::game::Game; +use crate::Chips; +use dialoguer::Input; +use dialoguer::Select; + +#[derive(Debug)] pub struct Human; impl Human { - fn raise(&self, seat: &Seat, hand: &Hand) -> u32 { + pub fn decide(game: &Game) -> Action { + Self::random(game) + // let ref choices = Self::available(game); + // let choice = Self::selection(choices, game); + // Self::choose(choices, choice, game) + } + + fn random(game: &Game) -> Action { + use rand::seq::SliceRandom; + let ref mut rng = rand::thread_rng(); + game.legal() + .choose(rng) + .copied() + .expect("decision node has options") + } + + fn raise(game: &Game) -> Chips { Input::new() - .with_prompt(self.infoset(seat, hand)) + .with_prompt(Self::infoset(game)) .validate_with(|i: &String| -> Result<(), &str> { - let input = match i.parse::() { + let input = match i.parse::() { Ok(value) => value, Err(_) => return Err("Enter a positive integer"), }; - if input < seat.min_raise(hand) { + if input < game.to_raise() { return Err("Raise too small"); } - if input > seat.max_raise(hand) { + if input > game.to_shove() { return Err("Raise too large"); } Ok(()) @@ -20,69 +44,62 @@ impl Human { .report(false) .interact() .unwrap() - .parse::() + .parse::() .unwrap() } - fn infoset(&self, seat: &Seat, hand: &Hand) -> String { + fn infoset(game: &Game) -> String { format!( "\nBOARD {}\nCARDS {}\nPOT {}\nSTACK {}\nTO CALL {}\nMIN RAISE {}\n\nAction", - hand.head.board, - seat.hole, - hand.head.pot, - seat.stack, - seat.to_call(hand), - seat.min_raise(hand), + game.board(), + game.actor().cards(), + game.pot(), + game.actor().stack(), + game.to_call(), + game.to_raise(), ) } -} -impl Player for Human { - fn act(&self, seat: &Seat, hand: &Hand) -> Action { - // get valid actions - let choices = seat - .valid_actions(hand) + fn available(game: &Game) -> Vec<&str> { + game.legal() .iter() .map(|a| match a { - Action::Fold(_) => "Fold", - Action::Check(_) => "Check", - Action::Call(_, _) => "Call", - Action::Raise(_, _) => "Raise", - Action::Shove(_, _) => "Shove", + Action::Fold => "Fold", + Action::Check => "Check", + Action::Call(_) => "Call", + Action::Raise(_) => "Raise", + Action::Shove(_) => "Shove", _ => unreachable!(), }) - .collect::>(); - let selection = Select::new() - .with_prompt(self.infoset(seat, hand)) + .collect::>() + } + + fn selection(choices: &[&str], game: &Game) -> usize { + Select::new() + .with_prompt(Self::infoset(game)) .report(false) - .items(choices.as_slice()) + .items(choices) .default(0) .interact() - .unwrap(); + .unwrap() + } + + fn choose(choices: &[&str], selection: usize, game: &Game) -> Action { match choices[selection] { - "Fold" => Action::Fold(seat.position), - "Check" => Action::Check(seat.position), - "Call" => Action::Call(seat.position, seat.to_call(hand)), - "Shove" => Action::Shove(seat.position, seat.to_shove(hand)), + "Fold" => Action::Fold, + "Check" => Action::Check, + "Call" => Action::Call(game.to_call()), + "Shove" => Action::Shove(game.to_shove()), "Raise" => { - let raise = self.raise(seat, hand); - let shove = seat.to_shove(hand); - match raise == shove { - true => Action::Shove(seat.position, shove), - false => Action::Raise(seat.position, raise), + let raise = Self::raise(game); + let shove = game.to_shove(); + if raise == shove { + Action::Shove(shove) + } else { + Action::Raise(raise) } } _ => unreachable!(), } } } -impl Debug for Human { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "Human") - } -} - -use crate::gameplay::{action::Action, hand::Hand, player::Player, seat::Seat}; -use dialoguer::{Input, Select}; -use std::fmt::{Debug, Formatter}; -use std::result::Result; diff --git a/src/players/mod.rs b/src/players/mod.rs index be5dbf2c..11c31fd0 100644 --- a/src/players/mod.rs +++ b/src/players/mod.rs @@ -1,2 +1 @@ pub mod human; -pub mod robot; diff --git a/src/players/robot.rs b/src/players/robot.rs deleted file mode 100644 index 12fc49e7..00000000 --- a/src/players/robot.rs +++ /dev/null @@ -1,44 +0,0 @@ -pub struct Robot; - -impl Player for Robot { - fn act(&self, seat: &Seat, hand: &Hand) -> Action { - self.policy(seat, hand).choose() - } -} - -impl Robot { - fn weight(&self, action: Action) -> u32 { - match action { - Action::Fold(_) => 1500, - Action::Check(_) => 1000, - Action::Call(..) => 4000, - Action::Raise(..) => 500, - Action::Shove(..) => 1, - _ => 0, - } - } - - fn policy(&self, seat: &Seat, hand: &Hand) -> Policy { - Policy { - choices: seat - .valid_actions(hand) - .iter() - .map(|a| Choice { - action: *a, - weight: self.weight(*a), - }) - .collect(), - } - } -} - -impl Debug for Robot { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Player") - } -} - -use crate::gameplay::player::Player; -use crate::gameplay::{action::Action, hand::Hand, seat::Seat}; -use crate::strategy::policy::{Choice, Policy}; -use std::fmt::Debug; diff --git a/src/save/derive.rs b/src/save/derive.rs new file mode 100644 index 00000000..d2963c28 --- /dev/null +++ b/src/save/derive.rs @@ -0,0 +1,138 @@ +use crate::cards::street::Street; +use crate::clustering::abstraction::Abstraction; + +/// trait for deriving database tables from other tables +pub trait Derive: Sized { + fn name() -> String; + fn exhaust() -> Vec; + fn creates() -> String; + fn indexes() -> String; + fn inserts(&self) -> String; + fn derives() -> String { + Self::exhaust() + .into_iter() + .map(|r| r.inserts()) + .collect::>() + .join("\n;") + } +} + +impl Derive for Abstraction { + fn exhaust() -> Vec { + Street::exhaust() + .into_iter() + .map(|s| Self::all(s).into_iter()) + .flatten() + .collect() + } + + fn name() -> String { + "abstraction".to_string() + } + + fn creates() -> String { + "CREATE TABLE IF NOT EXISTS abstraction ( + abs BIGINT, + street SMALLINT, + population INTEGER, + equity REAL + ); + TRUNCATE TABLE abstraction; + ALTER TABLE abstraction SET UNLOGGED; + + CREATE OR REPLACE FUNCTION get_population(xxx BIGINT) RETURNS INTEGER AS + $$ BEGIN RETURN (SELECT COUNT(*) FROM isomorphism e WHERE e.abs = xxx); END; $$ + LANGUAGE plpgsql; + + CREATE OR REPLACE FUNCTION get_street_abs(abs BIGINT) RETURNS SMALLINT AS + $$ BEGIN RETURN (abs >> 56)::SMALLINT; END; $$ + LANGUAGE plpgsql; + + CREATE OR REPLACE FUNCTION get_equity(parent BIGINT) RETURNS REAL AS + $$ BEGIN RETURN CASE WHEN get_street_abs(parent) = 3 + THEN (parent & 255)::REAL / 100 + ELSE ( + SELECT COALESCE(SUM(t.dx * r.equity) / NULLIF(SUM(t.dx), 0), 0) + FROM transitions t + JOIN abstraction r ON t.next = r.abs + WHERE t.prev = parent) END; END; $$ + LANGUAGE plpgsql; + " + .into() + } + + fn indexes() -> String { + " + CREATE INDEX IF NOT EXISTS idx_abstraction_abs ON abstraction (abs); + CREATE INDEX IF NOT EXISTS idx_abstraction_st ON abstraction (street); + CREATE INDEX IF NOT EXISTS idx_abstraction_eq ON abstraction (equity); + CREATE INDEX IF NOT EXISTS idx_abstraction_pop ON abstraction (population); + " + .into() + } + + fn inserts(&self) -> String { + let abs = i64::from(self.clone()); + format!( + "INSERT INTO abstraction ( + abs, + street, + equity, + population + ) VALUES ( ({}), + get_street_abs ({}), + get_equity ({}), + get_population ({}));", + abs, abs, abs, abs, + ) + } +} + +impl Derive for Street { + fn exhaust() -> Vec { + Self::all().iter().rev().copied().collect() + } + + fn name() -> String { + "street".to_string() + } + + fn creates() -> String { + "CREATE TABLE IF NOT EXISTS street ( + street SMALLINT, + nobs INTEGER, + nabs INTEGER + ); + + TRUNCATE TABLE street; + ALTER TABLE street SET UNLOGGED; + + CREATE OR REPLACE FUNCTION get_niso(s SMALLINT) RETURNS INTEGER AS + $$ BEGIN RETURN (SELECT COUNT(*) FROM isomorphism e WHERE e.street = s); END; $$ + LANGUAGE plpgsql; + + CREATE OR REPLACE FUNCTION get_nabs(s SMALLINT) RETURNS INTEGER AS + $$ BEGIN RETURN (SELECT COUNT(*) FROM abstraction a WHERE a.street = s); END; $$ + LANGUAGE plpgsql;" + .into() + } + + fn indexes() -> String { + "CREATE INDEX IF NOT EXISTS idx_street_st ON street (street);".into() + } + + fn inserts(&self) -> String { + let street = self.clone() as i16; + format!( + "INSERT INTO street ( + street, + nobs, + nabs + ) VALUES ( ({}), + get_niso({}), + get_nabs({}) + );", + street, street, street + ) + } +} diff --git a/src/save/mod.rs b/src/save/mod.rs new file mode 100644 index 00000000..2a0ec724 --- /dev/null +++ b/src/save/mod.rs @@ -0,0 +1,3 @@ +pub mod derive; +pub mod upload; +pub mod writer; diff --git a/src/save/upload.rs b/src/save/upload.rs new file mode 100644 index 00000000..6b2e7cbc --- /dev/null +++ b/src/save/upload.rs @@ -0,0 +1,67 @@ +use crate::cards::street::Street; +use tokio_postgres::types::Type; + +// blueprint ~ 154M, (grows with number of CFR iterations) +// isomorphism ~ 139M, +// metric ~ 40K, +// transition ~ 29K, +// abstraction ~ 500, +// street ~ 4 + +/// things that can be written to and read from disk, and uploaded into Postgres. +/// may or may not be dependent on other entities being written/in memory. +/// dependencies for methods returning Self are up to the implementor. +pub trait Table { + /// Returns the name of the table in the database + fn name() -> String; + /// Returns the COPY command used to load data into the database + fn copy() -> String; + /// Returns the SQL to prepare the table schema + fn creates() -> String; + /// Returns the SQL to create indices on the table + fn indices() -> String; + /// Returns the column types for the table + fn columns() -> &'static [Type]; + /// Returns the source file paths to load data from + fn sources() -> Vec; + /// build from scratch + fn grow(street: Street) -> Self; + /// read from disk + fn load(street: Street) -> Self; + /// write to disk + fn save(&self); + + /// query to nuke table in Postgres + fn truncates() -> String { + format!( + "TRUNCATE TABLE {}; ALTER TABLE {} SET UNLOGGED;", + Self::name(), + Self::name() + ) + } + /// path to file on disk + fn path(street: Street) -> String { + format!( + "{}/pgcopy/{}.{}", + std::env::current_dir() + .unwrap_or_default() + .to_string_lossy() + .into_owned(), + Self::name(), + street + ) + } + /// check if file exists on disk + fn done(street: Street) -> bool { + std::fs::metadata(Self::path(street)).is_ok() + } + /// Postgres signature header + 8 null bytes for flags and extension + /// header for binary copy: https://www.postgresql.org/docs/current/static/sql-copy.html + fn header() -> &'static [u8] { + b"PGCOPY\n\xFF\r\n\0\0\0\0\0\0\0\0\0" + } + /// Postgres signature footer to signal end of binary file + fn footer() -> u16 { + 0xFFFF + } +} diff --git a/src/save/writer.rs b/src/save/writer.rs new file mode 100644 index 00000000..29c0fc18 --- /dev/null +++ b/src/save/writer.rs @@ -0,0 +1,183 @@ +use super::derive::Derive; +use super::upload::Table; +use crate::cards::street::Street; +use crate::clustering::abstraction::Abstraction; +use crate::clustering::metric::Metric; +use crate::clustering::transitions::Decomp; +use crate::mccfr::encoder::Encoder; +use crate::mccfr::profile::Profile; +use byteorder::ReadBytesExt; +use byteorder::BE; +use std::fs::File; +use std::io::BufReader; +use std::io::Read; +use std::io::Seek; +use std::sync::Arc; +use tokio_postgres::binary_copy::BinaryCopyInWriter; +use tokio_postgres::types::ToSql; +use tokio_postgres::Client; +use tokio_postgres::Error as E; + +pub struct Writer(Arc); + +impl From> for Writer { + fn from(client: Arc) -> Self { + Self(client) + } +} + +impl Writer { + pub async fn save() -> Result<(), E> { + let postgres = Self(crate::db().await); + postgres.upload::().await?; + postgres.upload::().await?; + postgres.upload::().await?; + postgres.upload::().await?; + postgres.derive::().await?; + postgres.derive::().await?; + postgres.0.batch_execute("VACUUM ANALYZE;").await?; + Ok(()) + } + + async fn upload(&self) -> Result<(), E> + where + T: Table, + { + let ref name = T::name(); + if self.absent(name).await? { + log::info!("creating table ({})", name); + self.0.batch_execute(&T::creates()).await?; + self.0.batch_execute(&T::truncates()).await?; + } + if self.vacant(name).await? { + log::info!("copying {}", name); + self.stream::().await?; + self.0.batch_execute(&T::indices()).await?; + Ok(()) + } else { + log::info!("table data already uploaded ({})", name); + Ok(()) + } + } + + async fn derive(&self) -> Result<(), E> + where + D: Derive, + { + let ref name = D::name(); + if self.absent(name).await? { + log::info!("creating table ({})", name); + self.0.batch_execute(&D::creates()).await?; + } + if self.vacant(name).await? { + log::info!("deriving {}", name); + self.0.batch_execute(&D::indexes()).await?; + self.0.batch_execute(&D::derives()).await?; + Ok(()) + } else { + log::info!("table data already derived ({})", name); + Ok(()) + } + } + + async fn stream(&self) -> Result<(), E> + where + T: Table, + { + let sink = self.0.copy_in(&T::copy()).await?; + let writer = BinaryCopyInWriter::new(sink, T::columns()); + futures::pin_mut!(writer); + let ref mut fields = [0u8; 2]; + for ref mut reader in T::sources() + .iter() + .map(|s| File::open(s).expect("file not found")) + .map(|f| BufReader::new(f)) + { + reader.seek(std::io::SeekFrom::Start(19)).unwrap(); + while let Ok(()) = reader.read_exact(fields) { + match u16::from_be_bytes(*fields) { + 0xFFFF => break, + length => { + assert!(T::columns().len() == length as usize); + let row = (0..length).map(|_| { + match reader.read_u32::().expect("field size (bytes)") { + 4 => Field::F32(reader.read_f32::().unwrap()), + 8 => Field::I64(reader.read_i64::().unwrap()), + x => panic!("unsupported type: {}", x), + } + }); + writer.as_mut().write_raw(row).await?; + } + } + } + } + writer.finish().await?; + Ok(()) + } + + async fn vacant(&self, table: &str) -> Result { + let ref sql = format!( + " + SELECT 1 + FROM {} + LIMIT 1; + ", + table + ); + Ok(self.0.query(sql, &[]).await?.is_empty()) + } + + async fn absent(&self, table: &str) -> Result { + let ref sql = format!( + " + SELECT 1 + FROM information_schema.tables + WHERE table_name = '{}'; + ", + table + ); + Ok(self.0.query(sql, &[]).await?.is_empty()) + } +} + +/// doing this for zero copy reasons +/// it was impossible to achieve polymorphism between column types +/// without allocating a ton since writer.as_mut().write() +/// required &[&dyn ToSql] +/// which would have required collection into a Vec<&dyn ToSql> +/// because of lifetime reasons. now, we only need an Iterator +/// which is much more flexible, so we can map T::columns() to dynamically +/// iterate over table columns. +#[derive(Debug)] +enum Field { + F32(f32), + I64(i64), +} + +impl tokio_postgres::types::ToSql for Field { + fn to_sql( + &self, + ty: &tokio_postgres::types::Type, + out: &mut bytes::BytesMut, + ) -> Result> { + match self { + Field::F32(val) => val.to_sql(ty, out), + Field::I64(val) => val.to_sql(ty, out), + } + } + + fn accepts(ty: &tokio_postgres::types::Type) -> bool { + ::accepts(ty) || ::accepts(ty) + } + + fn to_sql_checked( + &self, + ty: &tokio_postgres::types::Type, + out: &mut bytes::BytesMut, + ) -> Result> { + match self { + Field::F32(val) => val.to_sql_checked(ty, out), + Field::I64(val) => val.to_sql_checked(ty, out), + } + } +} diff --git a/src/search/mod.rs b/src/search/mod.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/search/mod.rs @@ -0,0 +1 @@ + diff --git a/src/strategy/infoset.rs b/src/strategy/infoset.rs deleted file mode 100644 index 227eb74f..00000000 --- a/src/strategy/infoset.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub struct InfoSet<'a> { - pub hand: &'a Hand, - pub hole: &'a Hole, -} - -use crate::{cards::hole::Hole, gameplay::hand::Hand}; diff --git a/src/strategy/mod.rs b/src/strategy/mod.rs deleted file mode 100644 index 22a7c046..00000000 --- a/src/strategy/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod infoset; -pub mod policy; -pub mod range; diff --git a/src/strategy/policy.rs b/src/strategy/policy.rs deleted file mode 100644 index 96ff58c3..00000000 --- a/src/strategy/policy.rs +++ /dev/null @@ -1,28 +0,0 @@ -#[derive(Debug, Clone, Copy)] -pub struct Choice { - pub action: Action, - pub weight: u32, -} - -#[derive(Debug, Clone)] -pub struct Policy { - pub choices: Vec, -} - -impl Policy { - pub fn choose(&self) -> Action { - let mut sum = 0; - let cum = self.choices.iter().map(|p| p.weight).sum(); - let roll = thread_rng().gen_range(0..cum); - for policy in self.choices.iter() { - sum += policy.weight; - if roll < sum { - return policy.action; - } - } - unreachable!() - } -} - -use crate::gameplay::action::Action; -use rand::{thread_rng, Rng}; diff --git a/src/strategy/range.rs b/src/strategy/range.rs deleted file mode 100644 index c9f58d85..00000000 --- a/src/strategy/range.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub struct Range { - pub values: [T; 52 * 51 / 2], -} diff --git a/src/translate_action/mod.rs b/src/translate_action/mod.rs new file mode 100644 index 00000000..8379b132 --- /dev/null +++ b/src/translate_action/mod.rs @@ -0,0 +1 @@ +pub mod translate_action; diff --git a/src/translate_action/translate_action.rs b/src/translate_action/translate_action.rs new file mode 100644 index 00000000..df6a4463 --- /dev/null +++ b/src/translate_action/translate_action.rs @@ -0,0 +1,271 @@ +use crate::Chips; + +/// Provides the return type for translate_action() - see below for more details. +#[allow(dead_code)] +#[derive(Debug)] +pub struct TranslatedAction { + smaller_size: Chips, + larger_size: Chips, + smaller_percentage: f64, + larger_percentage: f64, +} + +/// Translates an incoming bet size to a randomized choice of a smaller and larger bet size. +/// +/// # Arguments +/// * `pot_size` - The current size of the pot +/// * `opponent_bet` - The actual bet made by the opponent +/// * `action_abstraction` - The list of allowed sizes to translate to. +/// +/// # Returns +/// * Translated Action - contains action sizes and the percentage to use both respective size (out +/// of 1.0). When returning for only one size, smaller_size and larger_size will be set to the +/// same value, smaller_percentage will be set to 1.0, and larger_size will bet set to 0.0. +/// +/// # Panics +/// * When pot_size is less than 1 chip +/// * When opponent_bet is less than 1 chip +/// * When action_abstraction isn't sorted in ascending order, doesn't contain only unique values, +/// contains less than two actions, or contains any actions less than 1 chip. +pub fn translate_action( + pot_size: Chips, + opponent_bet: Chips, + action_abstraction: &[Chips], +) -> TranslatedAction { + if pot_size <= 1 || opponent_bet < 1 || action_abstraction.into_iter().any(|&size| size < 1) { + panic!("pot_size, opponent_bet, and all action_abstraction sizes must be at least 1 chip.") + } + if action_abstraction.len() < 2 { + panic!("action_abstraction must have at least 2 elements.") + } + if !(action_abstraction + .into_iter() + .find(|&&bet_size| bet_size < 1) + .is_none()) + { + panic!("action_abstraction actions must all be at least 1 chip.") + } + + for chunk in action_abstraction.windows(2) { + if chunk[0] >= chunk[1] { + panic!("action_abstraction must be sorted in ascending order and contain no repeated values.") + } + } + + // If opponent_bet is itself an option in action_abstraction then no need to randomize! + if action_abstraction.contains(&opponent_bet) { + return TranslatedAction { + smaller_size: opponent_bet, + larger_size: opponent_bet, + smaller_percentage: 1.0, + larger_percentage: 0.0, + }; + } + + // If opponent_bet is outside the range of bets in the action_abstraction then there's no + // point in randomizing. As in the paper, all we can do in such cases is to simply use + // the closet bet (i.e. smallest or largest size) 100% of the time. + if opponent_bet < action_abstraction[0] { + return TranslatedAction { + smaller_size: action_abstraction[0], + larger_size: action_abstraction[0], + smaller_percentage: 1.0, + larger_percentage: 0.0, + }; + } + let largest_abstraction_size = action_abstraction + .last() + .copied() + .expect("Should never see this; we verified above that it has >= 2 elements."); + if opponent_bet > largest_abstraction_size { + return TranslatedAction { + smaller_size: largest_abstraction_size, + larger_size: largest_abstraction_size, + smaller_percentage: 1.0, + larger_percentage: 0.0, + }; + } + + // Now we've finally verified the inputs are good and that we cannot early return. Therefore: + // we can, and need to, choose two actions to translate opponent_bet to (as well as calculate + // the percentages we should randomize between them). + + let actions_to_randomize_between = action_abstraction + .windows(2) + .find(|&chunk| opponent_bet > chunk[0] && opponent_bet < chunk[1]) + .expect("Early returns above should have made it impossible to not find a match here."); + + let pot_size_f64 = pot_size as f64; + let probabilty_smaller_bet = calc_pseudo_harmonic_mapping( + // Scale down everything by pot to ensure we provide scale invariance. (This is not + // explained super clearly in the paper, but I believe it's *why* they scaled everything + // down to be relative to a pot size of 1 when they were actually analyzing things.) + actions_to_randomize_between[0] as f64 / pot_size_f64, + actions_to_randomize_between[1] as f64 / pot_size_f64, + opponent_bet as f64 / pot_size_f64, + ); + + TranslatedAction { + smaller_size: actions_to_randomize_between[0], + larger_size: actions_to_randomize_between[1], + + smaller_percentage: probabilty_smaller_bet, + larger_percentage: 1.0 - probabilty_smaller_bet, + } +} + +/// Calculates psuedo-harmonic mapping percentage to use the smaller bet. +/// +/// NOTE: INPUTS MUST ALL BE NORMALIZED RELATIVE TO THE POT SIZE (ie pot = 1.0). +/// (Hence the '_ratio' in each name.) +/// +/// Formula: f_A,B (x) = ((B - x) * (1 + A)) / ((B - A) * (1 + x)) +/// Where: A=smaller_bet/pot, B=larger_bet/pot, x=opponent_bet/pot +fn calc_pseudo_harmonic_mapping( + smaller_bet_ratio: f64, + larger_bet_ratio: f64, + opponent_bet_ratio: f64, +) -> f64 { + let numerator = (larger_bet_ratio - opponent_bet_ratio) * (1.0 + smaller_bet_ratio); + let denominator = (larger_bet_ratio - smaller_bet_ratio) * (1.0 + opponent_bet_ratio); + + // Prevent effective division by zero from quietly returning bad results. + if denominator.abs() < f64::EPSILON { + panic!("Denominator evaluates to approximately zero for the given inputs."); + } + + numerator / denominator +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_happy_path_maintains_scale_invariance() { + // Arbitrarily choosing 5 decimal places. + let precision = 0.00001; + + let translation = translate_action(100, 50, [11, 25, 75, 133, 30101].as_slice()); + + assert_eq!(translation.smaller_size, 25); + assert_eq!(translation.larger_size, 75); + + // When A=0.25, B=0.75, x=0.5, + // f(A, B, x) = ((B - x) * (1 + A)) / ((B - A) * (1 + x)) + // = 0.25 * 1.25 / (.5 * 1.5) + // = 0.3125 / 0.75 + // = (5/16) * (4/3) + // = 5/12 + // = 0.4166666666... + assert!((translation.smaller_percentage - 0.41667).abs() < precision); + assert!((translation.larger_percentage - 0.58333).abs() < precision); + } + + #[test] + fn test_no_max_i32_overflow() { + // Arbitrarily choosing to go out to 5 decimal places. + let precision = 0.00001; + + let translation = translate_action(1000, 500, [1, 250, 750, i16::MAX].as_slice()); + + assert_eq!(translation.smaller_size, 250); + assert_eq!(translation.larger_size, 750); + assert!((translation.smaller_percentage - 0.41667).abs() < precision); + assert!((translation.larger_percentage - 0.58333).abs() < precision); + } + + #[test] + fn test_size_in_abstraction() { + let translation = translate_action(1000, 6, [2, 6, 9].as_slice()); + + assert_eq!(translation.smaller_size, 6); + assert_eq!(translation.larger_size, 6); + assert!((translation.smaller_percentage - 1.0).abs() < f64::EPSILON); + assert!(translation.larger_percentage.abs() < f64::EPSILON); + } + + #[test] + fn test_size_smaller_than_any_in_abstraction() { + let translation = translate_action(1000, 8, [10, 20, 30].as_slice()); + + assert_eq!(translation.smaller_size, 10); + assert_eq!(translation.larger_size, 10); + assert!((translation.smaller_percentage - 1.0).abs() < f64::EPSILON); + assert!(translation.larger_percentage.abs() < f64::EPSILON); + } + #[test] + fn test_size_larger_than_any_in_abstraction() { + let translation = translate_action(1000, 37, [10, 20, 30].as_slice()); + + assert_eq!(translation.smaller_size, 30); + assert_eq!(translation.larger_size, 30); + assert!((translation.smaller_percentage - 1.0).abs() < f64::EPSILON); + assert!(translation.larger_percentage.abs() < f64::EPSILON); + } + + #[test] + #[should_panic] + fn test_disallows_zero_opponent_bet_size() { + translate_action(100, 0, [5, 6].as_slice()); + } + + #[test] + #[should_panic] + fn test_disallows_negative_opponent_bet_size() { + translate_action(100, -4, [5, 6].as_slice()); + } + + #[test] + #[should_panic] + fn test_disallows_zero_pot_size() { + translate_action(0, 1, [5, 6].as_slice()); + } + + #[test] + #[should_panic] + fn test_disallows_negative_pot_size() { + translate_action(-4, 1, [5, 6].as_slice()); + } + + #[test] + #[should_panic] + fn test_disallows_zero_chip_action_abstraction() { + translate_action(1000, 1, [0, 4, 6].as_slice()); + } + + #[test] + #[should_panic] + fn test_disallows_negative_chip_action_abstraction() { + translate_action(1000, 1, [-4, 4, 6].as_slice()); + } + + #[test] + #[should_panic] + fn test_disallows_empty_action_abstraction() { + translate_action(1000, 1, [].as_slice()); + } + + #[test] + #[should_panic] + fn test_disallows_unsorted_action_abstraction() { + translate_action(1000, 3, [2, 5, 4, 6].as_slice()); + } + + // See "Table 1: Effect of increasing A while holding B = 1 and x = 0.25 fixed". + // + // NOTE: Tests the internal calc_pseudo_harmonic_mapping(), not the public translate_action(). + #[test] + fn test_replicates_paper_table_1_results() { + let b = 1.0; + let x = 0.25; + + assert_eq!(calc_pseudo_harmonic_mapping(0.000, b, x), 0.6); + // The results table only reported these non-exact results out to 3 decimal places. + let precision = 0.001; + assert!((calc_pseudo_harmonic_mapping(0.001, b, x) - 0.601).abs() < precision); + assert!((calc_pseudo_harmonic_mapping(0.010, b, x) - 0.612).abs() < precision); + assert!((calc_pseudo_harmonic_mapping(0.050, b, x) - 0.663).abs() < precision); + assert!((calc_pseudo_harmonic_mapping(0.100, b, x) - 0.733).abs() < precision); + } +} diff --git a/src/transport/coupling.rs b/src/transport/coupling.rs new file mode 100644 index 00000000..68603956 --- /dev/null +++ b/src/transport/coupling.rs @@ -0,0 +1,31 @@ +use super::density::Density; +use super::measure::Measure; +use super::support::Support; + +pub trait Coupling { + type X: Support; + type Y: Support; + type M: Measure; + type P: Density; + type Q: Density; + + /// minimize the coupling using whatever algorithm + /// the implementor sees fit. the contract is that + /// when calling ::cost(), the coupling has already been minimized. + /// alternatively, we might move this implementation into a From trait. + fn minimize(self) -> Self; + + /// default ::cost() implemenation assumes that we have flow(x, y + /// available cheaply enough that we can doubly-integrate + /// over the support of joint distribution. + /// + /// in practice, our optimal cost implmentations (both Metric and + /// Equity) calculate flow(x, y) lazily and in a way that doesn't + /// make sense to integrate over the support of the joint distribution. + fn flow(&self, x: &Self::X, y: &Self::Y) -> f32; + + /// + /// Equity uses simple O(N) integration of total variation + /// Metric uses greedy approximation of EMD. + fn cost(&self) -> f32; +} diff --git a/src/transport/density.rs b/src/transport/density.rs new file mode 100644 index 00000000..aac69270 --- /dev/null +++ b/src/transport/density.rs @@ -0,0 +1,11 @@ +use super::support::Support; +use crate::Probability; + +/// generalization of any probability distribution over +/// arbitrary Support. +pub trait Density { + type S: Support; + + fn density(&self, x: &Self::S) -> Probability; + fn support(&self) -> impl Iterator; +} diff --git a/src/transport/greedy.rs b/src/transport/greedy.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/transport/greedy.rs @@ -0,0 +1 @@ + diff --git a/src/transport/greenkhorn.rs b/src/transport/greenkhorn.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/transport/greenkhorn.rs @@ -0,0 +1 @@ + diff --git a/src/transport/measure.rs b/src/transport/measure.rs new file mode 100644 index 00000000..d9c1e5a5 --- /dev/null +++ b/src/transport/measure.rs @@ -0,0 +1,18 @@ +use super::support::Support; + +/// generalization of *element-wise* distance metric between +/// two Density spaces over arbitrary Support. +/// +/// for Equity, this is trivially the absolute value of the difference. +/// for Metric, we precompute distances based on the previous layer's clustering. +/// +/// in both these cases, X and Y are of the same type. +/// note however that generally, image space X and range space Y need not +/// have the same support. what is important is that we can define a +/// distance between any x ∈ X and any y ∈ Y. +pub trait Measure { + type X: Support; + type Y: Support; + + fn distance(&self, x: &Self::X, y: &Self::Y) -> f32; +} diff --git a/src/transport/mod.rs b/src/transport/mod.rs new file mode 100644 index 00000000..75c53f28 --- /dev/null +++ b/src/transport/mod.rs @@ -0,0 +1,6 @@ +pub mod coupling; +pub mod density; +pub mod greedy; +pub mod greenkhorn; +pub mod measure; +pub mod support; diff --git a/src/transport/support.rs b/src/transport/support.rs new file mode 100644 index 00000000..1bde7663 --- /dev/null +++ b/src/transport/support.rs @@ -0,0 +1,7 @@ +/// marker trait for any type that can +/// be interpreted as a support for a probability distribution. +/// +/// currently only implemented by +/// - Abstraction::Random(_) , where Histogram is the implied Density and Metric is the implied Measure +/// - Abstraction::Equity(_) , where Histogram is the implied Density and i16 is the implied Measure +pub trait Support: Clone {} diff --git a/src/wasm.rs b/src/wasm.rs new file mode 100644 index 00000000..0679be64 --- /dev/null +++ b/src/wasm.rs @@ -0,0 +1,167 @@ +use crate::cards::card::Card; +use crate::cards::hand::Hand; +use crate::cards::observation::Observation; +use crate::cards::rank::Rank; +use crate::cards::street::Street; +use crate::cards::suit::Suit; +use wasm_bindgen::prelude::*; + +// Re-export types for JavaScript +#[wasm_bindgen] +pub struct WasmCard(Card); + +#[wasm_bindgen] +pub struct WasmHand(Hand); + +#[wasm_bindgen] +pub struct WasmObservation(Observation); + +// Card implementation +#[wasm_bindgen] +impl WasmCard { + #[wasm_bindgen(constructor)] + pub fn new(rank: u8, suit: u8) -> Result { + let r = Rank::try_from(rank).map_err(|e| JsValue::from_str(&e.to_string()))?; + let s = Suit::try_from(suit).map_err(|e| JsValue::from_str(&e.to_string()))?; + Ok(WasmCard(Card::from((r, s)))) + } + + #[wasm_bindgen] + pub fn from_string(card_str: &str) -> Result { + let card = Card::try_from(card_str).map_err(|e| JsValue::from_str(&e))?; + Ok(WasmCard(card)) + } + + #[wasm_bindgen] + pub fn rank(&self) -> u8 { + self.0.rank().into() + } + + #[wasm_bindgen] + pub fn suit(&self) -> u8 { + self.0.suit().into() + } + + #[wasm_bindgen] + pub fn to_string(&self) -> String { + self.0.to_string() + } + + #[wasm_bindgen] + pub fn to_byte(&self) -> u8 { + self.0.into() + } +} + +// Hand implementation +#[wasm_bindgen] +impl WasmHand { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + WasmHand(Hand::empty()) + } + + #[wasm_bindgen] + pub fn from_string(hand_str: &str) -> Result { + let hand = Hand::try_from(hand_str).map_err(|e| JsValue::from_str(&e))?; + Ok(WasmHand(hand)) + } + + #[wasm_bindgen] + pub fn add_card(&mut self, card: &WasmCard) { + let mut hand = self.0; + hand = Hand::add(hand, Hand::from(card.0)); + self.0 = hand; + } + + #[wasm_bindgen] + pub fn remove_card(&mut self, card: &WasmCard) { + self.0.remove(card.0); + } + + #[wasm_bindgen] + pub fn contains(&self, card: &WasmCard) -> bool { + self.0.contains(&card.0) + } + + #[wasm_bindgen] + pub fn size(&self) -> usize { + self.0.size() + } + + #[wasm_bindgen] + pub fn to_string(&self) -> String { + self.0.to_string() + } + + #[wasm_bindgen] + pub fn to_card_array(&self) -> js_sys::Array { + self.0 + .into_iter() + .map(WasmCard) + .map(JsValue::from) + .collect::() + } +} + +// Observation implementation +#[wasm_bindgen] +impl WasmObservation { + #[wasm_bindgen(constructor)] + pub fn new(pocket: &WasmHand, public: &WasmHand) -> Self { + WasmObservation(Observation::from((pocket.0, public.0))) + } + + #[wasm_bindgen] + pub fn from_string(obs_str: &str) -> Result { + let obs = Observation::try_from(obs_str).map_err(|e| JsValue::from_str(&e))?; + Ok(WasmObservation(obs)) + } + + #[wasm_bindgen] + pub fn from_street(street: usize) -> Result { + Ok(WasmObservation(Observation::from(Street::from(street)))) + } + + #[wasm_bindgen] + pub fn street(&self) -> usize { + self.0.street() as usize + } + + #[wasm_bindgen] + pub fn pocket(&self) -> WasmHand { + WasmHand(self.0.pocket().clone()) + } + + #[wasm_bindgen] + pub fn public(&self) -> WasmHand { + WasmHand(self.0.public().clone()) + } + + #[wasm_bindgen] + pub fn equity(&self) -> f64 { + self.0.equity() as f64 + } + + #[wasm_bindgen] + pub fn estimate(&self) -> f64 { + self.0.estimate() as f64 + } + + #[wasm_bindgen] + pub fn equivalent(&self) -> String { + self.0.equivalent() + } + + #[wasm_bindgen] + pub fn to_string(&self) -> String { + self.0.to_string() + } +} + +// Initialize function +#[wasm_bindgen(start)] +pub fn start() { + // This function will be called when the WASM module is loaded + console_error_panic_hook::set_once(); +}