Add rcon-first elo mmr foundation

This commit is contained in:
devRaGonSa
2026-03-25 17:29:29 +01:00
parent 787e753f77
commit 6ed416f79a
18 changed files with 2282 additions and 33 deletions

View File

@@ -244,6 +244,88 @@ def get_rcon_historical_read_model() -> RconHistoricalDataSource | None:
return RconHistoricalDataSource()
def describe_historical_runtime_policy() -> dict[str, object]:
"""Describe the effective historical runtime policy for the current environment."""
if get_historical_data_source_kind() != SOURCE_KIND_RCON:
return {
"mode": "public-scoreboard-primary",
"primary_source": SOURCE_KIND_PUBLIC_SCOREBOARD,
"fallback_source": None,
"summary": "Historical runtime uses public-scoreboard directly.",
}
return {
"mode": "rcon-first-with-public-scoreboard-fallback",
"primary_source": SOURCE_KIND_RCON,
"fallback_source": SOURCE_KIND_PUBLIC_SCOREBOARD,
"summary": (
"Historical runtime attempts the persisted RCON read model first and falls "
"back to public-scoreboard when the requested operation is unsupported, has "
"no coverage yet, or the primary path fails."
),
}
def build_historical_runtime_source_policy(
*,
operation: str,
rcon_status: str,
fallback_reason: str | None = None,
selected_source: str | None = None,
rcon_message: str | None = None,
) -> dict[str, object]:
"""Build one normalized source-policy block for historical runtime reads."""
configured_kind = get_historical_data_source_kind()
if configured_kind != SOURCE_KIND_RCON:
return build_source_policy(
primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
source_attempts=[
build_source_attempt(
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
role="primary",
status="success",
reason=f"{operation}-served-by-public-scoreboard",
)
],
)
if rcon_status == "success":
return build_source_policy(
primary_source=SOURCE_KIND_RCON,
selected_source=selected_source or SOURCE_KIND_RCON,
source_attempts=[
build_source_attempt(
source=SOURCE_KIND_RCON,
role="primary",
status="success",
reason=f"{operation}-served-by-rcon",
)
],
)
return build_source_policy(
primary_source=SOURCE_KIND_RCON,
selected_source=selected_source or SOURCE_KIND_PUBLIC_SCOREBOARD,
fallback_used=True,
fallback_reason=fallback_reason,
source_attempts=[
build_source_attempt(
source=SOURCE_KIND_RCON,
role="primary",
status=rcon_status,
reason=fallback_reason,
message=rcon_message,
),
build_source_attempt(
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
role="fallback",
status="success",
reason=f"{operation}-served-by-public-scoreboard-fallback",
),
],
)
def resolve_historical_ingestion_data_source() -> tuple[HistoricalDataSource, dict[str, object]]:
"""Resolve the writer-oriented historical provider with safe fallback semantics."""
configured_kind = get_historical_data_source_kind()

View File

