Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions COURSE_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
96 changes: 96 additions & 0 deletions data_recall_checkpoint/README.md
Original file line number Diff line number Diff line change
@@ -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)
226 changes: 226 additions & 0 deletions data_recall_checkpoint/check.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading