M5: baseline score, artifact build/load, calibration harness
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
76
backend/tests/test_score.py
Normal file
76
backend/tests/test_score.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from tft.model.baseline import classify_role, unit_value
|
||||||
|
from tft.model.calibrate import spearman
|
||||||
|
from tft.model.score import active_trait_tiers, score_board
|
||||||
|
|
||||||
|
STATIC = {
|
||||||
|
"units": {
|
||||||
|
"TFT17_A": {
|
||||||
|
"cost": 1,
|
||||||
|
"traits": ["TFT17_Tank"],
|
||||||
|
"role": "Tank",
|
||||||
|
"stats": {"hp": 650, "armor": 40, "magicResist": 40, "damage": 50,
|
||||||
|
"attackSpeed": 0.6, "mana": 60, "initialMana": 0, "range": 1},
|
||||||
|
},
|
||||||
|
"TFT17_B": {
|
||||||
|
"cost": 4,
|
||||||
|
"traits": ["TFT17_Tank"],
|
||||||
|
"role": "Marksman",
|
||||||
|
"stats": {"hp": 700, "armor": 25, "magicResist": 25, "damage": 75,
|
||||||
|
"attackSpeed": 0.75, "mana": 100, "initialMana": 20, "range": 4},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"traits": {
|
||||||
|
"TFT17_Tank": {
|
||||||
|
"breakpoints": [{"min_units": 2, "style": 1}, {"min_units": 4, "style": 3}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ARTIFACT = {"meta": {"set": 17}, "static": {**STATIC, "items": {}, "augments": {}},
|
||||||
|
"roles": {}, "learned": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_value():
|
||||||
|
assert unit_value(1, 1) == 1
|
||||||
|
assert unit_value(4, 2) == 12
|
||||||
|
assert unit_value(3, 3) == 27
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_role_from_cdragon_role():
|
||||||
|
assert classify_role(STATIC["units"]["TFT17_A"]) == "frontline"
|
||||||
|
assert classify_role(STATIC["units"]["TFT17_B"]) == "ad_carry"
|
||||||
|
|
||||||
|
|
||||||
|
def test_trait_activation():
|
||||||
|
board = [{"api_name": "TFT17_A", "stars": 1, "items": []},
|
||||||
|
{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
||||||
|
tiers = active_trait_tiers(board, STATIC["traits"], STATIC["units"])
|
||||||
|
assert tiers == {"TFT17_Tank": 1}
|
||||||
|
assert active_trait_tiers(board[:1], STATIC["traits"], STATIC["units"]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_score_ordering():
|
||||||
|
weak = [{"api_name": "TFT17_A", "stars": 1, "items": []}]
|
||||||
|
strong = [{"api_name": "TFT17_B", "stars": 2, "items": []}]
|
||||||
|
assert score_board(strong, [], ARTIFACT) > score_board(weak, [], ARTIFACT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_trait_beats_inactive():
|
||||||
|
pair = [{"api_name": "TFT17_A", "stars": 1, "items": []},
|
||||||
|
{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
||||||
|
solo_sum = score_board(pair[:1], [], ARTIFACT) + score_board(pair[1:], [], ARTIFACT)
|
||||||
|
assert score_board(pair, [], ARTIFACT) > solo_sum
|
||||||
|
|
||||||
|
|
||||||
|
def test_items_increase_score():
|
||||||
|
bare = [{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
||||||
|
with_item = [{"api_name": "TFT17_B", "stars": 1, "items": ["TFT_Item_InfinityEdge"]}]
|
||||||
|
assert score_board(with_item, [], ARTIFACT) > score_board(bare, [], ARTIFACT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spearman():
|
||||||
|
assert spearman([1, 2, 3, 4], [10, 20, 30, 40]) == pytest.approx(1.0)
|
||||||
|
assert spearman([1, 2, 3, 4], [40, 30, 20, 10]) == pytest.approx(-1.0)
|
||||||
|
assert spearman([1, 2, 3, 4], [10, 10, 10, 10]) == 0.0
|
||||||
@@ -22,6 +22,8 @@ def main() -> None:
|
|||||||
p_crawl.add_argument("--limit", type=int, default=None, help="stop after N new matches")
|
p_crawl.add_argument("--limit", type=int, default=None, help="stop after N new matches")
|
||||||
|
|
||||||
sub.add_parser("extract", help="extract endboards from raw matches")
|
sub.add_parser("extract", help="extract endboards from raw matches")
|
||||||
|
sub.add_parser("build-artifact", help="build analysis.json from static data + endboards")
|
||||||
|
sub.add_parser("calibrate", help="backtest score vs real placements (holdout)")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -56,6 +58,28 @@ def main() -> None:
|
|||||||
else:
|
else:
|
||||||
print("all ids resolved against static data")
|
print("all ids resolved against static data")
|
||||||
|
|
||||||
|
elif args.command == "build-artifact":
|
||||||
|
from tft.model import artifact as artifact_mod
|
||||||
|
from tft.staticdata.fetch import load_static
|
||||||
|
|
||||||
|
static = load_static()
|
||||||
|
learned = {}
|
||||||
|
art = artifact_mod.build(static, learned)
|
||||||
|
path = artifact_mod.save(art)
|
||||||
|
print(f"artifact written: {path}")
|
||||||
|
|
||||||
|
elif args.command == "calibrate":
|
||||||
|
from tft import db
|
||||||
|
from tft.model import artifact as artifact_mod
|
||||||
|
from tft.model.calibrate import calibrate
|
||||||
|
|
||||||
|
conn = db.connect()
|
||||||
|
art = artifact_mod.load(current_set())
|
||||||
|
result = calibrate(conn, art, holdout_only=True)
|
||||||
|
conn.close()
|
||||||
|
print(f"holdout matches: {result['matches']}")
|
||||||
|
print(f"mean spearman (score vs placement): {result['mean_spearman']:.3f}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
0
backend/tft/model/__init__.py
Normal file
0
backend/tft/model/__init__.py
Normal file
55
backend/tft/model/artifact.py
Normal file
55
backend/tft/model/artifact.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Build/load the versioned analysis artifact — the contract between pipeline and sim."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from tft import paths
|
||||||
|
from tft.model import baseline
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def build(static: dict, learned: dict | None = None, extra_meta: dict | None = None) -> dict:
|
||||||
|
roles = {
|
||||||
|
api: baseline.classify_role(unit) for api, unit in static["units"].items()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"meta": {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"set": static["meta"]["set"],
|
||||||
|
"patch": static["meta"].get("patch"),
|
||||||
|
"built_at": date.today().isoformat(),
|
||||||
|
**(extra_meta or {}),
|
||||||
|
},
|
||||||
|
"static": {
|
||||||
|
"units": static["units"],
|
||||||
|
"traits": static["traits"],
|
||||||
|
"items": static["items"],
|
||||||
|
"augments": static["augments"],
|
||||||
|
},
|
||||||
|
"roles": roles,
|
||||||
|
"learned": learned or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save(artifact: dict) -> str:
|
||||||
|
set_number = artifact["meta"]["set"]
|
||||||
|
build_date = artifact["meta"]["built_at"].replace("-", "")
|
||||||
|
out_dir = paths.artifact_dir(set_number, build_date)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path = out_dir / "analysis.json"
|
||||||
|
out_path.write_text(json.dumps(artifact))
|
||||||
|
paths.latest_artifact_pointer(set_number).write_text(
|
||||||
|
json.dumps({"path": str(out_path)})
|
||||||
|
)
|
||||||
|
return str(out_path)
|
||||||
|
|
||||||
|
|
||||||
|
def load(set_number: int) -> dict:
|
||||||
|
pointer = paths.latest_artifact_pointer(set_number)
|
||||||
|
if not pointer.exists():
|
||||||
|
raise SystemExit(
|
||||||
|
f"no artifact for set {set_number}: run `build-artifact` first"
|
||||||
|
)
|
||||||
|
path = json.loads(pointer.read_text())["path"]
|
||||||
|
return json.loads(open(path).read())
|
||||||
44
backend/tft/model/baseline.py
Normal file
44
backend/tft/model/baseline.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Rule-based baseline: unit values, stat proxies, role classification."""
|
||||||
|
|
||||||
|
|
||||||
|
def unit_value(cost: int, stars: int) -> float:
|
||||||
|
return cost * 3 ** (stars - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def stat_proxies(stats: dict) -> dict:
|
||||||
|
ehp = stats["hp"] * (1 + (stats["armor"] + stats["magicResist"]) / 2 / 100)
|
||||||
|
auto_dps = stats["damage"] * stats["attackSpeed"]
|
||||||
|
mana_gap = max(stats["mana"] - stats["initialMana"], 1)
|
||||||
|
cast_rate = stats["attackSpeed"] * 10 / mana_gap
|
||||||
|
return {"ehp": ehp, "auto_dps": auto_dps, "cast_rate": cast_rate}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_role(unit: dict) -> str:
|
||||||
|
"""frontline | ad_carry | ap_carry | utility, from cdragon role with stat fallback."""
|
||||||
|
role = (unit.get("role") or "").lower()
|
||||||
|
if "tank" in role:
|
||||||
|
return "frontline"
|
||||||
|
if "caster" in role or "ap" in role:
|
||||||
|
return "ap_carry"
|
||||||
|
if "marksman" in role or "assassin" in role or "fighter" in role or "ad" in role:
|
||||||
|
return "ad_carry"
|
||||||
|
if "support" in role or "specialist" in role:
|
||||||
|
return "utility"
|
||||||
|
stats = unit["stats"]
|
||||||
|
return "frontline" if stats["range"] <= 1 else "ad_carry"
|
||||||
|
|
||||||
|
|
||||||
|
# Default multipliers, replaced by learned values when the artifact has them.
|
||||||
|
DEFAULT_ITEM_MULT = 1.15
|
||||||
|
ROLE_FIT_BONUS = 1.05
|
||||||
|
DEFAULT_TRAIT_TIER_MULT = [1.0, 1.03, 1.07, 1.12, 1.20] # index = reached breakpoint ordinal
|
||||||
|
|
||||||
|
# Item tags that fit a role (checked against item api_name, crude but stable).
|
||||||
|
AD_HINTS = ("Deathblade", "InfinityEdge", "GiantSlayer", "LastWhisper", "RunaansHurricane", "GuinsoosRageblade")
|
||||||
|
AP_HINTS = ("RabadonsDeathcap", "ArchangelsStaff", "JeweledGauntlet", "HextechGunblade", "NashorsTooth", "Morellonomicon")
|
||||||
|
TANK_HINTS = ("BrambleVest", "DragonsClaw", "WarmogsArmor", "GargoyleStoneplate", "Redemption", "SunfireCape")
|
||||||
|
|
||||||
|
|
||||||
|
def item_fits_role(item_api_name: str, role: str) -> bool:
|
||||||
|
hints = {"ad_carry": AD_HINTS, "ap_carry": AP_HINTS, "frontline": TANK_HINTS}.get(role, ())
|
||||||
|
return any(h in item_api_name for h in hints)
|
||||||
76
backend/tft/model/calibrate.py
Normal file
76
backend/tft/model/calibrate.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Backtest the score formula against real placements (mean Spearman per match)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def _ranks(values: list[float]) -> list[float]:
|
||||||
|
order = sorted(range(len(values)), key=lambda i: values[i])
|
||||||
|
ranks = [0.0] * len(values)
|
||||||
|
i = 0
|
||||||
|
while i < len(order):
|
||||||
|
j = i
|
||||||
|
while j + 1 < len(order) and values[order[j + 1]] == values[order[i]]:
|
||||||
|
j += 1
|
||||||
|
midrank = (i + j) / 2 + 1
|
||||||
|
for k in range(i, j + 1):
|
||||||
|
ranks[order[k]] = midrank
|
||||||
|
i = j + 1
|
||||||
|
return ranks
|
||||||
|
|
||||||
|
|
||||||
|
def spearman(a: list[float], b: list[float]) -> float:
|
||||||
|
ra, rb = _ranks(a), _ranks(b)
|
||||||
|
n = len(a)
|
||||||
|
ma, mb = sum(ra) / n, sum(rb) / n
|
||||||
|
cov = sum((x - ma) * (y - mb) for x, y in zip(ra, rb))
|
||||||
|
va = sum((x - ma) ** 2 for x in ra) ** 0.5
|
||||||
|
vb = sum((y - mb) ** 2 for y in rb) ** 0.5
|
||||||
|
if va == 0 or vb == 0:
|
||||||
|
return 0.0
|
||||||
|
return cov / (va * vb)
|
||||||
|
|
||||||
|
|
||||||
|
def board_from_row(units_json: str, augments_json: str) -> tuple[list[dict], list[str]]:
|
||||||
|
units = [
|
||||||
|
{
|
||||||
|
"api_name": u["character_id"],
|
||||||
|
"stars": u["tier"],
|
||||||
|
"items": u.get("itemNames", []),
|
||||||
|
}
|
||||||
|
for u in json.loads(units_json)
|
||||||
|
]
|
||||||
|
return units, json.loads(augments_json)
|
||||||
|
|
||||||
|
|
||||||
|
def calibrate(conn, artifact: dict, holdout_only: bool = False) -> dict:
|
||||||
|
"""Mean Spearman between board score and placement (negated: higher = better)."""
|
||||||
|
from tft.model.score import score_board
|
||||||
|
|
||||||
|
set_number = artifact["meta"]["set"]
|
||||||
|
where = "WHERE set_number = ?"
|
||||||
|
if holdout_only:
|
||||||
|
where += " AND rowid % 5 = 0"
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT match_id, placement, units, augments FROM endboards {where}",
|
||||||
|
(set_number,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
by_match: dict[str, list] = {}
|
||||||
|
for match_id, placement, units_json, augments_json in rows:
|
||||||
|
board, augments = board_from_row(units_json, augments_json)
|
||||||
|
s = score_board(board, augments, artifact)
|
||||||
|
by_match.setdefault(match_id, []).append((placement, s))
|
||||||
|
|
||||||
|
correlations = []
|
||||||
|
for players in by_match.values():
|
||||||
|
if len(players) < 8:
|
||||||
|
continue
|
||||||
|
placements = [float(p) for p, _ in players]
|
||||||
|
scores = [s for _, s in players]
|
||||||
|
correlations.append(-spearman(placements, scores))
|
||||||
|
|
||||||
|
n = len(correlations)
|
||||||
|
return {
|
||||||
|
"matches": n,
|
||||||
|
"mean_spearman": sum(correlations) / n if n else 0.0,
|
||||||
|
}
|
||||||
69
backend/tft/model/score.py
Normal file
69
backend/tft/model/score.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""The one board-scoring entry point. Sim, bots, calibration, and UI all call this."""
|
||||||
|
|
||||||
|
from tft.model import baseline
|
||||||
|
|
||||||
|
|
||||||
|
def active_trait_tiers(board_units: list[dict], static_traits: dict, static_units: dict) -> dict:
|
||||||
|
"""trait api_name -> reached breakpoint ordinal (1-based), only active traits."""
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for u in board_units:
|
||||||
|
unit = static_units.get(u["api_name"])
|
||||||
|
if not unit:
|
||||||
|
continue
|
||||||
|
for trait in set(unit["traits"]):
|
||||||
|
counts[trait] = counts.get(trait, 0) + 1
|
||||||
|
|
||||||
|
tiers = {}
|
||||||
|
for trait, n in counts.items():
|
||||||
|
info = static_traits.get(trait)
|
||||||
|
if not info:
|
||||||
|
continue
|
||||||
|
ordinal = 0
|
||||||
|
for i, bp in enumerate(info["breakpoints"], start=1):
|
||||||
|
if n >= bp["min_units"]:
|
||||||
|
ordinal = i
|
||||||
|
if ordinal:
|
||||||
|
tiers[trait] = ordinal
|
||||||
|
return tiers
|
||||||
|
|
||||||
|
|
||||||
|
def score_board(board_units: list[dict], augments: list[str], artifact: dict) -> float:
|
||||||
|
"""board_units: [{api_name, stars, items: [item api names]}]."""
|
||||||
|
static_units = artifact["static"]["units"]
|
||||||
|
static_traits = artifact["static"]["traits"]
|
||||||
|
learned = artifact.get("learned", {})
|
||||||
|
item_mults = learned.get("items", {})
|
||||||
|
trait_mults = learned.get("traits", {})
|
||||||
|
augment_mults = learned.get("augments", {})
|
||||||
|
pair_lifts = learned.get("pairs", {})
|
||||||
|
roles = artifact.get("roles", {})
|
||||||
|
|
||||||
|
total = 0.0
|
||||||
|
for u in board_units:
|
||||||
|
unit = static_units.get(u["api_name"])
|
||||||
|
if not unit:
|
||||||
|
continue
|
||||||
|
value = baseline.unit_value(unit["cost"], u["stars"])
|
||||||
|
role = roles.get(u["api_name"]) or baseline.classify_role(unit)
|
||||||
|
for item in u.get("items", []):
|
||||||
|
mult = item_mults.get(item, baseline.DEFAULT_ITEM_MULT)
|
||||||
|
if baseline.item_fits_role(item, role):
|
||||||
|
mult *= baseline.ROLE_FIT_BONUS
|
||||||
|
value *= mult
|
||||||
|
total += value
|
||||||
|
|
||||||
|
for trait, ordinal in active_trait_tiers(board_units, static_traits, static_units).items():
|
||||||
|
default = baseline.DEFAULT_TRAIT_TIER_MULT[
|
||||||
|
min(ordinal, len(baseline.DEFAULT_TRAIT_TIER_MULT) - 1)
|
||||||
|
]
|
||||||
|
total *= trait_mults.get(f"{trait}@{ordinal}", default)
|
||||||
|
|
||||||
|
for augment in augments:
|
||||||
|
total *= augment_mults.get(augment, 1.0)
|
||||||
|
|
||||||
|
names = sorted({u["api_name"] for u in board_units})
|
||||||
|
for i, a in enumerate(names):
|
||||||
|
for b in names[i + 1 :]:
|
||||||
|
total += pair_lifts.get(f"{a}|{b}", 0.0)
|
||||||
|
|
||||||
|
return total
|
||||||
Reference in New Issue
Block a user