@@ -0,0 +1,644 @@
"""Core Elo/MMR rebuild engine backed by real historical signals."""
from __future__ import annotations
import argparse
import json
from collections import defaultdict
from datetime import datetime, timezone
from statistics import pstdev
from typing import Iterable
from .config import get_historical_data_source_kind
from .data_sources import (
SOURCE_KIND_PUBLIC_SCOREBOARD,
SOURCE_KIND_RCON,
build_source_attempt,
build_source_policy,
)
from .elo_mmr_models import (
CAPABILITY_APPROXIMATE,
CAPABILITY_EXACT,
CAPABILITY_UNAVAILABLE,
DEFAULT_BASE_MMR,
FULL_QUALITY_DURATION_SECONDS,
FULL_QUALITY_PLAYER_COUNT,
MIN_VALID_MATCH_DURATION_SECONDS,
MIN_VALID_MATCH_PLAYERS,
MONTHLY_ACTIVITY_TARGET_HOURS,
MONTHLY_ACTIVITY_TARGET_MATCHES,
MONTHLY_MIN_TIME_SECONDS,
MONTHLY_MIN_VALID_MATCHES,
build_signal,
summarize_accuracy,
)
from .elo_mmr_storage import (
get_elo_mmr_player_profile,
initialize_elo_mmr_storage,
list_elo_mmr_monthly_rankings,
replace_elo_mmr_state,
)
from .historical_storage import ALL_SERVERS_SLUG, initialize_historical_storage
from .sqlite_utils import connect_sqlite_readonly
from .writer_lock import backend_writer_lock, build_writer_lock_holder
SCOPE_ALL_SERVERS = ALL_SERVERS_SLUG
QUALITY_BUCKET_HIGH = "high"
QUALITY_BUCKET_MEDIUM = "medium"
QUALITY_BUCKET_LOW = "low"
ROLE_BUCKET_SUPPORT = "support"
ROLE_BUCKET_OFFENSE = "offense"
ROLE_BUCKET_DEFENSE = "defense"
ROLE_BUCKET_COMBAT = "combat"
ROLE_BUCKET_GENERALIST = "generalist"
ROLE_WEIGHTS = {
ROLE_BUCKET_SUPPORT: {"combat": 0.18, "objective": 0.18, "utility": 0.42, "discipline": 0.22},
ROLE_BUCKET_OFFENSE: {"combat": 0.38, "objective": 0.30, "utility": 0.10, "discipline": 0.22},
ROLE_BUCKET_DEFENSE: {"combat": 0.26, "objective": 0.34, "utility": 0.16, "discipline": 0.24},
ROLE_BUCKET_COMBAT: {"combat": 0.48, "objective": 0.14, "utility": 0.14, "discipline": 0.24},
ROLE_BUCKET_GENERALIST: {"combat": 0.34, "objective": 0.22, "utility": 0.20, "discipline": 0.24},
}
def rebuild_elo_mmr_models(*, db_path=None) -> dict[str, object]:
"""Rebuild persistent player ratings and monthly rankings from scratch."""
with backend_writer_lock(holder=build_writer_lock_holder("app.elo_mmr_engine rebuild")):
resolved_path = initialize_historical_storage(db_path=db_path)
initialize_elo_mmr_storage(db_path=resolved_path)
historical_source_policy = _build_historical_source_policy_for_elo()
match_rows = _load_closed_match_rows(db_path=resolved_path)
grouped_matches = _group_match_rows(match_rows)
ratings_by_scope: dict[str, dict[str, dict[str, object]]] = {SCOPE_ALL_SERVERS: {}}
player_ratings: list[dict[str, object]] = []
match_results: list[dict[str, object]] = []
monthly_checkpoints: list[dict[str, object]] = []
for match_group in grouped_matches:
server_scope = match_group["server_slug"]
ratings_by_scope.setdefault(server_scope, {})
for scope_key in (server_scope, SCOPE_ALL_SERVERS):
match_results.extend(
_score_match_for_scope(
match_group=match_group,
scope_key=scope_key,
ratings_by_scope=ratings_by_scope[scope_key],
)
)
for scope_ratings in ratings_by_scope.values():
player_ratings.extend(scope_ratings.values())
monthly_rankings = _build_monthly_rankings(match_results)
checkpoint_groups: dict[tuple[str, str], list[dict[str, object]]] = defaultdict(list)
for row in monthly_rankings:
checkpoint_groups[(row["scope_key"], row["month_key"])].append(row)
generated_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
for (scope_key, month_key), rows in checkpoint_groups.items():
eligible_count = sum(1 for row in rows if row["eligible"])
exact_ratio = round(
sum(float(row["capabilities"]["exact_ratio"]) for row in rows) / max(1, len(rows)),
3,
)
approximate_ratio = round(
sum(float(row["capabilities"]["approximate_ratio"]) for row in rows) / max(1, len(rows)),
3,
)
partial_count = sum(1 for row in rows if row["accuracy_mode"] == "partial")
monthly_checkpoints.append(
{
"scope_key": scope_key,
"month_key": month_key,
"generated_at": generated_at,
"player_count": len(rows),
"eligible_player_count": eligible_count,
"source_policy": historical_source_policy,
"capabilities_summary": {
"exact_ratio": exact_ratio,
"approximate_ratio": approximate_ratio,
"partial_count": partial_count,
"notes": [
"Outcome, combat, utility, discipline and persistent MMR use real stored signals.",
"ObjectiveIndex and role bucket are approximate proxies based on offense/defense/support/combat scores.",
"LeadershipIndex is not available with the current repository telemetry.",
],
},
}
)
replace_elo_mmr_state(
player_ratings=player_ratings,
match_results=match_results,
monthly_rankings=monthly_rankings,
monthly_checkpoints=monthly_checkpoints,
db_path=resolved_path,
)
latest_month_by_scope = {
checkpoint["scope_key"]: checkpoint["month_key"] for checkpoint in monthly_checkpoints
}
return {
"status": "ok",
"historical_source_policy": historical_source_policy,
"totals": {
"matches_scored": len({(row["scope_key"], row["external_match_id"]) for row in match_results}),
"player_ratings": len(player_ratings),
"match_results": len(match_results),
"monthly_rankings": len(monthly_rankings),
"monthly_checkpoints": len(monthly_checkpoints),
},
"latest_month_by_scope": latest_month_by_scope,
}
def list_elo_mmr_leaderboard_payload(*, server_id: str | None, limit: int) -> dict[str, object]:
"""Return the current monthly Elo/MMR leaderboard for one scope."""
scope_key = _normalize_scope_key(server_id)
result = list_elo_mmr_monthly_rankings(scope_key=scope_key, limit=limit)
return {
"scope_key": scope_key,
"month_key": result["month_key"],
"found": result["found"],
"generated_at": result["generated_at"],
"items": result["items"],
"source_policy": result["source_policy"] or _build_historical_source_policy_for_elo(),
"capabilities_summary": result["capabilities_summary"],
}
def get_elo_mmr_player_payload(*, player_id: str, server_id: str | None) -> dict[str, object] | None:
"""Return one Elo/MMR player profile."""
return get_elo_mmr_player_profile(
player_id=player_id,
scope_key=_normalize_scope_key(server_id),
)
def build_arg_parser() -> argparse.ArgumentParser:
"""Build the CLI parser for Elo/MMR maintenance."""
parser = argparse.ArgumentParser(
description="Rebuild or inspect the Elo/MMR monthly ranking system.",
)
parser.add_argument(
"mode",
choices=("rebuild", "leaderboard", "player"),
help="rebuild recomputes all persisted Elo/MMR state; leaderboard and player inspect the read model",
)
parser.add_argument("--server", dest="server_id", help="optional server scope")
parser.add_argument("--limit", type=int, default=10, help="max rows for leaderboard mode")
parser.add_argument("--player", dest="player_id", help="player id or steam id for player mode")
return parser
def main(argv: Iterable[str] | None = None) -> int:
"""Run the Elo/MMR CLI."""
parser = build_arg_parser()
args = parser.parse_args(list(argv) if argv is not None else None)
if args.mode == "rebuild":
print(json.dumps(rebuild_elo_mmr_models(), indent=2))
return 0
if args.mode == "leaderboard":
print(json.dumps(list_elo_mmr_leaderboard_payload(server_id=args.server_id, limit=args.limit), indent=2))
return 0
if not args.player_id:
parser.error("--player is required in player mode")
print(json.dumps(get_elo_mmr_player_payload(player_id=args.player_id, server_id=args.server_id), indent=2))
return 0
def _load_closed_match_rows(*, db_path) -> list[dict[str, object]]:
with connect_sqlite_readonly(db_path) as connection:
rows = connection.execute(
"""
SELECT
historical_servers.slug AS server_slug,
historical_servers.display_name AS server_name,
historical_matches.external_match_id,
historical_matches.started_at,
historical_matches.ended_at,
historical_matches.game_mode,
historical_matches.allied_score,
historical_matches.axis_score,
historical_players.stable_player_key,
historical_players.display_name AS player_name,
historical_players.steam_id,
historical_player_match_stats.team_side,
historical_player_match_stats.kills,
historical_player_match_stats.deaths,
historical_player_match_stats.teamkills,
historical_player_match_stats.time_seconds,
historical_player_match_stats.combat,
historical_player_match_stats.offense,
historical_player_match_stats.defense,
historical_player_match_stats.support
FROM historical_player_match_stats
INNER JOIN historical_matches
ON historical_matches.id = historical_player_match_stats.historical_match_id
INNER JOIN historical_servers
ON historical_servers.id = historical_matches.historical_server_id
INNER JOIN historical_players
ON historical_players.id = historical_player_match_stats.historical_player_id
WHERE historical_matches.ended_at IS NOT NULL
ORDER BY historical_matches.ended_at ASC, historical_matches.id ASC, historical_players.id ASC
"""
).fetchall()
return [dict(row) for row in rows]
def _group_match_rows(rows: list[dict[str, object]]) -> list[dict[str, object]]:
grouped: dict[tuple[str, str], list[dict[str, object]]] = defaultdict(list)
for row in rows:
grouped[(str(row["server_slug"]), str(row["external_match_id"]))].append(row)
items: list[dict[str, object]] = []
for (server_slug, match_id), players in grouped.items():
first = players[0]
items.append(
{
"server_slug": server_slug,
"server_name": first["server_name"],
"external_match_id": match_id,
"started_at": first["started_at"],
"ended_at": first["ended_at"],
"game_mode": first["game_mode"],
"allied_score": _safe_int(first["allied_score"]),
"axis_score": _safe_int(first["axis_score"]),
"players": players,
}
)
return items
def _score_match_for_scope(
*,
match_group: dict[str, object],
scope_key: str,
ratings_by_scope: dict[str, dict[str, object]],
) -> list[dict[str, object]]:
players = list(match_group["players"])
duration_seconds, duration_mode = _resolve_match_duration(match_group, players)
quality_factor = _build_quality_factor(
player_count=len(players),
duration_seconds=duration_seconds,
has_score=match_group.get("allied_score") is not None and match_group.get("axis_score") is not None,
)
quality_bucket = _classify_quality_bucket(quality_factor)
match_valid = duration_seconds >= MIN_VALID_MATCH_DURATION_SECONDS and len(players) >= MIN_VALID_MATCH_PLAYERS
month_key = str(match_group["ended_at"])[:7]
max_kills = max(max(_safe_int(player.get("kills")), 0) for player in players) or 1
max_support = max(max(_safe_int(player.get("support")), 0) for player in players) or 1
max_combat = max(max(_safe_int(player.get("combat")), 0) for player in players) or 1
max_objective = max(
max(_safe_int(player.get("offense")) + _safe_int(player.get("defense")), 0)
for player in players
) or 1
results: list[dict[str, object]] = []
for player in players:
stable_player_key = str(player["stable_player_key"])
rating_row = ratings_by_scope.setdefault(
stable_player_key,
{
"scope_key": scope_key,
"stable_player_key": stable_player_key,
"player_name": player["player_name"],
"steam_id": player.get("steam_id"),
"current_mmr": DEFAULT_BASE_MMR,
"matches_processed": 0,
"wins": 0,
"draws": 0,
"losses": 0,
"last_match_id": None,
"last_match_ended_at": None,
"accuracy_mode": "partial",
"capabilities": summarize_accuracy([]),
},
)
signals: list[dict[str, object]] = []
team_outcome = _resolve_team_outcome(
team_side=str(player.get("team_side") or ""),
allied_score=_safe_int(match_group.get("allied_score")),
axis_score=_safe_int(match_group.get("axis_score")),
)
outcome_score = 100.0 if team_outcome == "win" else 50.0 if team_outcome == "draw" else 0.0
signals.append(build_signal("OutcomeScore", CAPABILITY_EXACT, "Derived from team side and final match score."))
kills = _safe_int(player.get("kills"))
deaths = max(1, _safe_int(player.get("deaths")))
combat_raw = _safe_int(player.get("combat"))
combat_index = round(
(40.0 * (kills / max_kills))
+ (35.0 * min(1.0, (kills / deaths) / 3.0))
+ (25.0 * (combat_raw / max_combat)),
3,
)
signals.append(build_signal("CombatIndex", CAPABILITY_EXACT, "Uses kills, KDA proxy and persisted combat score."))
support = _safe_int(player.get("support"))
utility_index = round(100.0 * (support / max_support), 3) if max_support > 0 else 0.0
signals.append(build_signal("UtilityIndex", CAPABILITY_EXACT, "Uses persisted support points."))
objective_proxy = _safe_int(player.get("offense")) + _safe_int(player.get("defense"))
objective_index = round(100.0 * (objective_proxy / max_objective), 3) if max_objective > 0 else 0.0
signals.append(build_signal("ObjectiveIndex", CAPABILITY_APPROXIMATE, "Approximated from offense and defense scoreboard points because no tactical event feed exists yet."))
teamkills = _safe_int(player.get("teamkills"))
discipline_index = round(max(0.0, 100.0 - (teamkills * 25.0)), 3)
signals.append(build_signal("DisciplineIndex", CAPABILITY_EXACT, "Uses persisted teamkills. AFK and leave events are not available yet."))
leadership_index = None
signals.append(build_signal("LeadershipIndex", CAPABILITY_UNAVAILABLE, "No leadership-specific telemetry is stored in the repository yet."))
role_bucket = _resolve_role_bucket(player)
signals.append(build_signal("role_bucket", CAPABILITY_APPROXIMATE, "Inferred from the dominant combat/offense/defense/support axis because literal player role is unavailable."))
if duration_mode == CAPABILITY_EXACT:
signals.append(build_signal("quality_duration", CAPABILITY_EXACT, "Duration computed from match timestamps."))
else:
signals.append(build_signal("quality_duration", CAPABILITY_APPROXIMATE, "Duration approximated from the maximum persisted player time."))
weights = ROLE_WEIGHTS.get(role_bucket, ROLE_WEIGHTS[ROLE_BUCKET_GENERALIST])
impact_score = round(
sum(
{
"combat": combat_index,
"objective": objective_index,
"utility": utility_index,
"discipline": discipline_index,
}[key]
* weight
for key, weight in weights.items()
),
3,
)
combined_score = round((0.55 * outcome_score) + (0.45 * impact_score), 3)
if not match_valid:
delta_mmr = 0.0
match_score = 0.0
else:
delta_mmr = round(((combined_score - 50.0) * quality_factor) * 0.6, 3)
match_score = round(combined_score * quality_factor, 3)
capability_summary = summarize_accuracy(signals)
rating_before = float(rating_row["current_mmr"])
rating_after = round(rating_before + delta_mmr, 3)
results.append(
{
"scope_key": scope_key,
"month_key": month_key,
"external_match_id": match_group["external_match_id"],
"stable_player_key": stable_player_key,
"player_name": player["player_name"],
"steam_id": player.get("steam_id"),
"server_slug": match_group["server_slug"],
"server_name": match_group["server_name"],
"match_ended_at": match_group["ended_at"],
"match_valid": match_valid,
"quality_factor": quality_factor,
"quality_bucket": quality_bucket,
"role_bucket": role_bucket,
"role_bucket_mode": CAPABILITY_APPROXIMATE,
"outcome_score": outcome_score,
"combat_index": combat_index,
"objective_index": objective_index,
"objective_index_mode": CAPABILITY_APPROXIMATE,
"utility_index": utility_index,
"utility_index_mode": CAPABILITY_EXACT,
"leadership_index": leadership_index,
"leadership_index_mode": CAPABILITY_UNAVAILABLE,
"discipline_index": discipline_index,
"discipline_index_mode": CAPABILITY_EXACT,
"impact_score": impact_score,
"delta_mmr": delta_mmr,
"mmr_before": rating_before,
"mmr_after": rating_after,
"match_score": match_score,
"penalty_points": round(teamkills * 2.0, 3),
"capabilities": capability_summary,
"time_seconds": _safe_int(player.get("time_seconds")),
"team_outcome": team_outcome,
}
)
rating_row["current_mmr"] = rating_after
rating_row["matches_processed"] = int(rating_row["matches_processed"]) + 1
rating_row["last_match_id"] = match_group["external_match_id"]
rating_row["last_match_ended_at"] = match_group["ended_at"]
rating_row["accuracy_mode"] = capability_summary["accuracy_mode"]
rating_row["capabilities"] = capability_summary
if team_outcome == "win":
rating_row["wins"] = int(rating_row["wins"]) + 1
elif team_outcome == "draw":
rating_row["draws"] = int(rating_row["draws"]) + 1
else:
rating_row["losses"] = int(rating_row["losses"]) + 1
return results
def _build_monthly_rankings(match_results: list[dict[str, object]]) -> list[dict[str, object]]:
grouped: dict[tuple[str, str, str], list[dict[str, object]]] = defaultdict(list)
for row in match_results:
grouped[(row["scope_key"], row["month_key"], row["stable_player_key"])].append(row)
rankings: list[dict[str, object]] = []
grouped_by_scope_month: dict[tuple[str, str], list[dict[str, object]]] = defaultdict(list)
for (scope_key, month_key, stable_player_key), rows in grouped.items():
rows.sort(key=lambda item: (item["match_ended_at"], item["external_match_id"]))
valid_rows = [row for row in rows if row["match_valid"]]
total_time_seconds = sum(int(row["time_seconds"] or 0) for row in rows)
penalty_points = round(sum(float(row["penalty_points"]) for row in rows), 3)
capability_rows = [row["capabilities"] for row in rows]
exact_ratio = round(sum(float(item["exact_ratio"]) for item in capability_rows) / max(1, len(capability_rows)), 3)
approximate_ratio = round(sum(float(item["approximate_ratio"]) for item in capability_rows) / max(1, len(capability_rows)), 3)
unavailable_ratio = round(sum(float(item["unavailable_ratio"]) for item in capability_rows) / max(1, len(capability_rows)), 3)
accuracy_mode = "partial" if unavailable_ratio > 0 else "approximate" if approximate_ratio > 0 else "exact"
avg_match_score = round(sum(float(row["match_score"]) for row in valid_rows) / max(1, len(valid_rows)), 3)
baseline_mmr = round(float(rows[0]["mmr_before"]), 3)
current_mmr = round(float(rows[-1]["mmr_after"]), 3)
mmr_gain = round(current_mmr - baseline_mmr, 3)
strength_of_schedule = round(sum(float(row["quality_factor"]) for row in valid_rows) * 100.0 / max(1, len(valid_rows)), 3)
consistency = _build_consistency_score(valid_rows)
activity = _build_activity_score(valid_rows, total_time_seconds)
confidence = round(min(100.0, (len(valid_rows) / MONTHLY_MIN_VALID_MATCHES) * 40.0 + (total_time_seconds / MONTHLY_MIN_TIME_SECONDS) * 35.0 + (exact_ratio * 25.0)), 3)
eligible = len(valid_rows) >= MONTHLY_MIN_VALID_MATCHES and total_time_seconds >= MONTHLY_MIN_TIME_SECONDS
eligibility_reason = None if eligible else "minimum-valid-matches-not-met" if len(valid_rows) < MONTHLY_MIN_VALID_MATCHES else "minimum-playtime-not-met"
grouped_by_scope_month[(scope_key, month_key)].append(
{
"scope_key": scope_key,
"month_key": month_key,
"stable_player_key": stable_player_key,
"player_name": rows[-1]["player_name"],
"steam_id": rows[-1].get("steam_id"),
"current_mmr": current_mmr,
"baseline_mmr": baseline_mmr,
"mmr_gain": mmr_gain,
"avg_match_score": avg_match_score,
"strength_of_schedule": strength_of_schedule,
"consistency": consistency,
"activity": activity,
"confidence": confidence,
"penalty_points": penalty_points,
"monthly_rank_score": 0.0,
"valid_matches": len(valid_rows),
"total_matches": len(rows),
"total_time_seconds": total_time_seconds,
"eligible": eligible,
"eligibility_reason": eligibility_reason,
"accuracy_mode": accuracy_mode,
"capabilities": {
"accuracy_mode": accuracy_mode,
"exact_ratio": exact_ratio,
"approximate_ratio": approximate_ratio,
"unavailable_ratio": unavailable_ratio,
"signals": [
build_signal("OutcomeScore", CAPABILITY_EXACT, "Uses final scores and team side."),
build_signal("CombatIndex", CAPABILITY_EXACT, "Uses historical player stats."),
build_signal("ObjectiveIndex", CAPABILITY_APPROXIMATE, "Uses offense and defense scores as a tactical proxy."),
build_signal("UtilityIndex", CAPABILITY_EXACT, "Uses support points."),
build_signal("LeadershipIndex", CAPABILITY_UNAVAILABLE, "No leadership telemetry exists yet."),
build_signal("DisciplineIndex", CAPABILITY_EXACT, "Uses teamkills; no AFK or leave telemetry exists yet."),
build_signal("StrengthOfSchedule", CAPABILITY_APPROXIMATE, "Currently approximated from match quality and lobby density, not opponent MMR."),
],
},
"component_scores": {
"avg_match_score": avg_match_score,
"mmr_gain_raw": mmr_gain,
"strength_of_schedule": strength_of_schedule,
"consistency": consistency,
"activity": activity,
"confidence": confidence,
"penalty_points": penalty_points,
},
}
)
for rows in grouped_by_scope_month.values():
max_avg = max((row["avg_match_score"] for row in rows), default=1.0) or 1.0
max_gain = max((max(0.0, row["mmr_gain"]) for row in rows), default=1.0) or 1.0
max_sos = max((row["strength_of_schedule"] for row in rows), default=1.0) or 1.0
max_consistency = max((row["consistency"] for row in rows), default=1.0) or 1.0
max_activity = max((row["activity"] for row in rows), default=1.0) or 1.0
max_confidence = max((row["confidence"] for row in rows), default=1.0) or 1.0
for row in rows:
normalized_gain = max(0.0, row["mmr_gain"]) / max_gain if max_gain > 0 else 0.0
row["component_scores"]["normalized_mmr_gain"] = round(normalized_gain * 100.0, 3)
row["monthly_rank_score"] = round(
(0.38 * (row["avg_match_score"] / max_avg) * 100.0)
+ (0.22 * normalized_gain * 100.0)
+ (0.10 * (row["strength_of_schedule"] / max_sos) * 100.0)
+ (0.12 * (row["consistency"] / max_consistency) * 100.0)
+ (0.10 * (row["activity"] / max_activity) * 100.0)
+ (0.08 * (row["confidence"] / max_confidence) * 100.0)
- row["penalty_points"],
3,
)
rankings.append(row)
return rankings
def _build_historical_source_policy_for_elo() -> dict[str, object]:
if get_historical_data_source_kind() != SOURCE_KIND_RCON:
return build_source_policy(
primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
source_attempts=[build_source_attempt(source=SOURCE_KIND_PUBLIC_SCOREBOARD, role="primary", status="success")],
)
return build_source_policy(
primary_source=SOURCE_KIND_RCON,
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
fallback_used=True,
fallback_reason="rcon-historical-read-model-does-not-support-elo-mmr-competitive-calculations-yet",
source_attempts=[
build_source_attempt(source=SOURCE_KIND_RCON, role="primary", status="unsupported", reason="rcon-historical-read-model-does-not-support-elo-mmr-competitive-calculations-yet"),
build_source_attempt(source=SOURCE_KIND_PUBLIC_SCOREBOARD, role="fallback", status="success"),
],
)
def _resolve_match_duration(match_group: dict[str, object], players: list[dict[str, object]]) -> tuple[int, str]:
started_at = _parse_optional_timestamp(match_group.get("started_at"))
ended_at = _parse_optional_timestamp(match_group.get("ended_at"))
if started_at and ended_at and ended_at >= started_at:
return int((ended_at - started_at).total_seconds()), CAPABILITY_EXACT
return max((_safe_int(player.get("time_seconds")) for player in players), default=0), CAPABILITY_APPROXIMATE
def _build_quality_factor(*, player_count: int, duration_seconds: int, has_score: bool) -> float:
player_component = min(1.0, player_count / FULL_QUALITY_PLAYER_COUNT)
duration_component = min(1.0, duration_seconds / FULL_QUALITY_DURATION_SECONDS)
score_component = 1.0 if has_score else 0.7
return round((0.4 * player_component) + (0.4 * duration_component) + (0.2 * score_component), 3)
def _classify_quality_bucket(quality_factor: float) -> str:
if quality_factor >= 0.8:
return QUALITY_BUCKET_HIGH
if quality_factor >= 0.55:
return QUALITY_BUCKET_MEDIUM
return QUALITY_BUCKET_LOW
def _resolve_team_outcome(*, team_side: str, allied_score: int | None, axis_score: int | None) -> str:
if allied_score is None or axis_score is None or allied_score == axis_score:
return "draw"
normalized = team_side.strip().lower()
allied_won = allied_score > axis_score
if normalized.startswith("all"):
return "win" if allied_won else "loss"
if normalized.startswith("ax"):
return "win" if not allied_won else "loss"
return "draw"
def _resolve_role_bucket(player: dict[str, object]) -> str:
axes = {
ROLE_BUCKET_SUPPORT: _safe_int(player.get("support")),
ROLE_BUCKET_OFFENSE: _safe_int(player.get("offense")),
ROLE_BUCKET_DEFENSE: _safe_int(player.get("defense")),
ROLE_BUCKET_COMBAT: _safe_int(player.get("combat")),
}
top_bucket, top_value = max(axes.items(), key=lambda item: item[1])
sorted_values = sorted(axes.values(), reverse=True)
if top_value <= 0 or (len(sorted_values) >= 2 and sorted_values[0] == sorted_values[1]):
return ROLE_BUCKET_GENERALIST
return top_bucket
def _build_consistency_score(rows: list[dict[str, object]]) -> float:
if len(rows) <= 1:
return 100.0 if rows else 0.0
values = [float(row["match_score"]) for row in rows]
average = sum(values) / len(values)
if average <= 0:
return 0.0
return round(100.0 * (1.0 - min(1.0, pstdev(values) / max(average, 1.0))), 3)
def _build_activity_score(rows: list[dict[str, object]], total_time_seconds: int) -> float:
match_component = min(1.0, len(rows) / MONTHLY_ACTIVITY_TARGET_MATCHES)
hour_component = min(1.0, (total_time_seconds / 3600.0) / MONTHLY_ACTIVITY_TARGET_HOURS)
return round(((0.6 * match_component) + (0.4 * hour_component)) * 100.0, 3)
def _normalize_scope_key(server_id: str | None) -> str:
normalized = str(server_id or SCOPE_ALL_SERVERS).strip()
return normalized or SCOPE_ALL_SERVERS
def _parse_optional_timestamp(value: object) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _safe_int(value: object) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,71 @@
"""Contracts and capability helpers for the Elo/MMR monthly ranking system."""
from __future__ import annotations
from dataclasses import asdict, dataclass
CAPABILITY_EXACT = "exact"
CAPABILITY_APPROXIMATE = "approximate"
CAPABILITY_UNAVAILABLE = "not_available"
ACCURACY_EXACT = "exact"
ACCURACY_APPROXIMATE = "approximate"
ACCURACY_PARTIAL = "partial"
DEFAULT_BASE_MMR = 1000.0
MIN_VALID_MATCH_DURATION_SECONDS = 900
MIN_VALID_MATCH_PLAYERS = 20
FULL_QUALITY_PLAYER_COUNT = 70
FULL_QUALITY_DURATION_SECONDS = 3600
MONTHLY_MIN_VALID_MATCHES = 5
MONTHLY_MIN_TIME_SECONDS = 21600
MONTHLY_ACTIVITY_TARGET_MATCHES = 12
MONTHLY_ACTIVITY_TARGET_HOURS = 20.0
DEFAULT_MONTHLY_SCOREBOARD_MIN_MATCHES = 3
@dataclass(frozen=True, slots=True)
class EloSignalAvailability:
"""Normalized availability state for one scoring input."""
name: str
status: str
detail: str
def to_dict(self) -> dict[str, object]:
"""Return the availability entry as a serializable mapping."""
return asdict(self)
def build_signal(name: str, status: str, detail: str) -> dict[str, object]:
"""Create a normalized availability block for one signal."""
return EloSignalAvailability(name=name, status=status, detail=detail).to_dict()
def summarize_accuracy(signals: list[dict[str, object]]) -> dict[str, object]:
"""Summarize exact, approximate and unavailable signals for one calculation."""
exact_count = sum(1 for signal in signals if signal.get("status") == CAPABILITY_EXACT)
approximate_count = sum(
1 for signal in signals if signal.get("status") == CAPABILITY_APPROXIMATE
)
unavailable_count = sum(
1 for signal in signals if signal.get("status") == CAPABILITY_UNAVAILABLE
)
if unavailable_count > 0:
accuracy_mode = ACCURACY_PARTIAL
elif approximate_count > 0:
accuracy_mode = ACCURACY_APPROXIMATE
else:
accuracy_mode = ACCURACY_EXACT
total = max(1, len(signals))
return {
"accuracy_mode": accuracy_mode,
"exact_count": exact_count,
"approximate_count": approximate_count,
"unavailable_count": unavailable_count,
"exact_ratio": round(exact_count / total, 3),
"approximate_ratio": round(approximate_count / total, 3),
"unavailable_ratio": round(unavailable_count / total, 3),
"signals": list(signals),
}

