Dracufeuer commited on
Commit
c047289
·
verified ·
1 Parent(s): 31c3d08

Sync demo recommendation hardening

Browse files
.gitignore CHANGED
@@ -35,6 +35,7 @@ data/*.duckdb.tmp
35
 
36
  outputs/
37
  runs/
 
38
  *.log
39
 
40
  .DS_Store
 
35
 
36
  outputs/
37
  runs/
38
+ submission_evidence/
39
  *.log
40
 
41
  .DS_Store
PLAN.md CHANGED
@@ -111,6 +111,7 @@ All times are `America/Los_Angeles` / PDT unless noted.
111
  - 2026-06-14 01:13: updated this plan with the execution timeline. Current required finish checks are lint, tests, Gradio import smoke, commit/push, Space upload, Space HTTP 200, Modal UI HTTP 200, `modal-smoke`, and `modal-ask`.
112
  - 2026-06-14 01:21: checked the latest hackathon requirements again and added `SUBMISSION.md` with required links, final checklist, demo video script, social post draft, and verification commands.
113
  - 2026-06-14 01:27: addressed the submission-readiness review by adding hero-name parsing, making Draft Lab demoable, adding tests, preparing a dataset card, declaring Apache-2.0 licensing, and aligning the Modal dependency version.
 
114
 
115
  ## Adapter Eval Notes
116
 
 
111
  - 2026-06-14 01:13: updated this plan with the execution timeline. Current required finish checks are lint, tests, Gradio import smoke, commit/push, Space upload, Space HTTP 200, Modal UI HTTP 200, `modal-smoke`, and `modal-ask`.
112
  - 2026-06-14 01:21: checked the latest hackathon requirements again and added `SUBMISSION.md` with required links, final checklist, demo video script, social post draft, and verification commands.
113
  - 2026-06-14 01:27: addressed the submission-readiness review by adding hero-name parsing, making Draft Lab demoable, adding tests, preparing a dataset card, declaring Apache-2.0 licensing, and aligning the Modal dependency version.
114
+ - 2026-06-14 02:36: continued demo-readiness hardening by adding role-aware recommendation filtering, Bayesian win-rate shrinkage for low samples, pre-game item timing labels, and a repeatable public Space API check script.
115
 
116
  ## Adapter Eval Notes
117
 
SUBMISSION.md CHANGED
@@ -79,6 +79,7 @@ uv run pytest -q
79
  uv run python -c "from app import demo; print(type(demo).__name__, len(demo.blocks), len(demo.fns))"
80
  uv run dota2tuned modal-smoke
81
  uv run dota2tuned modal-ask "Suggest one mid hero against Phantom Assassin and Witch Doctor. Include one caveat." --context "Use only grounded advice. Mention that sample sizes and patch context matter." --max-new-tokens 160
 
82
  curl -L -sS -o /dev/null -w '%{http_code}\n' https://build-small-hackathon-dota2tuned.hf.space
83
  curl -sS -o /dev/null -w '%{http_code}\n' https://dracufeuer--dota2tuned-ui.modal.run
84
  git status --short --branch
 
79
  uv run python -c "from app import demo; print(type(demo).__name__, len(demo.blocks), len(demo.fns))"
80
  uv run dota2tuned modal-smoke
81
  uv run dota2tuned modal-ask "Suggest one mid hero against Phantom Assassin and Witch Doctor. Include one caveat." --context "Use only grounded advice. Mention that sample sizes and patch context matter." --max-new-tokens 160
82
+ uv run python scripts/check_public_space.py
83
  curl -L -sS -o /dev/null -w '%{http_code}\n' https://build-small-hackathon-dota2tuned.hf.space
84
  curl -sS -o /dev/null -w '%{http_code}\n' https://dracufeuer--dota2tuned-ui.modal.run
85
  git status --short --branch
scripts/check_public_space.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from gradio_client import Client
10
+
11
+ DEFAULT_SPACE = "https://build-small-hackathon-dota2tuned.hf.space"
12
+
13
+
14
+ CHECKS: list[tuple[str, tuple[Any, ...]]] = [
15
+ ("/draft_coach", ("", "Phantom Assassin, Witch Doctor", "", "mid", "pro")),
16
+ ("/hero_meta", ("current pro meta",)),
17
+ (
18
+ "/match_predictor",
19
+ ("Anti-Mage, Axe, Bane, Lina, Crystal Maiden", "Pudge, Invoker, Drow Ranger, Lion, Sven"),
20
+ ),
21
+ ("/hero_builds", ("Anti-Mage",)),
22
+ ("/draft_lab", ("Phantom Assassin, Witch Doctor", "mid", "Tiny scout card")),
23
+ ("/data_status", ()),
24
+ (
25
+ "/tuned_model",
26
+ (
27
+ "Suggest one mid hero against Phantom Assassin and Witch Doctor, "
28
+ "and include one caveat.",
29
+ "",
30
+ 160,
31
+ ),
32
+ ),
33
+ ]
34
+
35
+
36
+ def run_checks(space_url: str) -> dict[str, Any]:
37
+ client = Client(space_url)
38
+ results = []
39
+ for endpoint, args in CHECKS:
40
+ try:
41
+ result = client.predict(*args, api_name=endpoint)
42
+ status = "ok"
43
+ except Exception as exc: # pragma: no cover - network smoke script
44
+ result = repr(exc)
45
+ status = "error"
46
+ results.append(
47
+ {
48
+ "endpoint": endpoint,
49
+ "status": status,
50
+ "args": args,
51
+ "result": result,
52
+ }
53
+ )
54
+ print(f"{endpoint}: {status}")
55
+ return {
56
+ "generated_at": datetime.now(UTC).isoformat(),
57
+ "space": space_url,
58
+ "checks": results,
59
+ }
60
+
61
+
62
+ def main() -> None:
63
+ parser = argparse.ArgumentParser(description="Run public DOTA2Tuned Space API checks.")
64
+ parser.add_argument("--space", default=DEFAULT_SPACE)
65
+ parser.add_argument(
66
+ "--output",
67
+ default="submission_evidence/public_space_api_checks.json",
68
+ help="Path for JSON evidence output.",
69
+ )
70
+ args = parser.parse_args()
71
+
72
+ payload = run_checks(args.space)
73
+ output_path = Path(args.output)
74
+ output_path.parent.mkdir(parents=True, exist_ok=True)
75
+ output_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
76
+ print(output_path)
77
+
78
+
79
+ if __name__ == "__main__":
80
+ main()
src/dota2tuned/recommend.py CHANGED
@@ -7,6 +7,14 @@ import polars as pl
7
  from dota2tuned.schemas import DraftInput, Recommendation
8
  from dota2tuned.storage import read_parquet
9
 
 
 
 
 
 
 
 
 
10
 
11
  def _confidence(sample_size: int) -> str:
12
  if sample_size >= 500:
@@ -16,6 +24,33 @@ def _confidence(sample_size: int) -> str:
16
  return "low"
17
 
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  class DraftRecommender:
20
  def __init__(self, parquet_dir: Path) -> None:
21
  self.parquet_dir = parquet_dir
@@ -36,10 +71,12 @@ class DraftRecommender:
36
 
37
  rows = []
38
  for row in candidates.iter_rows(named=True):
 
 
39
  hero_id = int(row["hero_id"])
40
  pro_pick = int(row.get("pro_pick") or 0)
41
- pro_win_rate = row.get("pro_win_rate")
42
- base_score = float(pro_win_rate or 0.5)
43
  synergy_lift = self._pair_lift(hero_id, draft.allied_heroes, "ally")
44
  counter_lift = self._pair_lift(hero_id, draft.enemy_heroes, "enemy")
45
  score = base_score + synergy_lift + counter_lift
 
7
  from dota2tuned.schemas import DraftInput, Recommendation
8
  from dota2tuned.storage import read_parquet
9
 
10
+ _ROLE_HINTS = {
11
+ "carry": {"Carry"},
12
+ "mid": {"Carry", "Escape", "Initiator"},
13
+ "offlane": {"Initiator", "Durable", "Disabler"},
14
+ "soft support": {"Support"},
15
+ "hard support": {"Support"},
16
+ }
17
+
18
 
19
  def _confidence(sample_size: int) -> str:
20
  if sample_size >= 500:
 
24
  return "low"
25
 
26
 
27
+ def _role_tokens(value: object) -> set[str]:
28
+ if not value:
29
+ return set()
30
+ return {part.strip() for part in str(value).split(",") if part.strip()}
31
+
32
+
33
+ def _role_match(role: str | None, roles: object) -> bool:
34
+ if not role:
35
+ return True
36
+ tokens = _role_tokens(roles)
37
+ if not tokens:
38
+ return True
39
+ role_key = role.lower()
40
+ hints = _ROLE_HINTS.get(role_key)
41
+ if not hints:
42
+ return True
43
+ if role_key == "mid" and "Support" in tokens and "Carry" not in tokens:
44
+ return False
45
+ return bool(tokens & hints)
46
+
47
+
48
+ def _shrunk_win_rate(pro_win: int, pro_pick: int, *, prior_games: int = 50) -> float:
49
+ if pro_pick <= 0:
50
+ return 0.5
51
+ return (pro_win + (prior_games * 0.5)) / (pro_pick + prior_games)
52
+
53
+
54
  class DraftRecommender:
55
  def __init__(self, parquet_dir: Path) -> None:
56
  self.parquet_dir = parquet_dir
 
71
 
72
  rows = []
73
  for row in candidates.iter_rows(named=True):
74
+ if not _role_match(draft.role, row.get("roles")):
75
+ continue
76
  hero_id = int(row["hero_id"])
77
  pro_pick = int(row.get("pro_pick") or 0)
78
+ pro_win = int(row.get("pro_win") or 0)
79
+ base_score = _shrunk_win_rate(pro_win, pro_pick)
80
  synergy_lift = self._pair_lift(hero_id, draft.allied_heroes, "ally")
81
  counter_lift = self._pair_lift(hero_id, draft.enemy_heroes, "enemy")
82
  score = base_score + synergy_lift + counter_lift
src/dota2tuned/ui/gradio_app.py CHANGED
@@ -78,6 +78,13 @@ def _format_hero_ids(hero_ids: list[int], names: dict[int, str]) -> str:
78
  return ", ".join(f"{names.get(hero_id, f'Hero {hero_id}')} ({hero_id})" for hero_id in hero_ids)
79
 
80
 
 
 
 
 
 
 
 
81
  def _format_recs(recs: list) -> str:
82
  if not recs:
83
  return (
@@ -209,10 +216,10 @@ def build_app() -> gr.Blocks:
209
  )
210
  lines = []
211
  for row in rows:
212
- minutes = round(float(row.get("median_time") or 0) / 60, 1)
213
  lines.append(
214
  f"- **{row.get('item_key')}** in `{row.get('time_bucket')}`: "
215
- f"{row.get('purchases')} purchases, median `{minutes}` min"
216
  )
217
  return "\n".join(lines) if lines else "No observed item timings for that hero."
218
 
 
78
  return ", ".join(f"{names.get(hero_id, f'Hero {hero_id}')} ({hero_id})" for hero_id in hero_ids)
79
 
80
 
81
+ def _format_item_time(seconds: object) -> str:
82
+ value = float(seconds or 0)
83
+ if value < 0:
84
+ return "pre-game"
85
+ return f"{round(value / 60, 1)} min"
86
+
87
+
88
  def _format_recs(recs: list) -> str:
89
  if not recs:
90
  return (
 
216
  )
217
  lines = []
218
  for row in rows:
219
+ median_time = _format_item_time(row.get("median_time"))
220
  lines.append(
221
  f"- **{row.get('item_key')}** in `{row.get('time_bucket')}`: "
222
+ f"{row.get('purchases')} purchases, median `{median_time}`"
223
  )
224
  return "\n".join(lines) if lines else "No observed item timings for that hero."
225
 
tests/test_gradio_app.py CHANGED
@@ -1,6 +1,6 @@
1
  import polars as pl
2
 
3
- from dota2tuned.ui.gradio_app import _hero_lookup, _parse_heroes
4
 
5
 
6
  def test_parse_heroes_accepts_names_and_ids():
@@ -27,3 +27,8 @@ def test_parse_heroes_reports_unknown_names():
27
 
28
  assert hero_ids == [1]
29
  assert unknown == ["Banana King"]
 
 
 
 
 
 
1
  import polars as pl
2
 
3
+ from dota2tuned.ui.gradio_app import _format_item_time, _hero_lookup, _parse_heroes
4
 
5
 
6
  def test_parse_heroes_accepts_names_and_ids():
 
27
 
28
  assert hero_ids == [1]
29
  assert unknown == ["Banana King"]
30
+
31
+
32
+ def test_format_item_time_labels_pre_game_buys():
33
+ assert _format_item_time(-90) == "pre-game"
34
+ assert _format_item_time(180) == "3.0 min"
tests/test_recommend.py CHANGED
@@ -12,6 +12,7 @@ def test_recommendation_schema_and_exclusions(tmp_path: Path):
12
  {
13
  "hero_id": 1,
14
  "hero_name": "Anti-Mage",
 
15
  "pro_pick": 1000,
16
  "pro_win": 520,
17
  "pro_win_rate": 0.52,
@@ -19,6 +20,7 @@ def test_recommendation_schema_and_exclusions(tmp_path: Path):
19
  {
20
  "hero_id": 2,
21
  "hero_name": "Axe",
 
22
  "pro_pick": 200,
23
  "pro_win": 90,
24
  "pro_win_rate": 0.45,
@@ -31,3 +33,61 @@ def test_recommendation_schema_and_exclusions(tmp_path: Path):
31
  assert len(recs) == 1
32
  assert isinstance(recs[0], Recommendation)
33
  assert recs[0].hero_id == 2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  {
13
  "hero_id": 1,
14
  "hero_name": "Anti-Mage",
15
+ "roles": "Carry,Escape,Nuker",
16
  "pro_pick": 1000,
17
  "pro_win": 520,
18
  "pro_win_rate": 0.52,
 
20
  {
21
  "hero_id": 2,
22
  "hero_name": "Axe",
23
+ "roles": "Initiator,Durable,Disabler",
24
  "pro_pick": 200,
25
  "pro_win": 90,
26
  "pro_win_rate": 0.45,
 
33
  assert len(recs) == 1
34
  assert isinstance(recs[0], Recommendation)
35
  assert recs[0].hero_id == 2
36
+
37
+
38
+ def test_mid_recommendations_filter_support_first_heroes(tmp_path: Path):
39
+ write_parquet(
40
+ tmp_path / "dim_hero.parquet",
41
+ [
42
+ {
43
+ "hero_id": 13,
44
+ "hero_name": "Puck",
45
+ "roles": "Initiator,Disabler,Escape,Nuker",
46
+ "pro_pick": 12,
47
+ "pro_win": 4,
48
+ "pro_win_rate": 0.3333,
49
+ },
50
+ {
51
+ "hero_id": 91,
52
+ "hero_name": "Io",
53
+ "roles": "Support,Escape,Nuker",
54
+ "pro_pick": 4,
55
+ "pro_win": 4,
56
+ "pro_win_rate": 1.0,
57
+ },
58
+ ],
59
+ )
60
+ write_parquet(tmp_path / "fact_hero_pair_stats.parquet", [])
61
+
62
+ recs = DraftRecommender(tmp_path).recommend(DraftInput(role="mid"), limit=5)
63
+
64
+ assert [rec.hero_name for rec in recs] == ["Puck"]
65
+
66
+
67
+ def test_low_sample_win_rates_are_shrunk(tmp_path: Path):
68
+ write_parquet(
69
+ tmp_path / "dim_hero.parquet",
70
+ [
71
+ {
72
+ "hero_id": 1,
73
+ "hero_name": "Tiny Sample",
74
+ "roles": "Carry",
75
+ "pro_pick": 1,
76
+ "pro_win": 1,
77
+ "pro_win_rate": 1.0,
78
+ },
79
+ {
80
+ "hero_id": 2,
81
+ "hero_name": "Large Sample",
82
+ "roles": "Carry",
83
+ "pro_pick": 200,
84
+ "pro_win": 120,
85
+ "pro_win_rate": 0.6,
86
+ },
87
+ ],
88
+ )
89
+ write_parquet(tmp_path / "fact_hero_pair_stats.parquet", [])
90
+
91
+ recs = DraftRecommender(tmp_path).recommend(DraftInput(role="carry"), limit=2)
92
+
93
+ assert recs[0].hero_name == "Large Sample"