@@ 0,0 1,484 @@
++++
+title = "Day 2"
+date = 2023-01-23
+weight = 1
++++
+
+Alright, day 2.
+
+## Problem statement
+
+Rock Paper Scissors!
+
+We are given a "strategy guide" that looks like:
+
+```
+A Y
+B X
+C Z
+```
+
+and we need to follow the provided scheme.
+
+We save the example as `input/02/example.txt` and our input as `input/02/input.txt`.
+
+## Python time!
+
+We create our solution in `2022/py/day02/sol.py` and add a blank `2022/py/day02/__init__.py`.
+
+Software engineering best practices be damned, let's hard code everything!
+Since we know the score associated with each combination of moves, we can
+create a dictionary that maps each combination to the final score:
+
+```python
+# 2022/py/day02/sol.py
+
+from py.read_input import read_input
+
+
+def main() -> int:
+ input = read_input(2)
+ scores = {
+ "A X": 4, # 3 + 1
+ "A Y": 8, # 6 + 2
+ "A Z": 3, # 0 + 3
+ "B X": 1, # 0 + 1
+ "B Y": 5, # 3 + 2
+ "B Z": 9, # 6 + 3
+ "C X": 7, # 6 + 1
+ "C Y": 2, # 0 + 2
+ "C Z": 6, # 3 + 3
+ }
+ result = sum(scores[line.strip()] for line in input.splitlines())
+ print(f"Part one: {result}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+```
+
+Part two involves a different scoring scheme, so we can take the same approach
+but with a different `scores` table:
+
+```python
+# 2022/py/day02/sol.py
+
+from typing import Final, Iterable, Mapping
+
+from py.read_input import read_input
+
+
+P1_SCORES: Final[dict[str, int]] = {
+ "A X": 4, # 3 + 1
+ "A Y": 8, # 6 + 2
+ "A Z": 3, # 0 + 3
+ "B X": 1, # 0 + 1
+ "B Y": 5, # 3 + 2
+ "B Z": 9, # 6 + 3
+ "C X": 7, # 6 + 1
+ "C Y": 2, # 0 + 2
+ "C Z": 6, # 3 + 3
+}
+
+P2_SCORES: Final[dict[str, int]] = {
+ "A X": 3, # 3 + 0
+ "A Y": 4, # 1 + 3
+ "A Z": 8, # 2 + 6
+ "B X": 1, # 1 + 0
+ "B Y": 5, # 2 + 3
+ "B Z": 9, # 3 + 6
+ "C X": 2, # 2 + 0
+ "C Y": 6, # 3 + 3
+ "C Z": 7, # 1 + 6
+}
+
+
+def score(games: Iterable[str], table: Mapping[str, int]) -> int:
+ return sum(table[game] for game in games)
+
+
+def main() -> int:
+ input = read_input(2)
+ games = [line.strip() for line in input.splitlines()]
+ print(f"Part one: {score(games, P1_SCORES)}")
+ print(f"Part two: {score(games, P2_SCORES)}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+```
+
+I guess one interesting note is the use of the `Iterable` type instead of
+`list[str]` for the `games` argument and the `Mapping` type instead of
+`dict[str, int]` for the `table` argument. In general when using type
+hints, it can be nice to get in the habit of accepting more general abstract types.
+It's not likely for this program, but you could imagine that someone might
+swap to using a `set` or `frozenset` to represent some collection. By using
+argument types like `Iterable`, you can enable users of functions to pass in a
+larger variety of inputs without additional copies or allocations. Additionally,
+immutable container types are covariant on their value types. Using immutable
+container argument types often makes your interfaces much more flexible.
+
+### Efficiency
+
+This solution is optimal in terms of asymptotic complexity (it runs in linear time
+over the length of the input). But one idea we could try would be to use a
+`match` statement instead of a lookup table. It's unlikely that this will yield
+performance benefits since dictionary lookups are well optimized in Python. I'm
+more curious to see how these approaches fare in Rust, but let's try it in Python,
+just to see!
+
+```python
+# 2022/py/day02/sol.py
+
+from time import perf_counter
+from typing import Callable, Final, Iterable, Mapping, Sequence
+
+from py.read_input import read_input
+
+
+P1_SCORES: Final[dict[str, int]] = {
+ "A X": 4, # 3 + 1
+ "A Y": 8, # 6 + 2
+ "A Z": 3, # 0 + 3
+ "B X": 1, # 0 + 1
+ "B Y": 5, # 3 + 2
+ "B Z": 9, # 6 + 3
+ "C X": 7, # 6 + 1
+ "C Y": 2, # 0 + 2
+ "C Z": 6, # 3 + 3
+}
+
+P2_SCORES: Final[dict[str, int]] = {
+ "A X": 3, # 3 + 0
+ "A Y": 4, # 1 + 3
+ "A Z": 8, # 2 + 6
+ "B X": 1, # 1 + 0
+ "B Y": 5, # 2 + 3
+ "B Z": 9, # 3 + 6
+ "C X": 2, # 2 + 0
+ "C Y": 6, # 3 + 3
+ "C Z": 7, # 1 + 6
+}
+
+
+def table_score(games: Iterable[str], table: Mapping[str, int]) -> int:
+ return sum(table[game] for game in games)
+
+
+def table_sol(games: Sequence[str]) -> tuple[int, int]:
+ return table_score(games, P1_SCORES), table_score(games, P2_SCORES)
+
+
+def score_fn_p1(game: str) -> int:
+ match game:
+ case "A X":
+ return 4
+ case "A Y":
+ return 8
+ case "A Z":
+ return 3
+ case "B X":
+ return 1
+ case "B Y":
+ return 5
+ case "B Z":
+ return 9
+ case "C X":
+ return 7
+ case "C Y":
+ return 2
+ case "C Z":
+ return 6
+ case _:
+ raise ValueError(f"Invalid game: {game}")
+
+
+def score_fn_p2(game: str) -> int:
+ match game:
+ case "A X":
+ return 3
+ case "A Y":
+ return 4
+ case "A Z":
+ return 8
+ case "B X":
+ return 1
+ case "B Y":
+ return 5
+ case "B Z":
+ return 9
+ case "C X":
+ return 2
+ case "C Y":
+ return 6
+ case "C Z":
+ return 7
+ case _:
+ raise ValueError(f"Invalid game: {game}")
+
+
+def match_score(games: Iterable[str], score_fn: Callable[[str], int]) -> int:
+ return sum(score_fn(game) for game in games)
+
+
+def match_sol(games: Sequence[str]) -> tuple[int, int]:
+ return match_score(games, score_fn_p1), match_score(games, score_fn_p2)
+
+
+def main() -> int:
+ input = read_input(2)
+ games = [line.strip() for line in input.splitlines()]
+ expected_output = table_sol(games)
+ print(f"Part one: {expected_output[0]}")
+ print(f"Part two: {expected_output[1]}")
+
+ def benchmark(solution: Callable[[Sequence[str]], tuple[int, int]]) -> float:
+ start = perf_counter()
+ for _ in range(1000):
+ assert solution(games) == expected_output
+ end = perf_counter()
+ return end - start
+
+ print(f"Table solution takes {benchmark(table_sol)}s")
+ print(f"Match solution takes {benchmark(match_sol)}s")
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+```
+
+Running this on my machine yields this result:
+
+```
+$ python -m py.day02.sol
+Part one: 11906
+Part two: 11186
+Table solution takes 0.1719587080006022s
+Match solution takes 0.38245958300103666s
+```
+
+So the `match`-based solution takes more than twice as long as the lookup table
+approach. I'm glad that Python now has some form of structural pattern matching, but
+a little bit sad about the performance gap to lookup tables, especially when compared
+with compiled languages like Rust or Standard ML.
+
+So for Python, we'll stick with our original lookup table approach.
+
+## Rust!
+
+Let's try the same exercise in Rust.
+
+You know most of the boilerplate from day 1, so I'll gloss over most of those details.
+
+We'll use the [`phf`](https://crates.io/crates/phf) crate to build our lookup table.
+
+```
+$ cargo add phf phf_macros
+```
+
+```rust
+use phf_macros::phf_map;
+
+fn parse_games(input: &str) -> Vec<&str> {
+ input.lines().map(str::trim).collect::<Vec<_>>()
+}
+
+static P1_SCORES: phf::Map<&'static str, i32> = phf_map! {
+ "A X" => 3 + 1,
+ "A Y" => 6 + 2,
+ "A Z" => 0 + 3,
+ "B X" => 0 + 1,
+ "B Y" => 3 + 2,
+ "B Z" => 6 + 3,
+ "C X" => 6 + 1,
+ "C Y" => 0 + 2,
+ "C Z" => 3 + 3,
+};
+
+static P2_SCORES: phf::Map<&'static str, i32> = phf_map! {
+ "A X" => 3 + 0,
+ "A Y" => 1 + 3,
+ "A Z" => 2 + 6,
+ "B X" => 1 + 0,
+ "B Y" => 2 + 3,
+ "B Z" => 3 + 6,
+ "C X" => 2 + 0,
+ "C Y" => 3 + 3,
+ "C Z" => 1 + 6,
+};
+
+fn table_score(games: &[&str], table: &phf::Map<&'static str, i32>) -> i32 {
+ games
+ .iter()
+ .map(|game| table.get(game).expect("invalid game"))
+ .sum()
+}
+
+pub fn table_sol(input: &str) -> (i32, i32) {
+ let games = parse_games(input);
+ (
+ table_score(&games, &P1_SCORES),
+ table_score(&games, &P2_SCORES),
+ )
+}
+
+fn score_fn_p1(game: &str) -> i32 {
+ match game {
+ "A X" => 3 + 1,
+ "A Y" => 6 + 2,
+ "A Z" => 0 + 3,
+ "B X" => 0 + 1,
+ "B Y" => 3 + 2,
+ "B Z" => 6 + 3,
+ "C X" => 6 + 1,
+ "C Y" => 0 + 2,
+ "C Z" => 3 + 3,
+ _ => panic!("invalid game: {game}"),
+ }
+}
+
+fn score_fn_p2(game: &str) -> i32 {
+ match game {
+ "A X" => 3 + 0,
+ "A Y" => 1 + 3,
+ "A Z" => 2 + 6,
+ "B X" => 1 + 0,
+ "B Y" => 2 + 3,
+ "B Z" => 3 + 6,
+ "C X" => 2 + 0,
+ "C Y" => 3 + 3,
+ "C Z" => 1 + 6,
+ _ => panic!("invalid game: {game}"),
+ }
+}
+
+fn match_score(games: &[&str], score_fn: impl Fn(&str) -> i32) -> i32 {
+ games.iter().cloned().map(score_fn).sum()
+}
+
+pub fn match_sol(input: &str) -> (i32, i32) {
+ let games = parse_games(input);
+ (
+ match_score(&games, score_fn_p1),
+ match_score(&games, score_fn_p2),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const EXAMPLE: &str = r#"A Y
+B X
+C Z"#;
+
+ #[test]
+ fn test_table_sol() {
+ assert_eq!(table_sol(EXAMPLE), (15, 12));
+ }
+
+ #[test]
+ fn test_match_sol() {
+ assert_eq!(match_sol(EXAMPLE), (15, 12));
+ }
+}
+```
+
+Let's hook it all up in our `main` function:
+
+```rust
+use std::error::Error;
+
+use day02::{match_sol, table_sol};
+
+fn main() -> Result<(), Box<dyn Error>> {
+ let input = include_str!("../../../../input/02/input.txt");
+ let (tp1, tp2) = table_sol(input);
+ println!("Table sol p1: {tp1}, p2: {tp2}");
+ let (mp1, mp2) = match_sol(input);
+ println!("Match sol p1: {mp1}, p2: {mp2}");
+ Ok(())
+}
+```
+
+We can run it and get our expected results:
+
+```
+$ cargo run
+ Finished dev [unoptimized + debuginfo] target(s) in 0.02s
+ Running `/Users/sgeisenh/projects/aoc/2022/rust/day02/target/debug/main`
+Table sol p1: 11906, p2: 11186
+Match sol p1: 11906, p2: 11186
+```
+
+Similarly, we add a benchmark file for `criterion` to use:
+
+```rust
+use std::hint::black_box;
+
+use criterion::{criterion_group, criterion_main, Criterion};
+
+static INPUT: &str = include_str!("../../../input/02/input.txt");
+
+pub fn criterion_benchmark(c: &mut Criterion) {
+ c.bench_function("table_sol", |b| {
+ b.iter(|| day02::table_sol(black_box(INPUT)))
+ });
+ c.bench_function("match_sol", |b| {
+ b.iter(|| day02::match_sol(black_box(INPUT)))
+ });
+}
+
+criterion_group!(benches, criterion_benchmark);
+criterion_main!(benches);
+```
+
+And finally, we run our benchmarks:
+
+```
+$ cargo bench
+ Finished bench [optimized] target(s) in 0.03s
+ Running unittests src/lib.rs (/Users/sgeisenh/projects/aoc/2022/rust/day02/target/release/deps/day02-14b54ca39dda7916)
+
+running 2 tests
+test tests::test_match_sol ... ignored
+test tests::test_table_sol ... ignored
+
+test result: ok. 0 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 0.00s
+
+ Running unittests src/bin/main.rs (/Users/sgeisenh/projects/aoc/2022/rust/day02/target/release/deps/main-ea2d4c751f6a8144)
+
+running 0 tests
+
+test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
+
+ Running benches/my_benchmark.rs (/Users/sgeisenh/projects/aoc/2022/rust/day02/target/release/deps/my_benchmark-24b0eacb110149e1)
+Gnuplot not found, using plotters backend
+table_sol time: [105.59 µs 105.63 µs 105.69 µs]
+ change: [-0.1857% -0.0658% +0.0629%] (p = 0.31 > 0.05)
+ No change in performance detected.
+Found 4 outliers among 100 measurements (4.00%)
+ 1 (1.00%) high mild
+ 3 (3.00%) high severe
+
+match_sol time: [47.056 µs 47.131 µs 47.222 µs]
+ change: [-6.3413% -5.8155% -5.1458%] (p = 0.00 < 0.05)
+ Performance has improved.
+Found 5 outliers among 100 measurements (5.00%)
+ 1 (1.00%) high mild
+ 4 (4.00%) high severe
+```
+
+As expected, the `match`-based solution is significantly faster than the table
+lookup solution in Rust.
+
+## That's it!
+
+I'm a little bit sad that day 1 had some more interesting algorithmic ideas to
+explore than day 2. Oh well. I'll probably jump to something like day 20 for my
+next post so we can get into some juicy ideas.