View File

@@ -0,0 +1,558 @@
"""SQLite storage for persistent Elo/MMR and monthly ranking results."""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from .config import get_storage_path
from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer
def initialize_elo_mmr_storage(*, db_path: Path | None = None) -> Path:
"""Create the Elo/MMR persistence tables in the shared backend SQLite."""
resolved_path = _resolve_db_path(db_path)
resolved_path.parent.mkdir(parents=True, exist_ok=True)
with _connect_writer(resolved_path) as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS elo_mmr_player_ratings (
scope_key TEXT NOT NULL,
stable_player_key TEXT NOT NULL,
player_name TEXT NOT NULL,
steam_id TEXT,
current_mmr REAL NOT NULL,
matches_processed INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
draws INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
last_match_id TEXT,
last_match_ended_at TEXT,
accuracy_mode TEXT NOT NULL,
capabilities_json TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (scope_key, stable_player_key)
);
CREATE TABLE IF NOT EXISTS elo_mmr_match_results (
scope_key TEXT NOT NULL,
month_key TEXT NOT NULL,
external_match_id TEXT NOT NULL,
stable_player_key TEXT NOT NULL,
player_name TEXT NOT NULL,
steam_id TEXT,
server_slug TEXT NOT NULL,
server_name TEXT NOT NULL,
match_ended_at TEXT NOT NULL,
match_valid INTEGER NOT NULL,
quality_factor REAL NOT NULL,
quality_bucket TEXT NOT NULL,
role_bucket TEXT NOT NULL,
role_bucket_mode TEXT NOT NULL,
outcome_score REAL NOT NULL,
combat_index REAL NOT NULL,
objective_index REAL,
objective_index_mode TEXT NOT NULL,
utility_index REAL,
utility_index_mode TEXT NOT NULL,
leadership_index REAL,
leadership_index_mode TEXT NOT NULL,
discipline_index REAL,
discipline_index_mode TEXT NOT NULL,
impact_score REAL NOT NULL,
delta_mmr REAL NOT NULL,
mmr_before REAL NOT NULL,
mmr_after REAL NOT NULL,
match_score REAL NOT NULL,
penalty_points REAL NOT NULL,
capabilities_json TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (scope_key, external_match_id, stable_player_key)
);
CREATE TABLE IF NOT EXISTS elo_mmr_monthly_rankings (
scope_key TEXT NOT NULL,
month_key TEXT NOT NULL,
stable_player_key TEXT NOT NULL,
player_name TEXT NOT NULL,
steam_id TEXT,
current_mmr REAL NOT NULL,
baseline_mmr REAL NOT NULL,
mmr_gain REAL NOT NULL,
avg_match_score REAL NOT NULL,
strength_of_schedule REAL NOT NULL,
consistency REAL NOT NULL,
activity REAL NOT NULL,
confidence REAL NOT NULL,
penalty_points REAL NOT NULL,
monthly_rank_score REAL NOT NULL,
valid_matches INTEGER NOT NULL,
total_matches INTEGER NOT NULL,
total_time_seconds INTEGER NOT NULL,
eligible INTEGER NOT NULL,
eligibility_reason TEXT,
accuracy_mode TEXT NOT NULL,
capabilities_json TEXT NOT NULL,
component_scores_json TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (scope_key, month_key, stable_player_key)
);
CREATE TABLE IF NOT EXISTS elo_mmr_monthly_checkpoints (
scope_key TEXT NOT NULL,
month_key TEXT NOT NULL,
generated_at TEXT NOT NULL,
player_count INTEGER NOT NULL,
eligible_player_count INTEGER NOT NULL,
source_policy_json TEXT NOT NULL,
capabilities_summary_json TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (scope_key, month_key)
);
CREATE INDEX IF NOT EXISTS idx_elo_mmr_monthly_rankings_scope_month
ON elo_mmr_monthly_rankings(scope_key, month_key, eligible, monthly_rank_score DESC);
CREATE INDEX IF NOT EXISTS idx_elo_mmr_player_ratings_scope
ON elo_mmr_player_ratings(scope_key, current_mmr DESC);
"""
)
return resolved_path
def replace_elo_mmr_state(
*,
player_ratings: list[dict[str, object]],
match_results: list[dict[str, object]],
monthly_rankings: list[dict[str, object]],
monthly_checkpoints: list[dict[str, object]],
db_path: Path | None = None,
) -> Path:
"""Replace the persisted Elo/MMR state with a freshly rebuilt dataset."""
resolved_path = initialize_elo_mmr_storage(db_path=db_path)
with _connect_writer(resolved_path) as connection:
connection.execute("DELETE FROM elo_mmr_monthly_checkpoints")
connection.execute("DELETE FROM elo_mmr_monthly_rankings")
connection.execute("DELETE FROM elo_mmr_match_results")
connection.execute("DELETE FROM elo_mmr_player_ratings")
connection.executemany(
"""
INSERT INTO elo_mmr_player_ratings (
scope_key,
stable_player_key,
player_name,
steam_id,
current_mmr,
matches_processed,
wins,
draws,
losses,
last_match_id,
last_match_ended_at,
accuracy_mode,
capabilities_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
row["scope_key"],
row["stable_player_key"],
row["player_name"],
row.get("steam_id"),
row["current_mmr"],
row["matches_processed"],
row["wins"],
row["draws"],
row["losses"],
row.get("last_match_id"),
row.get("last_match_ended_at"),
row["accuracy_mode"],
json.dumps(row["capabilities"], ensure_ascii=True, separators=(",", ":")),
)
for row in player_ratings
],
)
connection.executemany(
"""
INSERT INTO elo_mmr_match_results (
scope_key,
month_key,
external_match_id,
stable_player_key,
player_name,
steam_id,
server_slug,
server_name,
match_ended_at,
match_valid,
quality_factor,
quality_bucket,
role_bucket,
role_bucket_mode,
outcome_score,
combat_index,
objective_index,
objective_index_mode,
utility_index,
utility_index_mode,
leadership_index,
leadership_index_mode,
discipline_index,
discipline_index_mode,
impact_score,
delta_mmr,
mmr_before,
mmr_after,
match_score,
penalty_points,
capabilities_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
row["scope_key"],
row["month_key"],
row["external_match_id"],
row["stable_player_key"],
row["player_name"],
row.get("steam_id"),
row["server_slug"],
row["server_name"],
row["match_ended_at"],
1 if row["match_valid"] else 0,
row["quality_factor"],
row["quality_bucket"],
row["role_bucket"],
row["role_bucket_mode"],
row["outcome_score"],
row["combat_index"],
row.get("objective_index"),
row["objective_index_mode"],
row.get("utility_index"),
row["utility_index_mode"],
row.get("leadership_index"),
row["leadership_index_mode"],
row.get("discipline_index"),
row["discipline_index_mode"],
row["impact_score"],
row["delta_mmr"],
row["mmr_before"],
row["mmr_after"],
row["match_score"],
row["penalty_points"],
json.dumps(row["capabilities"], ensure_ascii=True, separators=(",", ":")),
)
for row in match_results
],
)
connection.executemany(
"""
INSERT INTO elo_mmr_monthly_rankings (
scope_key,
month_key,
stable_player_key,
player_name,
steam_id,
current_mmr,
baseline_mmr,
mmr_gain,
avg_match_score,
strength_of_schedule,
consistency,
activity,
confidence,
penalty_points,
monthly_rank_score,
valid_matches,
total_matches,
total_time_seconds,
eligible,
eligibility_reason,
accuracy_mode,
capabilities_json,
component_scores_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
row["scope_key"],
row["month_key"],
row["stable_player_key"],
row["player_name"],
row.get("steam_id"),
row["current_mmr"],
row["baseline_mmr"],
row["mmr_gain"],
row["avg_match_score"],
row["strength_of_schedule"],
row["consistency"],
row["activity"],
row["confidence"],
row["penalty_points"],
row["monthly_rank_score"],
row["valid_matches"],
row["total_matches"],
row["total_time_seconds"],
1 if row["eligible"] else 0,
row.get("eligibility_reason"),
row["accuracy_mode"],
json.dumps(row["capabilities"], ensure_ascii=True, separators=(",", ":")),
json.dumps(row["component_scores"], ensure_ascii=True, separators=(",", ":")),
)
for row in monthly_rankings
],
)
connection.executemany(
"""
INSERT INTO elo_mmr_monthly_checkpoints (
scope_key,
month_key,
generated_at,
player_count,
eligible_player_count,
source_policy_json,
capabilities_summary_json
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
[
(
row["scope_key"],
row["month_key"],
row["generated_at"],
row["player_count"],
row["eligible_player_count"],
json.dumps(row["source_policy"], ensure_ascii=True, separators=(",", ":")),
json.dumps(
row["capabilities_summary"],
ensure_ascii=True,
separators=(",", ":"),
),
)
for row in monthly_checkpoints
],
)
return resolved_path
def list_elo_mmr_monthly_rankings(
*,
scope_key: str,
limit: int = 10,
month_key: str | None = None,
eligible_only: bool = True,
db_path: Path | None = None,
) -> dict[str, object]:
"""Return the persisted monthly Elo/MMR leaderboard for one scope."""
resolved_path = _resolve_db_path(db_path)
resolved_month_key = month_key or get_latest_elo_mmr_month_key(scope_key=scope_key, db_path=resolved_path)
if not resolved_month_key:
return {
"month_key": None,
"found": False,
"generated_at": None,
"items": [],
"source_policy": None,
"capabilities_summary": None,
}
where_clauses = ["scope_key = ?", "month_key = ?"]
params: list[object] = [scope_key, resolved_month_key]
if eligible_only:
where_clauses.append("eligible = 1")
params.append(limit)
try:
with _connect_readonly(resolved_path) as connection:
checkpoint_row = connection.execute(
"""
SELECT generated_at, source_policy_json, capabilities_summary_json
FROM elo_mmr_monthly_checkpoints
WHERE scope_key = ? AND month_key = ?
""",
(scope_key, resolved_month_key),
).fetchone()
rows = connection.execute(
f"""
SELECT *
FROM elo_mmr_monthly_rankings
WHERE {" AND ".join(where_clauses)}
ORDER BY monthly_rank_score DESC, current_mmr DESC, player_name COLLATE NOCASE ASC
LIMIT ?
""",
params,
).fetchall()
except sqlite3.OperationalError:
return {
"month_key": None,
"found": False,
"generated_at": None,
"items": [],
"source_policy": None,
"capabilities_summary": None,
}
items = []
for index, row in enumerate(rows, start=1):
items.append(
{
"ranking_position": index,
"player": {
"stable_player_key": row["stable_player_key"],
"name": row["player_name"],
"steam_id": row["steam_id"],
},
"persistent_rating": {
"mmr": round(float(row["current_mmr"] or 0.0), 3),
"baseline_mmr": round(float(row["baseline_mmr"] or 0.0), 3),
"mmr_gain": round(float(row["mmr_gain"] or 0.0), 3),
},
"monthly_rank_score": round(float(row["monthly_rank_score"] or 0.0), 3),
"components": json.loads(row["component_scores_json"]),
"valid_matches": int(row["valid_matches"] or 0),
"total_matches": int(row["total_matches"] or 0),
"total_time_seconds": int(row["total_time_seconds"] or 0),
"eligible": bool(row["eligible"]),
"eligibility_reason": row["eligibility_reason"],
"accuracy_mode": row["accuracy_mode"],
"capabilities": json.loads(row["capabilities_json"]),
}
)
return {
"month_key": resolved_month_key,
"found": bool(items),
"generated_at": checkpoint_row["generated_at"] if checkpoint_row else None,
"items": items,
"source_policy": json.loads(checkpoint_row["source_policy_json"])
if checkpoint_row
else None,
"capabilities_summary": json.loads(checkpoint_row["capabilities_summary_json"])
if checkpoint_row
else None,
}
def get_elo_mmr_player_profile(
*,
player_id: str,
scope_key: str,
month_key: str | None = None,
db_path: Path | None = None,
) -> dict[str, object] | None:
"""Return the persisted rating and monthly ranking profile for one player."""
resolved_player_id = player_id.strip()
if not resolved_player_id:
return None
resolved_path = _resolve_db_path(db_path)
resolved_month_key = month_key or get_latest_elo_mmr_month_key(scope_key=scope_key, db_path=resolved_path)
try:
with _connect_readonly(resolved_path) as connection:
rating_row = connection.execute(
"""
SELECT *
FROM elo_mmr_player_ratings
WHERE scope_key = ?
AND (stable_player_key = ? OR steam_id = ?)
ORDER BY updated_at DESC
LIMIT 1
""",
(scope_key, resolved_player_id, resolved_player_id),
).fetchone()
monthly_row = None
if resolved_month_key:
monthly_row = connection.execute(
"""
SELECT *
FROM elo_mmr_monthly_rankings
WHERE scope_key = ?
AND month_key = ?
AND (stable_player_key = ? OR steam_id = ?)
ORDER BY updated_at DESC
LIMIT 1
""",
(scope_key, resolved_month_key, resolved_player_id, resolved_player_id),
).fetchone()
except sqlite3.OperationalError:
return None
if rating_row is None and monthly_row is None:
return None
return {
"scope_key": scope_key,
"month_key": resolved_month_key,
"player": {
"stable_player_key": (
rating_row["stable_player_key"] if rating_row else monthly_row["stable_player_key"]
),
"name": rating_row["player_name"] if rating_row else monthly_row["player_name"],
"steam_id": rating_row["steam_id"] if rating_row else monthly_row["steam_id"],
},
"persistent_rating": (
{
"mmr": round(float(rating_row["current_mmr"] or 0.0), 3),
"matches_processed": int(rating_row["matches_processed"] or 0),
"wins": int(rating_row["wins"] or 0),
"draws": int(rating_row["draws"] or 0),
"losses": int(rating_row["losses"] or 0),
"last_match_id": rating_row["last_match_id"],
"last_match_ended_at": rating_row["last_match_ended_at"],
"accuracy_mode": rating_row["accuracy_mode"],
"capabilities": json.loads(rating_row["capabilities_json"]),
}
if rating_row
else None
),
"monthly_ranking": (
{
"monthly_rank_score": round(float(monthly_row["monthly_rank_score"] or 0.0), 3),
"current_mmr": round(float(monthly_row["current_mmr"] or 0.0), 3),
"baseline_mmr": round(float(monthly_row["baseline_mmr"] or 0.0), 3),
"mmr_gain": round(float(monthly_row["mmr_gain"] or 0.0), 3),
"valid_matches": int(monthly_row["valid_matches"] or 0),
"total_matches": int(monthly_row["total_matches"] or 0),
"total_time_seconds": int(monthly_row["total_time_seconds"] or 0),
"eligible": bool(monthly_row["eligible"]),
"eligibility_reason": monthly_row["eligibility_reason"],
"accuracy_mode": monthly_row["accuracy_mode"],
"components": json.loads(monthly_row["component_scores_json"]),
"capabilities": json.loads(monthly_row["capabilities_json"]),
}
if monthly_row
else None
),
}
def get_latest_elo_mmr_month_key(
*,
scope_key: str,
db_path: Path | None = None,
) -> str | None:
"""Return the latest month key available for one Elo/MMR scope."""
resolved_path = _resolve_db_path(db_path)
try:
with _connect_readonly(resolved_path) as connection:
row = connection.execute(
"""
SELECT MAX(month_key) AS latest_month_key
FROM elo_mmr_monthly_checkpoints
WHERE scope_key = ?
""",
(scope_key,),
).fetchone()
except sqlite3.OperationalError:
return None
return str(row["latest_month_key"]) if row and row["latest_month_key"] else None
def _connect_writer(db_path: Path):
return connect_sqlite_writer(db_path)
def _connect_readonly(db_path: Path):
return connect_sqlite_readonly(db_path)
def _resolve_db_path(db_path: Path | None) -> Path:
return db_path or get_storage_path()

View File

@@ -13,6 +13,7 @@ from .config import (
get_historical_refresh_overlap_hours,
)
from .data_sources import HistoricalDataSource, resolve_historical_ingestion_data_source
from .elo_mmr_engine import rebuild_elo_mmr_models
from .historical_snapshots import generate_and_persist_historical_snapshots
from .historical_storage import (
finalize_backfill_progress,
@@ -187,12 +188,17 @@ def _run_ingestion(
active_runs.pop(str(server["slug"]), None)
if rebuild_snapshots:
snapshot_result = generate_and_persist_historical_snapshots(server_key=server_slug)
elo_mmr_result = rebuild_elo_mmr_models()
else:
snapshot_result = {
"status": "skipped",
"reason": "snapshot-rebuild-disabled",
"generation_policy": "handled-by-caller",
}
elo_mmr_result = {
"status": "skipped",
"reason": "snapshot-rebuild-disabled",
}
except Exception as exc:
for active_server_slug, run_id in active_runs.items():
finalize_ingestion_run(
@@ -227,6 +233,7 @@ def _run_ingestion(
"servers": processed_servers,
"coverage": list_historical_coverage_report(server_slug=server_slug),
"snapshot_result": snapshot_result,
"elo_mmr_result": elo_mmr_result,
"totals": {
"pages_processed": stats.pages_processed,
"matches_seen": stats.matches_seen,

View File

@@ -15,6 +15,7 @@ from .config import (
get_historical_refresh_retry_delay_seconds,
get_historical_data_source_kind,
)
from .elo_mmr_engine import rebuild_elo_mmr_models
from .historical_ingestion import run_incremental_refresh
from .historical_snapshots import (
generate_and_persist_historical_snapshots,
@@ -116,6 +117,7 @@ def _run_refresh_with_retries(
server_slug=server_slug,
run_number=run_number,
)
elo_mmr_result = rebuild_elo_mmr_models()
else:
refresh_result = {
"status": "skipped",
@@ -126,6 +128,10 @@ def _run_refresh_with_retries(
"reason": "rcon-primary-cycle-no-classic-fallback-needed",
"generation_policy": "classic-historical-fallback-only",
}
elo_mmr_result = {
"status": "skipped",
"reason": "rcon-primary-cycle-no-classic-fallback-needed",
}
return {
"status": "ok",
"attempts_used": attempt,
@@ -135,6 +141,7 @@ def _run_refresh_with_retries(
"classic_fallback_reason": classic_fallback_reason,
"refresh_result": refresh_result,
"snapshot_result": snapshot_result,
"elo_mmr_result": elo_mmr_result,
}
except Exception as exc:
if attempt > max_retries:

View File

@@ -15,9 +15,15 @@ from .data_sources import (
SOURCE_KIND_RCON,
build_source_attempt,
build_source_policy,
build_historical_runtime_source_policy,
describe_historical_runtime_policy,
get_live_data_source,
get_rcon_historical_read_model,
)
from .elo_mmr_engine import (
get_elo_mmr_player_payload,
list_elo_mmr_leaderboard_payload,
)
from .historical_snapshot_storage import get_historical_snapshot
from .historical_snapshots import (
DEFAULT_MONTHLY_SNAPSHOT_WINDOW,
@@ -57,6 +63,12 @@ def build_health_payload() -> dict[str, str]:
"phase": "bootstrap",
"live_data_source": get_live_data_source_kind(),
"historical_data_source": get_historical_data_source_kind(),
"historical_runtime_policy": describe_historical_runtime_policy()["mode"],
"live_runtime_policy": (
"rcon-first-with-a2s-fallback"
if get_live_data_source_kind() == SOURCE_KIND_RCON
else "a2s-primary"
),
}
@@ -905,6 +917,64 @@ def build_historical_player_profile_payload(player_id: str) -> dict[str, object]
}
def build_elo_mmr_leaderboard_payload(
*,
limit: int = 10,
server_id: str | None = None,
) -> dict[str, object]:
"""Return the current Elo/MMR monthly leaderboard."""
payload = list_elo_mmr_leaderboard_payload(server_id=server_id, limit=limit)
is_all_servers = server_id == ALL_SERVERS_SLUG
return {
"status": "ok",
"data": {
"title": (
"Leaderboard mensual Elo/MMR global"
if is_all_servers
else "Leaderboard mensual Elo/MMR por servidor"
),
"context": "historical-elo-mmr-leaderboard",
"source": "elo-mmr-persisted-read-model",
"server_slug": server_id,
"month_key": payload.get("month_key"),
"found": bool(payload.get("found")),
"generated_at": payload.get("generated_at"),
"limit": limit,
**(payload.get("source_policy") or _resolve_historical_fallback_policy(
operation="elo-mmr-leaderboard",
fallback_reason="elo-mmr-source-policy-missing",
)),
"capabilities_summary": payload.get("capabilities_summary"),
"items": payload.get("items") or [],
},
}
def build_elo_mmr_player_payload(
*,
player_id: str,
server_id: str | None = None,
) -> dict[str, object]:
"""Return one Elo/MMR player profile."""
profile = get_elo_mmr_player_payload(player_id=player_id, server_id=server_id)
return {
"status": "ok",
"data": {
"title": "Perfil Elo/MMR de jugador",
"context": "historical-elo-mmr-player",
"source": "elo-mmr-persisted-read-model",
"player_id": player_id,
"server_slug": server_id,
"found": profile is not None,
**_resolve_historical_fallback_policy(
operation="elo-mmr-player",
fallback_reason="rcon-historical-read-model-does-not-support-elo-mmr-competitive-calculations-yet",
),
"profile": profile,
},
}
def _get_historical_snapshot_record(
*,
server_key: str | None,
@@ -1164,38 +1234,15 @@ def _to_snapshot_age_minutes(snapshot_age_seconds: int | None) -> int | None:
return snapshot_age_seconds // 60
def _resolve_historical_fallback_policy(*, fallback_reason: str) -> dict[str, object]:
if get_historical_data_source_kind() != SOURCE_KIND_RCON:
return build_source_policy(
primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
source_attempts=[
build_source_attempt(
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
role="primary",
status="success",
)
],
)
return build_source_policy(
primary_source=SOURCE_KIND_RCON,
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
fallback_used=True,
def _resolve_historical_fallback_policy(
*,
fallback_reason: str,
operation: str = "historical-read",
) -> dict[str, object]:
return build_historical_runtime_source_policy(
operation=operation,
rcon_status="unsupported",
fallback_reason=fallback_reason,
source_attempts=[
build_source_attempt(
source=SOURCE_KIND_RCON,
role="primary",
status="unsupported",
reason=fallback_reason,
),
build_source_attempt(
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
role="fallback",
status="success",
),
],
)

View File

@@ -64,6 +64,8 @@ def describe_rcon_historical_read_model() -> dict[str, object]:
"/api/historical/leaderboard",
"/api/historical/monthly-mvp",
"/api/historical/monthly-mvp-v2",
"/api/historical/elo-mmr/leaderboard",
"/api/historical/elo-mmr/player",
"/api/historical/player-events",
"/api/historical/player-profile",
"/api/historical/snapshots/*",

View File

@@ -8,6 +8,8 @@ from urllib.parse import parse_qs, urlparse
from .payloads import (
build_community_payload,
build_discord_payload,
build_elo_mmr_leaderboard_payload,
build_elo_mmr_player_payload,
build_error_payload,
build_health_payload,
build_historical_leaderboard_payload,
@@ -268,6 +270,27 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
return HTTPStatus.BAD_REQUEST, build_error_payload("Player parameter is required")
return HTTPStatus.OK, build_historical_player_profile_payload(player_id)
if parsed.path == "/api/historical/elo-mmr/leaderboard":
limit = _parse_limit(parsed.query)
if limit is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
server_id = parse_qs(parsed.query).get("server", [None])[0]
return HTTPStatus.OK, build_elo_mmr_leaderboard_payload(
limit=limit,
server_id=server_id,
)
if parsed.path == "/api/historical/elo-mmr/player":
params = parse_qs(parsed.query)
player_id = params.get("player", [None])[0]
if not player_id:
return HTTPStatus.BAD_REQUEST, build_error_payload("Player parameter is required")
server_id = params.get("server", [None])[0]
return HTTPStatus.OK, build_elo_mmr_player_payload(
player_id=player_id,
server_id=server_id,
)
builder = GET_ROUTES.get(parsed.path)
if builder is None:
if parsed.path.startswith("/api/servers/") and parsed.path.endswith("/history"):