From f060bbf8f66947c52c52f9dbd5853c71f9be9663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Maj?= Date: Fri, 2 Oct 2026 11:30:10 +0200 Subject: [PATCH 1/2] feat(course): add data recall checkpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New mandatory, standalone checkpoint between Level 3 and Level 4 — 30-60 minutes, no new concepts, reactivating NumPy/Pandas from Level 1 on the real combat log schema Level 3 Mission 06 introduced (CombatLogRow: battle_id, turn, hero_name, hero_class, monster, action, damage_dealt, damage_taken, hero_hp, monster_hp, result). Four tasks: load_combat_log (real pd.DataFrame), win_rate_by_class (terminal rows grouped by hero_class — groupby is an explicit learning objective here, not just a means to a number), damage_stats (NumPy mean/std/p75/boolean-mask over attack-only damage, excluding potion-turn zeros which aren't weak hits), and rank_monsters_by_difficulty (same win-rate idea, grouped by monster, sorted). check.py runs every function against two fixtures and compares values against ground truth computed independently with stdlib csv + plain Python (never by re-running the student's own pandas/numpy code). The variant fixture has a genuinely different win-rate ranking and damage distribution, so hardcoded answers that happen to match the primary fixture fail on the variant. Because groupby and NumPy array work are explicit learning objectives (not just "get the right number"), two functions also get a light technique gate: an AST check that groupby is actually called inside win_rate_by_class, and a monkeypatch spy confirming np.percentile is actually called inside damage_stats — neither cares about variable names, chaining style, or lambda usage. Both fixtures are generated from real, Pydantic-validated CombatLogRow instances (imported directly from the frozen Level 3 Mission 06 task.py), not hand-typed CSV rows — 42 battles each, 3 hero classes, 4 monsters, both wins and losses, both attack and potion turns. Verified: reference solution and an alternative-valid solution (different chaining, .query/.agg/np.asarray/np.greater/np.count_nonzero style) both PASS; four fakes (hardcoded answers incl. one that exactly matches the primary fixture, groupby avoided via a per-class loop, np.array created ceremonially with real work done in plain Python, analysis of only the first half of the dataset) all FAIL at the gate they're meant to trip; scaffold FAILs cleanly; deterministic across 3 repeated runs. --- data_recall_checkpoint/README.md | 96 ++++++++ data_recall_checkpoint/check.py | 226 ++++++++++++++++++ .../data/combat_log_fixture.csv | 219 +++++++++++++++++ .../data/combat_log_fixture_variant.csv | 210 ++++++++++++++++ data_recall_checkpoint/task.py | 113 +++++++++ 5 files changed, 864 insertions(+) create mode 100644 data_recall_checkpoint/README.md create mode 100644 data_recall_checkpoint/check.py create mode 100644 data_recall_checkpoint/data/combat_log_fixture.csv create mode 100644 data_recall_checkpoint/data/combat_log_fixture_variant.csv create mode 100644 data_recall_checkpoint/task.py diff --git a/data_recall_checkpoint/README.md b/data_recall_checkpoint/README.md new file mode 100644 index 0000000..64be547 --- /dev/null +++ b/data_recall_checkpoint/README.md @@ -0,0 +1,96 @@ +# Data Recall Checkpoint: Combat Log Analytics + +You learned NumPy and Pandas back in Level 1. Since then the RPG has gained +validation (Pydantic) and persistence (the repository pattern) in Level 3 — +including a real, validated combat log. Before moving into Level 4's +interfaces and tooling, use those data skills again on the combat logs your +application now knows how to store. + +This is **recall, not a new lesson**. No new concepts. No new library. +30–60 minutes. + +## The model + +Level 3 Mission 06 introduced `CombatLogRow`: one row per **turn**, not per +battle. A single battle (`battle_id`) usually spans several rows — all but +the last have `result == "ongoing"`; only the final row of a battle has a +terminal `result` (`"win"` or `"loss"`). To compute a win rate you need +exactly the terminal rows, not every row. + +## The data + +`data/combat_log_fixture.csv` — ~220 rows, same schema `CsvCombatLogRepository` +writes: + +``` +battle_id, turn, hero_name, hero_class, monster, action, +damage_dealt, damage_taken, hero_hp, monster_hp, result +``` + +`action` is `"attack"` or `"potion"`. `result` is `"ongoing"`, `"win"`, or +`"loss"`. You don't need to generate this data yourself — it's provided so +grading is stable regardless of how much you've personally played. + +A second file, `combat_log_fixture_variant.csv`, has the same shape but +different numbers (including a different monster difficulty ranking) — the +checker uses it to make sure your answers actually come from the data, not +from a number you typed once and left there. + +## Cheat-sheet (you've seen all of this in Level 1) + +```python +df = pd.read_csv(path) # M21 +df[df["col"] != "x"] # M22 — filter +df.groupby("col")["other"].apply(fn) # M22 — groupby +df.sort_values("col") # M22 — sort/rank +arr = df["col"].to_numpy() # M17 — array +arr.mean(), arr.std(), np.percentile(arr, 75) # M20 — aggregations +arr > threshold # M18 — boolean mask +``` + +Relevant Level 1 missions if you want a refresher: [17 — Dice Become Arrays](../level_1_python_basics/missions/17_dice_become_arrays/README.md), [18 — Critical Hits](../level_1_python_basics/missions/18_critical_hits/README.md), [20 — Damage Distributions](../level_1_python_basics/missions/20_damage_distributions/README.md), [21 — From Combat Log to DataFrame](../level_1_python_basics/missions/21_from_combat_log_to_dataframe/README.md), [22 — Filter, Group, Rank](../level_1_python_basics/missions/22_filter_group_rank/README.md). + +## Your task + +Open `task.py` and implement all four functions. + +```python +def load_combat_log(path: Path) -> pd.DataFrame: ... +def win_rate_by_class(df: pd.DataFrame) -> dict[str, float]: ... +def damage_stats(df: pd.DataFrame) -> dict[str, float | int]: ... +def rank_monsters_by_difficulty(df: pd.DataFrame) -> list[str]: ... +``` + +Each function's docstring has the exact contract. Two notes worth reading +before you start: + +- **`win_rate_by_class` must use pandas' `groupby`.** A per-class Python + loop can produce the same numbers, but `groupby` is specifically what + this task is here to reactivate — the checker looks for it. +- **`damage_stats` only counts `"attack"` rows.** A `"potion"` turn has + `damage_dealt == 0` because the hero healed instead of attacking, not + because the hit was weak — mixing those zeros into the stats would + understate real attack damage. + +## Questions this answers + +- Which hero class wins most often, and by how much? +- What does a "hard-hitting" attack look like in this log (p75), and how + often does one land? +- Which monster is operationally the hardest, by this log's win rates alone? + +## How to check + +```bash +uv run python data_recall_checkpoint/check.py +``` + +The checker runs every function against two different fixtures and checks +the actual values — not your code's syntax. It also confirms `groupby` is +really used in `win_rate_by_class` and that `np.percentile` is really called +in `damage_stats`, since those two specific APIs are the point of this +checkpoint. + +--- + +**Next:** [Level 4 — Interfaces and Reports](../level_4_interfaces/README.md) diff --git a/data_recall_checkpoint/check.py b/data_recall_checkpoint/check.py new file mode 100644 index 0000000..77117a8 --- /dev/null +++ b/data_recall_checkpoint/check.py @@ -0,0 +1,226 @@ +"""Check: Data Recall Checkpoint — Combat Log Analytics. + +Behavioral checker: every function is run against TWO fixtures (primary and +variant) and compared against ground truth computed independently with +stdlib `csv` + plain Python — never by re-running the student's own pandas +or NumPy code. The variant fixture has a genuinely different win-rate +ranking and different damage statistics, so a hardcoded answer that happens +to match the primary fixture fails on the variant. + +For win_rate_by_class and damage_stats, Pandas' `groupby` and NumPy's array +work are explicit learning objectives for this checkpoint (not just "produce +the right numbers") — so those two functions also get a light technique +gate on top of the behavioral one: an AST check that `groupby` is actually +called inside win_rate_by_class, and a monkeypatch spy confirming +`numpy.percentile` is actually called inside damage_stats. Neither gate +cares about variable names, chaining style, or lambda usage. +""" +import ast +import csv +import json +import math +import statistics +import sys +from collections import defaultdict +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +mission = Path(__file__).parent +PROGRESS_FILE = Path(__file__).parent / ".progress" + + +def update_progress() -> None: + progress: dict = {"missions": {}, "projects": {}} + if PROGRESS_FILE.exists(): + try: + progress = json.loads(PROGRESS_FILE.read_text()) + except json.JSONDecodeError: + pass + progress["projects"]["data_recall_checkpoint"] = "complete" + PROGRESS_FILE.write_text(json.dumps(progress, indent=2)) + + +# ── ground truth (stdlib csv + plain Python only — never pandas/numpy) ────── + +def _load_raw_rows(path: Path) -> list[dict]: + with path.open(newline="") as f: + return list(csv.DictReader(f)) + + +def _ground_truth_win_rate_by(rows: list[dict], key: str) -> dict[str, float]: + totals: dict[str, list[int]] = defaultdict(lambda: [0, 0]) + for row in rows: + if row["result"] in ("win", "loss"): + totals[row[key]][1] += 1 + if row["result"] == "win": + totals[row[key]][0] += 1 + return {k: wins / total for k, (wins, total) in totals.items() if total > 0} + + +def _percentile_linear(sorted_values: list[float], pct: float) -> float: + n = len(sorted_values) + if n == 0: + return 0.0 + k = (n - 1) * (pct / 100) + f = int(k) + c = f + 1 if f + 1 < n else f + if f == c: + return float(sorted_values[f]) + d0 = sorted_values[f] * (c - k) + d1 = sorted_values[c] * (k - f) + return float(d0 + d1) + + +def _ground_truth_damage_stats(rows: list[dict]) -> dict[str, float | int]: + values = [int(row["damage_dealt"]) for row in rows if row["action"] == "attack"] + mean = statistics.mean(values) + std = statistics.pstdev(values) + p75 = _percentile_linear(sorted(values), 75) + high_count = sum(1 for v in values if v > p75) + return {"mean": mean, "std": std, "p75": p75, "high_damage_count": high_count} + + +def _ground_truth_ranking(rows: list[dict]) -> list[str]: + rates = _ground_truth_win_rate_by(rows, "monster") + return [name for name, _ in sorted(rates.items(), key=lambda kv: kv[1])] + + +def _dicts_close(actual: dict, expected: dict, *, rel_tol: float = 1e-6, abs_tol: float = 1e-6) -> bool: + if set(actual.keys()) != set(expected.keys()): + return False + for k, exp_v in expected.items(): + act_v = actual.get(k) + if isinstance(exp_v, float) or isinstance(act_v, float): + if act_v is None or not math.isclose(float(act_v), float(exp_v), rel_tol=rel_tol, abs_tol=abs_tol): + return False + else: + if act_v != exp_v: + return False + return True + + +# ── AST technique gate: win_rate_by_class must call .groupby(...) ─────────── + +def _function_uses_attr_call(source: str, fn_name: str, attr_name: str) -> bool: + tree = ast.parse(source) + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef) or node.name != fn_name: + continue + for child in ast.walk(node): + if isinstance(child, ast.Call) and isinstance(child.func, ast.Attribute): + if child.func.attr == attr_name: + return True + return False + + +FIXTURES = [ + ("primary", mission / "data" / "combat_log_fixture.csv"), + ("variant", mission / "data" / "combat_log_fixture_variant.csv"), +] + + +def main() -> None: + try: + from task import ( # type: ignore[import] + load_combat_log, + win_rate_by_class, + damage_stats, + rank_monsters_by_difficulty, + ) + except ImportError as exc: + print(f"❌ Cannot import from task.py: {exc}") + raise SystemExit(1) + + import pandas as pd + + # ── scaffold guard ──────────────────────────────────────────────────── + try: + load_combat_log(FIXTURES[0][1]) + except NotImplementedError: + print("❌ load_combat_log is not implemented yet — replace NotImplementedError with your code") + raise SystemExit(1) + except Exception: + pass # any real error = student modified it; continue to the real checks + + for fixture_name, fixture_path in FIXTURES: + raw_rows = _load_raw_rows(fixture_path) + + # ── Task 1: load_combat_log ───────────────────────────────────── + df = load_combat_log(fixture_path) + if not isinstance(df, pd.DataFrame): + print(f"❌ [{fixture_name}] load_combat_log must return a pandas DataFrame, got {type(df)}") + raise SystemExit(1) + if len(df) != len(raw_rows): + print(f"❌ [{fixture_name}] load_combat_log: expected {len(raw_rows)} rows, got {len(df)}") + raise SystemExit(1) + expected_columns = { + "battle_id", "turn", "hero_name", "hero_class", "monster", + "action", "damage_dealt", "damage_taken", "hero_hp", "monster_hp", "result", + } + if not expected_columns.issubset(set(df.columns)): + print(f"❌ [{fixture_name}] load_combat_log: missing columns " + f"{expected_columns - set(df.columns)}") + raise SystemExit(1) + if not pd.api.types.is_integer_dtype(df["damage_dealt"]): + print(f"❌ [{fixture_name}] load_combat_log: 'damage_dealt' should be numeric " + f"(did you read the CSV with pandas, not row-by-row strings?)") + raise SystemExit(1) + print(f"✓ [{fixture_name}] load_combat_log → DataFrame, {len(df)} rows, correct columns") + + # ── Task 2: win_rate_by_class (behavior + groupby technique gate) ─ + result = win_rate_by_class(df) + expected = _ground_truth_win_rate_by(raw_rows, "hero_class") + if not isinstance(result, dict) or not _dicts_close(result, expected): + print(f"❌ [{fixture_name}] win_rate_by_class: expected {expected}, got {result}") + raise SystemExit(1) + print(f"✓ [{fixture_name}] win_rate_by_class → {result}") + + # ── Task 3: damage_stats (behavior + np.percentile technique gate) ─ + import numpy as np + _percentile_calls: list = [] + _original_percentile = np.percentile + + def _percentile_spy(*args, **kwargs): + _percentile_calls.append((args, kwargs)) + return _original_percentile(*args, **kwargs) + + np.percentile = _percentile_spy + try: + result = damage_stats(df) + finally: + np.percentile = _original_percentile + + expected = _ground_truth_damage_stats(raw_rows) + if not isinstance(result, dict) or not _dicts_close(result, expected, rel_tol=1e-3, abs_tol=1e-3): + print(f"❌ [{fixture_name}] damage_stats: expected {expected}, got {result}") + raise SystemExit(1) + if not _percentile_calls: + print(f"❌ [{fixture_name}] damage_stats: np.percentile was never called — " + f"values must be computed with NumPy, not plain Python arithmetic " + f"on a ceremonially-created array") + raise SystemExit(1) + print(f"✓ [{fixture_name}] damage_stats → {result} (np.percentile confirmed used)") + + # ── Task 4: rank_monsters_by_difficulty (behavior) ───────────────── + result = rank_monsters_by_difficulty(df) + expected = _ground_truth_ranking(raw_rows) + if list(result) != expected: + print(f"❌ [{fixture_name}] rank_monsters_by_difficulty: expected {expected}, got {result}") + raise SystemExit(1) + print(f"✓ [{fixture_name}] rank_monsters_by_difficulty → {result}") + + # ── technique gate: win_rate_by_class must actually use groupby ────── + src = (mission / "task.py").read_text() + if not _function_uses_attr_call(src, "win_rate_by_class", "groupby"): + print("❌ win_rate_by_class must use pandas' groupby — " + "a per-class Python loop (e.g. `for cls in df['hero_class'].unique(): ...`) " + "produces the same numbers but skips the learning objective of this task") + raise SystemExit(1) + print("✓ win_rate_by_class uses pandas groupby") + + update_progress() + print("\n✅ Data Recall Checkpoint complete! Pandas and NumPy are back online.") + + +if __name__ == "__main__": + main() diff --git a/data_recall_checkpoint/data/combat_log_fixture.csv b/data_recall_checkpoint/data/combat_log_fixture.csv new file mode 100644 index 0000000..2c18869 --- /dev/null +++ b/data_recall_checkpoint/data/combat_log_fixture.csv @@ -0,0 +1,219 @@ +battle_id,turn,hero_name,hero_class,monster,action,damage_dealt,damage_taken,hero_hp,monster_hp,result +1,1,Dorn,rogue,Troll,attack,10,12,88,70,ongoing +1,2,Dorn,rogue,Troll,attack,9,16,72,61,ongoing +1,3,Dorn,rogue,Troll,attack,8,14,58,53,ongoing +1,4,Dorn,rogue,Troll,potion,0,12,58,53,ongoing +1,5,Dorn,rogue,Troll,potion,0,15,58,53,ongoing +1,6,Dorn,rogue,Troll,attack,8,12,46,45,ongoing +1,7,Dorn,rogue,Troll,attack,11,14,0,34,loss +2,1,Gwyn,warrior,Goblin,attack,13,6,114,17,ongoing +2,2,Gwyn,warrior,Goblin,attack,13,4,110,4,ongoing +2,3,Gwyn,warrior,Goblin,attack,11,3,107,0,ongoing +2,4,Gwyn,warrior,Goblin,attack,11,2,105,0,win +3,1,Gwyn,rogue,Dragon,attack,2,17,83,118,ongoing +3,2,Gwyn,rogue,Dragon,attack,4,20,63,114,ongoing +3,3,Gwyn,rogue,Dragon,attack,4,19,44,110,ongoing +3,4,Gwyn,rogue,Dragon,attack,2,18,26,108,ongoing +3,5,Gwyn,rogue,Dragon,attack,5,17,9,0,win +4,1,Beorn,warrior,Dragon,attack,5,18,102,115,ongoing +4,2,Beorn,warrior,Dragon,attack,3,18,84,112,ongoing +4,3,Beorn,warrior,Dragon,attack,1,19,65,111,ongoing +4,4,Beorn,warrior,Dragon,attack,1,14,51,110,ongoing +4,5,Beorn,warrior,Dragon,potion,0,18,48,110,ongoing +4,6,Beorn,warrior,Dragon,attack,5,17,31,105,ongoing +4,7,Beorn,warrior,Dragon,attack,1,19,0,104,loss +5,1,Hale,rogue,Orc,attack,12,10,90,38,ongoing +5,2,Hale,rogue,Orc,attack,9,7,83,29,ongoing +5,3,Hale,rogue,Orc,attack,10,9,74,19,ongoing +5,4,Hale,rogue,Orc,attack,8,11,63,11,ongoing +5,5,Hale,rogue,Orc,attack,10,10,53,1,ongoing +5,6,Hale,rogue,Orc,potion,0,9,64,1,ongoing +5,7,Hale,rogue,Orc,attack,8,9,55,0,ongoing +5,8,Hale,rogue,Orc,attack,9,9,0,0,loss +6,1,Hale,warrior,Dragon,attack,2,15,105,118,ongoing +6,2,Hale,warrior,Dragon,potion,0,15,100,118,ongoing +6,3,Hale,warrior,Dragon,potion,0,17,102,118,ongoing +6,4,Hale,warrior,Dragon,attack,4,14,88,114,ongoing +6,5,Hale,warrior,Dragon,attack,1,16,72,113,ongoing +6,6,Hale,warrior,Dragon,attack,1,18,54,112,ongoing +6,7,Hale,warrior,Dragon,attack,2,16,38,110,ongoing +6,8,Hale,warrior,Dragon,attack,5,15,23,0,win +7,1,Eska,mage,Troll,attack,15,13,67,65,ongoing +7,2,Eska,mage,Troll,attack,11,13,54,54,ongoing +7,3,Eska,mage,Troll,attack,11,14,40,43,ongoing +7,4,Eska,mage,Troll,potion,0,12,45,43,ongoing +7,5,Eska,mage,Troll,attack,15,13,0,28,loss +8,1,Finn,rogue,Orc,attack,10,12,88,40,ongoing +8,2,Finn,rogue,Orc,attack,10,8,80,30,ongoing +8,3,Finn,rogue,Orc,attack,9,10,70,21,ongoing +8,4,Finn,rogue,Orc,attack,8,8,62,13,ongoing +8,5,Finn,rogue,Orc,potion,0,9,70,13,ongoing +8,6,Finn,rogue,Orc,attack,11,12,58,0,win +9,1,Ada,mage,Orc,attack,16,8,72,34,ongoing +9,2,Ada,mage,Orc,potion,0,11,80,34,ongoing +9,3,Ada,mage,Orc,attack,16,9,71,18,ongoing +9,4,Ada,mage,Orc,attack,17,8,63,1,ongoing +9,5,Ada,mage,Orc,potion,0,10,72,1,ongoing +9,6,Ada,mage,Orc,attack,17,8,64,0,ongoing +9,7,Ada,mage,Orc,attack,15,10,54,0,ongoing +9,8,Ada,mage,Orc,attack,16,13,0,0,loss +10,1,Beorn,warrior,Dragon,potion,0,14,119,120,ongoing +10,2,Beorn,warrior,Dragon,attack,5,18,101,115,ongoing +10,3,Beorn,warrior,Dragon,potion,0,15,99,115,ongoing +10,4,Beorn,warrior,Dragon,attack,1,19,80,114,ongoing +10,5,Beorn,warrior,Dragon,attack,4,19,0,110,loss +11,1,Dorn,mage,Orc,attack,17,13,67,33,ongoing +11,2,Dorn,mage,Orc,attack,13,8,59,20,ongoing +11,3,Dorn,mage,Orc,attack,16,12,47,0,win +12,1,Ada,mage,Orc,attack,12,8,72,38,ongoing +12,2,Ada,mage,Orc,attack,15,12,60,23,ongoing +12,3,Ada,mage,Orc,attack,16,12,48,7,ongoing +12,4,Ada,mage,Orc,potion,0,10,54,7,ongoing +12,5,Ada,mage,Orc,attack,13,13,41,0,win +13,1,Ada,warrior,Troll,attack,6,12,108,74,ongoing +13,2,Ada,warrior,Troll,attack,5,11,0,69,loss +14,1,Gwyn,rogue,Dragon,attack,7,20,80,113,ongoing +14,2,Gwyn,rogue,Dragon,attack,4,20,60,109,ongoing +14,3,Gwyn,rogue,Dragon,potion,0,17,59,109,ongoing +14,4,Gwyn,rogue,Dragon,attack,5,16,43,104,ongoing +14,5,Gwyn,rogue,Dragon,attack,3,19,24,101,ongoing +14,6,Gwyn,rogue,Dragon,attack,5,18,6,96,ongoing +14,7,Gwyn,rogue,Dragon,attack,4,15,0,92,ongoing +14,8,Gwyn,rogue,Dragon,attack,5,17,0,0,win +15,1,Ada,warrior,Troll,attack,7,11,109,73,ongoing +15,2,Ada,warrior,Troll,attack,6,14,0,67,loss +16,1,Finn,mage,Troll,attack,12,12,68,68,ongoing +16,2,Finn,mage,Troll,attack,11,17,0,57,loss +17,1,Ada,mage,Dragon,attack,9,16,64,111,ongoing +17,2,Ada,mage,Dragon,attack,8,16,0,103,loss +18,1,Dorn,warrior,Dragon,attack,5,15,105,115,ongoing +18,2,Dorn,warrior,Dragon,attack,5,19,86,110,ongoing +18,3,Dorn,warrior,Dragon,attack,2,16,70,108,ongoing +18,4,Dorn,warrior,Dragon,attack,5,15,55,103,ongoing +18,5,Dorn,warrior,Dragon,attack,5,18,37,0,win +19,1,Eska,mage,Orc,attack,14,9,71,36,ongoing +19,2,Eska,mage,Orc,attack,16,8,63,20,ongoing +19,3,Eska,mage,Orc,potion,0,8,65,20,ongoing +19,4,Eska,mage,Orc,attack,15,10,55,5,ongoing +19,5,Eska,mage,Orc,attack,14,12,43,0,ongoing +19,6,Eska,mage,Orc,attack,14,9,0,0,loss +20,1,Finn,mage,Orc,attack,12,11,69,38,ongoing +20,2,Finn,mage,Orc,potion,0,11,69,38,ongoing +20,3,Finn,mage,Orc,attack,12,13,56,26,ongoing +20,4,Finn,mage,Orc,attack,14,11,45,12,ongoing +20,5,Finn,mage,Orc,attack,12,12,33,0,ongoing +20,6,Finn,mage,Orc,attack,17,12,21,0,ongoing +20,7,Finn,mage,Orc,attack,15,13,8,0,ongoing +20,8,Finn,mage,Orc,attack,15,9,0,0,loss +21,1,Ada,mage,Goblin,attack,15,8,72,15,ongoing +21,2,Ada,mage,Goblin,potion,0,6,80,15,ongoing +21,3,Ada,mage,Goblin,attack,19,5,75,0,ongoing +21,4,Ada,mage,Goblin,attack,18,8,67,0,win +22,1,Beorn,warrior,Orc,attack,6,10,110,44,ongoing +22,2,Beorn,warrior,Orc,potion,0,9,115,44,ongoing +22,3,Beorn,warrior,Orc,attack,7,7,108,0,win +23,1,Celia,warrior,Dragon,potion,0,16,115,120,ongoing +23,2,Celia,warrior,Dragon,attack,1,19,96,119,ongoing +23,3,Celia,warrior,Dragon,potion,0,16,90,119,ongoing +23,4,Celia,warrior,Dragon,attack,1,19,71,118,ongoing +23,5,Celia,warrior,Dragon,attack,5,17,54,113,ongoing +23,6,Celia,warrior,Dragon,attack,1,16,38,112,ongoing +23,7,Celia,warrior,Dragon,potion,0,18,35,112,ongoing +23,8,Celia,warrior,Dragon,attack,2,19,0,110,loss +24,1,Gwyn,mage,Orc,attack,16,11,69,34,ongoing +24,2,Gwyn,mage,Orc,attack,13,13,56,21,ongoing +24,3,Gwyn,mage,Orc,attack,17,8,48,4,ongoing +24,4,Gwyn,mage,Orc,attack,15,8,40,0,win +25,1,Gwyn,warrior,Troll,attack,5,11,109,75,ongoing +25,2,Gwyn,warrior,Troll,potion,0,13,116,75,ongoing +25,3,Gwyn,warrior,Troll,attack,7,10,106,68,ongoing +25,4,Gwyn,warrior,Troll,attack,8,11,95,60,ongoing +25,5,Gwyn,warrior,Troll,attack,8,14,81,0,win +26,1,Gwyn,warrior,Troll,attack,8,15,105,72,ongoing +26,2,Gwyn,warrior,Troll,attack,4,11,94,68,ongoing +26,3,Gwyn,warrior,Troll,attack,7,12,82,61,ongoing +26,4,Gwyn,warrior,Troll,attack,8,11,71,53,ongoing +26,5,Gwyn,warrior,Troll,attack,9,15,56,44,ongoing +26,6,Gwyn,warrior,Troll,attack,9,11,45,35,ongoing +26,7,Gwyn,warrior,Troll,attack,5,12,0,30,loss +27,1,Celia,warrior,Dragon,attack,2,17,103,118,ongoing +27,2,Celia,warrior,Dragon,attack,3,19,84,115,ongoing +27,3,Celia,warrior,Dragon,attack,5,14,70,110,ongoing +27,4,Celia,warrior,Dragon,attack,2,17,53,108,ongoing +27,5,Celia,warrior,Dragon,potion,0,14,54,108,ongoing +27,6,Celia,warrior,Dragon,attack,2,14,40,106,ongoing +27,7,Celia,warrior,Dragon,attack,1,14,0,105,loss +28,1,Hale,mage,Troll,attack,14,13,67,66,ongoing +28,2,Hale,mage,Troll,attack,11,16,51,55,ongoing +28,3,Hale,mage,Troll,attack,12,16,35,43,ongoing +28,4,Hale,mage,Troll,potion,0,13,37,43,ongoing +28,5,Hale,mage,Troll,attack,10,13,24,33,ongoing +28,6,Hale,mage,Troll,attack,15,14,10,18,ongoing +28,7,Hale,mage,Troll,attack,12,16,0,6,ongoing +28,8,Hale,mage,Troll,attack,12,17,0,0,win +29,1,Celia,mage,Orc,potion,0,10,80,50,ongoing +29,2,Celia,mage,Orc,attack,13,10,70,0,win +30,1,Celia,warrior,Troll,attack,6,10,110,74,ongoing +30,2,Celia,warrior,Troll,attack,5,15,95,69,ongoing +30,3,Celia,warrior,Troll,potion,0,10,99,69,ongoing +30,4,Celia,warrior,Troll,attack,7,10,89,62,ongoing +30,5,Celia,warrior,Troll,attack,4,13,76,58,ongoing +30,6,Celia,warrior,Troll,attack,7,13,63,0,win +31,1,Finn,rogue,Dragon,attack,6,18,82,114,ongoing +31,2,Finn,rogue,Dragon,attack,7,15,67,0,win +32,1,Gwyn,warrior,Orc,attack,6,10,110,44,ongoing +32,2,Gwyn,warrior,Orc,attack,10,10,100,34,ongoing +32,3,Gwyn,warrior,Orc,attack,11,9,91,23,ongoing +32,4,Gwyn,warrior,Orc,attack,9,6,85,14,ongoing +32,5,Gwyn,warrior,Orc,attack,7,8,77,0,win +33,1,Finn,rogue,Orc,attack,12,12,88,38,ongoing +33,2,Finn,rogue,Orc,attack,10,7,81,28,ongoing +33,3,Finn,rogue,Orc,attack,13,12,69,0,win +34,1,Celia,mage,Goblin,attack,19,8,72,11,ongoing +34,2,Celia,mage,Goblin,attack,14,8,64,0,ongoing +34,3,Celia,mage,Goblin,attack,15,7,57,0,ongoing +34,4,Celia,mage,Goblin,potion,0,7,62,0,ongoing +34,5,Celia,mage,Goblin,attack,17,8,54,0,ongoing +34,6,Celia,mage,Goblin,attack,18,4,50,0,ongoing +34,7,Celia,mage,Goblin,attack,14,8,42,0,win +35,1,Ada,mage,Orc,attack,17,9,71,33,ongoing +35,2,Ada,mage,Orc,attack,14,12,59,19,ongoing +35,3,Ada,mage,Orc,attack,15,13,46,4,ongoing +35,4,Ada,mage,Orc,potion,0,8,58,4,ongoing +35,5,Ada,mage,Orc,attack,15,9,49,0,win +36,1,Dorn,rogue,Orc,attack,12,12,88,38,ongoing +36,2,Dorn,rogue,Orc,potion,0,9,92,38,ongoing +36,3,Dorn,rogue,Orc,attack,11,7,85,27,ongoing +36,4,Dorn,rogue,Orc,potion,0,8,96,27,ongoing +36,5,Dorn,rogue,Orc,attack,9,10,86,18,ongoing +36,6,Dorn,rogue,Orc,attack,11,7,79,7,ongoing +36,7,Dorn,rogue,Orc,attack,13,9,70,0,ongoing +36,8,Dorn,rogue,Orc,attack,8,8,62,0,win +37,1,Finn,mage,Orc,attack,13,10,70,37,ongoing +37,2,Finn,mage,Orc,attack,12,8,62,25,ongoing +37,3,Finn,mage,Orc,potion,0,8,65,25,ongoing +37,4,Finn,mage,Orc,attack,17,13,52,8,ongoing +37,5,Finn,mage,Orc,attack,15,10,42,0,ongoing +37,6,Finn,mage,Orc,attack,17,8,34,0,ongoing +37,7,Finn,mage,Orc,attack,13,13,21,0,ongoing +37,8,Finn,mage,Orc,attack,16,8,13,0,win +38,1,Hale,mage,Troll,attack,12,13,67,68,ongoing +38,2,Hale,mage,Troll,attack,15,14,0,53,loss +39,1,Hale,rogue,Orc,attack,11,10,90,39,ongoing +39,2,Hale,rogue,Orc,attack,10,12,78,29,ongoing +39,3,Hale,rogue,Orc,attack,8,11,67,0,win +40,1,Dorn,warrior,Goblin,attack,12,4,116,18,ongoing +40,2,Dorn,warrior,Goblin,potion,0,5,120,18,ongoing +40,3,Dorn,warrior,Goblin,attack,10,5,115,8,ongoing +40,4,Dorn,warrior,Goblin,attack,10,3,112,0,ongoing +40,5,Dorn,warrior,Goblin,attack,12,7,105,0,win +41,1,Finn,warrior,Orc,attack,10,10,110,40,ongoing +41,2,Finn,warrior,Orc,attack,11,10,100,0,win +42,1,Hale,warrior,Troll,attack,4,15,105,76,ongoing +42,2,Hale,warrior,Troll,attack,5,13,92,71,ongoing +42,3,Hale,warrior,Troll,attack,4,12,80,67,ongoing +42,4,Hale,warrior,Troll,attack,5,13,67,62,ongoing +42,5,Hale,warrior,Troll,potion,0,12,67,62,ongoing +42,6,Hale,warrior,Troll,attack,8,13,54,54,ongoing +42,7,Hale,warrior,Troll,attack,9,12,42,45,ongoing +42,8,Hale,warrior,Troll,attack,8,10,32,0,win diff --git a/data_recall_checkpoint/data/combat_log_fixture_variant.csv b/data_recall_checkpoint/data/combat_log_fixture_variant.csv new file mode 100644 index 0000000..8fb0559 --- /dev/null +++ b/data_recall_checkpoint/data/combat_log_fixture_variant.csv @@ -0,0 +1,210 @@ +battle_id,turn,hero_name,hero_class,monster,action,damage_dealt,damage_taken,hero_hp,monster_hp,result +1,1,Gwyn,rogue,Dragon,attack,5,15,85,115,ongoing +1,2,Gwyn,rogue,Dragon,attack,5,15,70,110,ongoing +1,3,Gwyn,rogue,Dragon,attack,6,17,53,0,win +2,1,Dorn,warrior,Dragon,attack,4,15,105,116,ongoing +2,2,Dorn,warrior,Dragon,attack,2,17,88,114,ongoing +2,3,Dorn,warrior,Dragon,potion,0,17,91,114,ongoing +2,4,Dorn,warrior,Dragon,potion,0,15,90,114,ongoing +2,5,Dorn,warrior,Dragon,attack,3,16,74,111,ongoing +2,6,Dorn,warrior,Dragon,attack,1,19,55,110,ongoing +2,7,Dorn,warrior,Dragon,attack,1,17,38,109,ongoing +2,8,Dorn,warrior,Dragon,attack,5,14,24,0,win +3,1,Celia,warrior,Orc,attack,11,11,109,39,ongoing +3,2,Celia,warrior,Orc,attack,7,8,101,32,ongoing +3,3,Celia,warrior,Orc,attack,11,8,93,0,win +4,1,Dorn,mage,Troll,attack,11,14,66,69,ongoing +4,2,Dorn,mage,Troll,attack,14,13,53,55,ongoing +4,3,Dorn,mage,Troll,attack,13,16,37,42,ongoing +4,4,Dorn,mage,Troll,attack,12,14,23,30,ongoing +4,5,Dorn,mage,Troll,attack,13,13,10,0,win +5,1,Dorn,rogue,Troll,attack,10,13,87,70,ongoing +5,2,Dorn,rogue,Troll,attack,6,11,0,64,loss +6,1,Gwyn,rogue,Troll,attack,10,16,84,70,ongoing +6,2,Gwyn,rogue,Troll,attack,11,13,71,59,ongoing +6,3,Gwyn,rogue,Troll,attack,11,12,59,48,ongoing +6,4,Gwyn,rogue,Troll,attack,11,15,44,37,ongoing +6,5,Gwyn,rogue,Troll,attack,9,11,33,28,ongoing +6,6,Gwyn,rogue,Troll,attack,7,13,20,21,ongoing +6,7,Gwyn,rogue,Troll,attack,11,16,4,0,win +7,1,Hale,mage,Orc,attack,13,13,67,37,ongoing +7,2,Hale,mage,Orc,attack,12,8,59,0,win +8,1,Beorn,mage,Dragon,attack,11,16,64,109,ongoing +8,2,Beorn,mage,Dragon,attack,9,16,48,100,ongoing +8,3,Beorn,mage,Dragon,attack,10,18,30,0,win +9,1,Gwyn,mage,Orc,attack,15,12,68,35,ongoing +9,2,Gwyn,mage,Orc,attack,13,11,57,22,ongoing +9,3,Gwyn,mage,Orc,attack,15,12,0,7,loss +10,1,Dorn,warrior,Orc,attack,6,11,109,44,ongoing +10,2,Dorn,warrior,Orc,attack,11,11,98,33,ongoing +10,3,Dorn,warrior,Orc,attack,11,9,89,22,ongoing +10,4,Dorn,warrior,Orc,attack,9,10,79,13,ongoing +10,5,Dorn,warrior,Orc,attack,9,8,71,0,win +11,1,Eska,warrior,Orc,attack,11,10,110,39,ongoing +11,2,Eska,warrior,Orc,attack,9,10,100,30,ongoing +11,3,Eska,warrior,Orc,attack,8,7,93,22,ongoing +11,4,Eska,warrior,Orc,attack,8,11,82,14,ongoing +11,5,Eska,warrior,Orc,attack,6,10,72,8,ongoing +11,6,Eska,warrior,Orc,attack,11,8,64,0,win +12,1,Dorn,mage,Dragon,potion,0,20,78,120,ongoing +12,2,Dorn,mage,Dragon,attack,9,18,60,111,ongoing +12,3,Dorn,mage,Dragon,attack,8,17,43,0,win +13,1,Dorn,warrior,Goblin,attack,9,3,117,21,ongoing +13,2,Dorn,warrior,Goblin,attack,9,5,112,12,ongoing +13,3,Dorn,warrior,Goblin,potion,0,2,120,12,ongoing +13,4,Dorn,warrior,Goblin,attack,10,4,116,2,ongoing +13,5,Dorn,warrior,Goblin,attack,9,7,109,0,ongoing +13,6,Dorn,warrior,Goblin,attack,11,2,107,0,ongoing +13,7,Dorn,warrior,Goblin,attack,8,5,0,0,loss +14,1,Eska,rogue,Dragon,potion,0,15,100,120,ongoing +14,2,Eska,rogue,Dragon,attack,4,20,80,116,ongoing +14,3,Eska,rogue,Dragon,attack,4,17,63,112,ongoing +14,4,Eska,rogue,Dragon,attack,4,16,47,108,ongoing +14,5,Eska,rogue,Dragon,attack,4,20,27,104,ongoing +14,6,Eska,rogue,Dragon,attack,7,16,11,0,win +15,1,Gwyn,rogue,Troll,attack,8,12,88,72,ongoing +15,2,Gwyn,rogue,Troll,attack,11,15,73,61,ongoing +15,3,Gwyn,rogue,Troll,attack,11,12,61,50,ongoing +15,4,Gwyn,rogue,Troll,attack,10,11,50,40,ongoing +15,5,Gwyn,rogue,Troll,attack,11,12,38,29,ongoing +15,6,Gwyn,rogue,Troll,attack,10,16,22,19,ongoing +15,7,Gwyn,rogue,Troll,attack,11,15,7,0,win +16,1,Dorn,mage,Troll,attack,13,16,64,67,ongoing +16,2,Dorn,mage,Troll,attack,13,14,50,54,ongoing +16,3,Dorn,mage,Troll,attack,13,16,34,41,ongoing +16,4,Dorn,mage,Troll,attack,14,13,21,27,ongoing +16,5,Dorn,mage,Troll,potion,0,12,24,27,ongoing +16,6,Dorn,mage,Troll,attack,11,17,7,0,win +17,1,Dorn,rogue,Orc,attack,12,9,91,38,ongoing +17,2,Dorn,rogue,Orc,attack,8,7,84,30,ongoing +17,3,Dorn,rogue,Orc,attack,8,10,74,22,ongoing +17,4,Dorn,rogue,Orc,potion,0,7,82,22,ongoing +17,5,Dorn,rogue,Orc,attack,12,9,73,10,ongoing +17,6,Dorn,rogue,Orc,attack,10,7,66,0,ongoing +17,7,Dorn,rogue,Orc,attack,13,9,0,0,loss +18,1,Dorn,warrior,Orc,attack,7,10,110,43,ongoing +18,2,Dorn,warrior,Orc,attack,7,7,103,36,ongoing +18,3,Dorn,warrior,Orc,attack,11,9,94,25,ongoing +18,4,Dorn,warrior,Orc,potion,0,7,97,25,ongoing +18,5,Dorn,warrior,Orc,attack,6,7,90,0,win +19,1,Dorn,mage,Dragon,attack,8,18,62,112,ongoing +19,2,Dorn,mage,Dragon,attack,8,17,45,104,ongoing +19,3,Dorn,mage,Dragon,potion,0,18,46,104,ongoing +19,4,Dorn,mage,Dragon,attack,9,16,30,95,ongoing +19,5,Dorn,mage,Dragon,attack,10,20,10,85,ongoing +19,6,Dorn,mage,Dragon,attack,10,19,0,0,win +20,1,Hale,mage,Orc,attack,14,9,71,36,ongoing +20,2,Hale,mage,Orc,attack,14,8,63,22,ongoing +20,3,Hale,mage,Orc,attack,16,8,0,6,loss +21,1,Eska,mage,Goblin,attack,15,8,72,15,ongoing +21,2,Eska,mage,Goblin,attack,14,4,68,1,ongoing +21,3,Eska,mage,Goblin,attack,16,8,60,0,ongoing +21,4,Eska,mage,Goblin,attack,14,5,0,0,loss +22,1,Beorn,rogue,Troll,attack,9,15,85,71,ongoing +22,2,Beorn,rogue,Troll,attack,10,11,74,61,ongoing +22,3,Beorn,rogue,Troll,attack,11,13,61,50,ongoing +22,4,Beorn,rogue,Troll,potion,0,15,62,50,ongoing +22,5,Beorn,rogue,Troll,attack,7,11,51,43,ongoing +22,6,Beorn,rogue,Troll,attack,7,11,40,36,ongoing +22,7,Beorn,rogue,Troll,attack,10,13,0,26,loss +23,1,Gwyn,warrior,Goblin,attack,9,6,114,21,ongoing +23,2,Gwyn,warrior,Goblin,attack,10,3,111,11,ongoing +23,3,Gwyn,warrior,Goblin,attack,8,3,108,3,ongoing +23,4,Gwyn,warrior,Goblin,attack,12,2,106,0,ongoing +23,5,Gwyn,warrior,Goblin,attack,10,7,99,0,ongoing +23,6,Gwyn,warrior,Goblin,attack,12,6,93,0,ongoing +23,7,Gwyn,warrior,Goblin,attack,8,4,89,0,ongoing +23,8,Gwyn,warrior,Goblin,attack,13,5,0,0,loss +24,1,Beorn,mage,Dragon,attack,8,21,59,112,ongoing +24,2,Beorn,mage,Dragon,attack,7,21,38,0,win +25,1,Ada,mage,Goblin,attack,18,5,75,12,ongoing +25,2,Ada,mage,Goblin,attack,16,6,69,0,ongoing +25,3,Ada,mage,Goblin,attack,17,8,61,0,ongoing +25,4,Ada,mage,Goblin,attack,18,7,54,0,ongoing +25,5,Ada,mage,Goblin,potion,0,4,62,0,ongoing +25,6,Ada,mage,Goblin,attack,16,5,57,0,win +26,1,Finn,mage,Dragon,attack,7,21,59,113,ongoing +26,2,Finn,mage,Dragon,attack,11,19,40,102,ongoing +26,3,Finn,mage,Dragon,attack,8,21,19,94,ongoing +26,4,Finn,mage,Dragon,attack,9,21,0,85,ongoing +26,5,Finn,mage,Dragon,attack,11,20,0,74,ongoing +26,6,Finn,mage,Dragon,attack,6,18,0,0,win +27,1,Hale,warrior,Goblin,attack,13,3,117,17,ongoing +27,2,Hale,warrior,Goblin,attack,11,6,111,6,ongoing +27,3,Hale,warrior,Goblin,attack,13,5,106,0,ongoing +27,4,Hale,warrior,Goblin,potion,0,3,120,0,ongoing +27,5,Hale,warrior,Goblin,attack,12,6,114,0,ongoing +27,6,Hale,warrior,Goblin,attack,12,6,108,0,ongoing +27,7,Hale,warrior,Goblin,attack,9,6,102,0,ongoing +27,8,Hale,warrior,Goblin,attack,11,5,97,0,win +28,1,Gwyn,warrior,Goblin,attack,11,2,118,19,ongoing +28,2,Gwyn,warrior,Goblin,attack,13,5,113,6,ongoing +28,3,Gwyn,warrior,Goblin,attack,10,2,111,0,ongoing +28,4,Gwyn,warrior,Goblin,attack,11,7,104,0,ongoing +28,5,Gwyn,warrior,Goblin,attack,13,5,0,0,loss +29,1,Beorn,rogue,Dragon,potion,0,19,100,120,ongoing +29,2,Beorn,rogue,Dragon,attack,3,18,82,117,ongoing +29,3,Beorn,rogue,Dragon,attack,7,20,62,0,win +30,1,Beorn,rogue,Dragon,attack,3,18,82,117,ongoing +30,2,Beorn,rogue,Dragon,attack,6,17,65,111,ongoing +30,3,Beorn,rogue,Dragon,attack,7,16,49,104,ongoing +30,4,Beorn,rogue,Dragon,attack,3,17,32,101,ongoing +30,5,Beorn,rogue,Dragon,attack,4,16,16,97,ongoing +30,6,Beorn,rogue,Dragon,attack,5,16,0,92,ongoing +30,7,Beorn,rogue,Dragon,attack,6,16,0,86,ongoing +30,8,Beorn,rogue,Dragon,attack,6,18,0,0,win +31,1,Beorn,mage,Troll,attack,10,12,68,70,ongoing +31,2,Beorn,mage,Troll,attack,15,14,54,55,ongoing +31,3,Beorn,mage,Troll,attack,12,13,0,43,loss +32,1,Dorn,warrior,Troll,attack,6,10,110,74,ongoing +32,2,Dorn,warrior,Troll,attack,8,11,99,66,ongoing +32,3,Dorn,warrior,Troll,attack,9,10,0,57,loss +33,1,Dorn,rogue,Dragon,potion,0,18,100,120,ongoing +33,2,Dorn,rogue,Dragon,attack,6,15,85,114,ongoing +33,3,Dorn,rogue,Dragon,potion,0,18,85,114,ongoing +33,4,Dorn,rogue,Dragon,attack,6,16,69,108,ongoing +33,5,Dorn,rogue,Dragon,attack,6,18,51,0,win +34,1,Eska,mage,Goblin,attack,18,9,71,12,ongoing +34,2,Eska,mage,Goblin,attack,16,9,62,0,ongoing +34,3,Eska,mage,Goblin,attack,17,6,56,0,ongoing +34,4,Eska,mage,Goblin,attack,15,9,0,0,loss +35,1,Ada,warrior,Goblin,potion,0,2,120,30,ongoing +35,2,Ada,warrior,Goblin,attack,9,7,113,21,ongoing +35,3,Ada,warrior,Goblin,attack,9,5,108,12,ongoing +35,4,Ada,warrior,Goblin,potion,0,2,119,12,ongoing +35,5,Ada,warrior,Goblin,attack,9,7,112,3,ongoing +35,6,Ada,warrior,Goblin,attack,11,7,105,0,ongoing +35,7,Ada,warrior,Goblin,attack,9,6,99,0,win +36,1,Hale,warrior,Goblin,attack,11,6,114,19,ongoing +36,2,Hale,warrior,Goblin,attack,9,5,0,10,loss +37,1,Eska,warrior,Goblin,attack,8,6,114,22,ongoing +37,2,Eska,warrior,Goblin,attack,11,5,109,11,ongoing +37,3,Eska,warrior,Goblin,potion,0,3,120,11,ongoing +37,4,Eska,warrior,Goblin,attack,12,6,114,0,win +38,1,Eska,warrior,Dragon,potion,0,14,120,120,ongoing +38,2,Eska,warrior,Dragon,attack,5,17,103,0,win +39,1,Beorn,mage,Goblin,attack,17,9,71,13,ongoing +39,2,Beorn,mage,Goblin,attack,14,4,67,0,ongoing +39,3,Beorn,mage,Goblin,attack,15,9,58,0,ongoing +39,4,Beorn,mage,Goblin,potion,0,5,64,0,ongoing +39,5,Beorn,mage,Goblin,attack,17,6,0,0,loss +40,1,Eska,mage,Dragon,attack,9,19,61,111,ongoing +40,2,Eska,mage,Dragon,attack,9,20,41,102,ongoing +40,3,Eska,mage,Dragon,attack,11,18,23,91,ongoing +40,4,Eska,mage,Dragon,attack,9,19,4,82,ongoing +40,5,Eska,mage,Dragon,attack,10,17,0,72,ongoing +40,6,Eska,mage,Dragon,attack,10,18,0,62,ongoing +40,7,Eska,mage,Dragon,attack,7,20,0,0,win +41,1,Finn,warrior,Goblin,attack,12,6,114,18,ongoing +41,2,Finn,warrior,Goblin,potion,0,6,120,18,ongoing +41,3,Finn,warrior,Goblin,attack,11,4,116,7,ongoing +41,4,Finn,warrior,Goblin,attack,8,4,112,0,ongoing +41,5,Finn,warrior,Goblin,attack,12,7,105,0,ongoing +41,6,Finn,warrior,Goblin,attack,12,6,99,0,ongoing +41,7,Finn,warrior,Goblin,potion,0,2,115,0,ongoing +41,8,Finn,warrior,Goblin,attack,9,7,0,0,loss +42,1,Celia,warrior,Troll,attack,7,14,106,73,ongoing +42,2,Celia,warrior,Troll,attack,9,10,96,64,ongoing +42,3,Celia,warrior,Troll,attack,6,15,81,58,ongoing +42,4,Celia,warrior,Troll,attack,4,12,69,54,ongoing +42,5,Celia,warrior,Troll,attack,6,14,55,0,win diff --git a/data_recall_checkpoint/task.py b/data_recall_checkpoint/task.py new file mode 100644 index 0000000..786901a --- /dev/null +++ b/data_recall_checkpoint/task.py @@ -0,0 +1,113 @@ +"""Data Recall Checkpoint: Combat Log Analytics. + +You learned NumPy and Pandas back in Level 1. Since then the RPG gained +validation (Pydantic) and persistence (the repository pattern) in Level 3 — +including a real, validated combat log. Before moving into Level 4's +interfaces and tooling, use those data skills again on data your application +now knows how to store. + +See README.md for the full brief, a short cheat-sheet, and the analytical +questions this checkpoint asks you to answer. + +Check: uv run python check.py +""" +from pathlib import Path + +import numpy as np +import pandas as pd + +DATA_DIR = Path(__file__).parent / "data" + + +def load_combat_log(path: Path) -> pd.DataFrame: + """Load a combat log CSV into a DataFrame. + + The CSV has one row per turn (see README for the exact column list). + Return the full DataFrame, unfiltered — separating finished battles from + in-progress ("ongoing") turns is the job of the functions below, not this + one. + + TODO: + - read the CSV at `path` with pandas + - return the resulting DataFrame + """ + raise NotImplementedError + + +def win_rate_by_class(df: pd.DataFrame) -> dict[str, float]: + """Hero win rate per hero_class, using only finished battles. + + A battle is one `battle_id`; most battles span several turn rows, and + only the LAST row of a battle has a terminal `result` ("win" or "loss") — + every earlier row is "ongoing". Win rate must be computed over terminal + rows only, grouped by `hero_class`. + + Returns a dict like {"warrior": 0.59, "mage": 0.56, "rogue": 0.78} — one + entry per hero_class that has at least one finished battle. + + TODO: + - keep only rows where result != "ongoing" + - group the result by hero_class using pandas' groupby + - for each group, compute the fraction of rows where result == "win" + - return the per-class win rates as a plain dict + """ + raise NotImplementedError + + +def damage_stats(df: pd.DataFrame) -> dict[str, float | int]: + """NumPy statistics over attack damage. + + `action` is either "attack" or "potion". A potion turn deals no damage + by definition (damage_dealt is 0 because the hero healed instead of + attacking, not because the attack was weak) — including those zeros + would understate how hard hero attacks actually hit. Only "attack" rows + belong in this analysis. + + Using the `damage_dealt` values from attack rows as a NumPy array, + return a dict with exactly these keys: + - "mean": the mean damage + - "std": the population standard deviation (i.e. plain `ndarray.std()` — + no sample correction) + - "p75": the 75th percentile (`np.percentile(arr, 75)`, linear + interpolation — the default) + - "high_damage_count": how many attack rows dealt more damage than p75 + + TODO: + - filter to action == "attack" + - convert the damage_dealt column to a NumPy array + - compute mean, std, and the 75th percentile with NumPy + - build a boolean mask for damage > p75 and count it + - return the four values in a dict + """ + raise NotImplementedError + + +def rank_monsters_by_difficulty(df: pd.DataFrame) -> list[str]: + """Rank monsters from hardest to easiest, by hero win rate. + + "Difficulty" here is only this one operational metric: the monster the + hero beats LEAST often (lowest hero win rate, over finished battles) is + ranked hardest. This is not a universal definition of difficulty — it + ignores average battle length, damage exchanged, hero class matchups, + and everything else. It is simply what this exercise asks you to + compute. + + Return monster names ordered from hardest (lowest win rate) to easiest + (highest win rate). + + TODO: + - keep only finished battles + - group by monster and compute hero win rate per monster (same idea as + win_rate_by_class, just grouped on a different column) + - sort the monsters by win rate, ascending + - return the monster names as a list, in that order + """ + raise NotImplementedError + + +if __name__ == "__main__": + df = load_combat_log(DATA_DIR / "combat_log_fixture.csv") + print(f"Loaded {len(df)} rows.") + print("Win rate by class:", win_rate_by_class(df)) + print("Damage stats:", damage_stats(df)) + print("Monsters, hardest to easiest:", rank_monsters_by_difficulty(df)) From ec9697b9acccee2e78a108961ec33e5d6f1161b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Maj?= Date: Fri, 2 Oct 2026 11:30:24 +0200 Subject: [PATCH 2/2] docs(course): integrate data checkpoint into course flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - COURSE_MAP.md / README.md: document the new mandatory checkpoint between Level 3 and Level 4. No link to a Final Analytics Epilogue — that doesn't exist yet; README only notes in prose that data skills get tied together again later, after Level 7. - tools/course_status.py: new print_checkpoint() helper (the checkpoint is flat — one README/task.py/check.py, no missions/projects subdirectory — so it doesn't fit print_level()'s path convention) wired in between Level 3 and Level 4. Reuses the existing "projects" progress bucket so completion is tracked the same way other mandatory units are; existing levels' print_level() calls and path semantics are untouched. - tools/author_check.py: new check_data_checkpoint() verifies the checkpoint's required files and check.py hygiene, and it's added to the existing COURSE_MAP.md/README.md documentation-coverage sweep. Verified end-to-end: course_status.py correctly shows the checkpoint between Level 3 and Level 4, correctly resolves "next up" to it once L1-L3 are complete, and correctly flips to [✓] after a real passing check.py run (confirmed via a temporary reference solution, then reverted to scaffold). author_check.py passes with the new checks included (737 checks, 0 errors). No changes to any level_1...level_7 file. --- COURSE_MAP.md | 12 ++++++++++++ README.md | 6 ++++++ tools/author_check.py | 43 ++++++++++++++++++++++++++++++++++++++++++ tools/course_status.py | 32 +++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+) diff --git a/COURSE_MAP.md b/COURSE_MAP.md index d58db03..68b4ffc 100644 --- a/COURSE_MAP.md +++ b/COURSE_MAP.md @@ -156,6 +156,18 @@ External data is untrusted. Validate at boundaries, isolate persistence from dom --- +## Data Recall Checkpoint + +**Prerequisite:** Level 3 complete + +You learned NumPy and Pandas in Level 1. The RPG now has a real, validated +combat log (Level 3) — use those data skills again before moving on. +No new concepts, 30–60 minutes. + +**Checkpoint:** [Combat Log Analytics](data_recall_checkpoint/README.md) + +--- + ## Level 4: Interfaces and Reports **Prerequisite:** Level 3 complete diff --git a/README.md b/README.md index a13eff2..18b3e88 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,9 @@ level_3_validation_and_persistence/ ← Pydantic, repository pattern, SQLite missions/ ← 7 missions projects/ ← P01 Validated Bestiary · P02 Save Slot Manager · P03 boss fight +data_recall_checkpoint/ ← mandatory checkpoint: NumPy/Pandas recall on the real combat log + (no new concepts — 30-60 min, see COURSE_MAP.md) + level_4_interfaces/ ← CLI (Typer), logging, Rich output, reports, entry points starter_verbose_rpg/ ← the interface-smelly app you will improve missions/ ← 6 missions @@ -97,6 +100,9 @@ level_7_concurrency_and_background_work/ ← asyncio, job_id, ThreadPoolExecuto Each checkpoint project (`P01`, `P02`) is a standalone exercise that exercises the level's concepts. The boss fight (`P03`) is a full-size challenge that combines everything from the level. +The `data_recall_checkpoint/` between Level 3 and Level 4 is mandatory, not optional — the course +later ties data skills back together once more, after Level 7. + ## Note on global commands This repository contains intentionally incomplete starter files. Running diff --git a/tools/author_check.py b/tools/author_check.py index 924f94a..7d118f7 100644 --- a/tools/author_check.py +++ b/tools/author_check.py @@ -16,6 +16,7 @@ LEVEL1_ROOT = REPO_ROOT / "level_1_python_basics" LEVEL2_ROOT = REPO_ROOT / "level_2_oop_and_design" LEVEL3_ROOT = REPO_ROOT / "level_3_validation_and_persistence" +DATA_CHECKPOINT_ROOT = REPO_ROOT / "data_recall_checkpoint" LEVEL4_ROOT = REPO_ROOT / "level_4_interfaces" LEVEL5_ROOT = REPO_ROOT / "level_5_maintainability" LEVEL6_ROOT = REPO_ROOT / "level_6_api" @@ -210,6 +211,44 @@ def check_folder_structure( f"MISSING: {label}/projects/{project_id}/{fname}") +def check_data_checkpoint(root: Path) -> None: + """Verify the (flat, non-mission/project-shaped) Data Recall Checkpoint. + + It has its own README/task.py/check.py/data files directly under its + root — not nested under missions/ or projects/ like a level — so it + doesn't fit check_folder_structure()'s convention. + """ + for fname in ("README.md", "task.py", "check.py"): + fpath = root / fname + check( + fpath.exists(), + f"data_recall_checkpoint/{fname}", + f"MISSING: data_recall_checkpoint/{fname}", + ) + for fname in ("combat_log_fixture.csv", "combat_log_fixture_variant.csv"): + fpath = root / "data" / fname + check( + fpath.exists(), + f"data_recall_checkpoint/data/{fname}", + f"MISSING: data_recall_checkpoint/data/{fname}", + ) + + check_py = root / "check.py" + if check_py.exists(): + text = check_py.read_text() + rel = check_py.relative_to(REPO_ROOT) + check( + "raise SystemExit(1)" in text, + f"{rel}: has SystemExit(1)", + f"{rel}: MISSING raise SystemExit(1) in except block", + ) + check( + '"uv", "run", "python"' not in text, + f"{rel}: no uv-run-python subprocess", + f"{rel}: uses ['uv','run','python',...] — change to sys.executable", + ) + + def check_hygiene(level_root: Path, missions: list[str], projects: list[str]) -> None: all_checks = ( [level_root / "missions" / m / "check.py" for m in missions] @@ -325,6 +364,9 @@ def check_dependencies() -> None: print("Checking Level 3 folder structure…") check_folder_structure(LEVEL3_ROOT, L3_MISSIONS, L3_PROJECTS, L3_MISSION_FILE_EXCEPTIONS, "level_3") +print("Checking Data Recall Checkpoint structure…") +check_data_checkpoint(DATA_CHECKPOINT_ROOT) + print("Checking Level 4 folder structure…") check_folder_structure(LEVEL4_ROOT, L4_MISSIONS, L4_PROJECTS, L4_MISSION_FILE_EXCEPTIONS, "level_4") @@ -394,6 +436,7 @@ def check_dependencies() -> None: (LEVEL1_ROOT, "level_1_python_basics", "Level 1"), (LEVEL2_ROOT, "level_2_oop_and_design", "Level 2"), (LEVEL3_ROOT, "level_3_validation_and_persistence", "Level 3"), + (DATA_CHECKPOINT_ROOT, "data_recall_checkpoint", "Data Recall Checkpoint"), (LEVEL4_ROOT, "level_4_interfaces", "Level 4"), (LEVEL5_ROOT, "level_5_maintainability", "Level 5"), (LEVEL6_ROOT, "level_6_api", "Level 6"), diff --git a/tools/course_status.py b/tools/course_status.py index 503ba5c..dc56a8a 100644 --- a/tools/course_status.py +++ b/tools/course_status.py @@ -5,11 +5,13 @@ LEVEL1_ROOT = REPO_ROOT / "level_1_python_basics" LEVEL2_ROOT = REPO_ROOT / "level_2_oop_and_design" LEVEL3_ROOT = REPO_ROOT / "level_3_validation_and_persistence" +DATA_CHECKPOINT_ROOT = REPO_ROOT / "data_recall_checkpoint" LEVEL4_ROOT = REPO_ROOT / "level_4_interfaces" LEVEL1_PROGRESS_FILE = LEVEL1_ROOT / ".progress" LEVEL2_PROGRESS_FILE = LEVEL2_ROOT / ".progress" LEVEL3_PROGRESS_FILE = LEVEL3_ROOT / ".progress" +DATA_CHECKPOINT_PROGRESS_FILE = DATA_CHECKPOINT_ROOT / ".progress" LEVEL4_PROGRESS_FILE = LEVEL4_ROOT / ".progress" LEVEL5_ROOT = REPO_ROOT / "level_5_maintainability" @@ -348,10 +350,38 @@ def print_level( next_up_ref[0] = f"{level_prefix}/projects/{project_id}/README.md" +def print_checkpoint( + name: str, + progress: dict, + checkpoint_id: str, + readme_path: str, + next_up_ref: list, +) -> None: + """Print a single, flat (non-mission/project) course checkpoint. + + Unlike a level, a checkpoint has no missions/projects subdirectory — + it's a single README/task.py/check.py unit, so it doesn't fit + print_level()'s path convention. Reuses the same "projects" progress + bucket so a checkpoint's completion is tracked the same way existing + mandatory units are. + """ + print() + print(name) + print("─" * 40) + print() + + status = progress.get("projects", {}).get(checkpoint_id, "not_started") + symbol = SYMBOLS[status] + print(f" [{symbol}] {name}") + if status != "complete" and not next_up_ref[0]: + next_up_ref[0] = readme_path + + def main() -> None: l1_progress = load_progress(LEVEL1_PROGRESS_FILE) l2_progress = load_progress(LEVEL2_PROGRESS_FILE) l3_progress = load_progress(LEVEL3_PROGRESS_FILE) + data_checkpoint_progress = load_progress(DATA_CHECKPOINT_PROGRESS_FILE) l4_progress = load_progress(LEVEL4_PROGRESS_FILE) l5_progress = load_progress(LEVEL5_PROGRESS_FILE) l6_progress = load_progress(LEVEL6_PROGRESS_FILE) @@ -369,6 +399,8 @@ def main() -> None: "level_2_oop_and_design", next_up) print_level("Level 3: Validation and Persistence", LEVEL3_WORLDS, l3_progress, "level_3_validation_and_persistence", next_up) + print_checkpoint("Data Recall Checkpoint", data_checkpoint_progress, + "data_recall_checkpoint", "data_recall_checkpoint/README.md", next_up) print_level("Level 4: Interfaces and Reports", LEVEL4_WORLDS, l4_progress, "level_4_interfaces", next_up) print_level("Level 5: Production Quality and Maintainability", LEVEL5_WORLDS, l5_progress,