File size: 13,129 Bytes
a1b0768
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
"""Elo vs tactical/positional playstyle scatter and 0–100 style scale for the web UI."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

import joblib
import numpy as np
import pandas as pd

from chess_tutor.inference.pipeline import InferenceResult
from chess_tutor.paths import (
    default_cohort_db_path,
    default_web_style_artifacts_path,
)
from chess_tutor.playstyle_coaching.data import load_snapshots_df
from chess_tutor.playstyle_coaching.teacher import bracket_mid_elo

TACTICAL_FEATURES: tuple[str, ...] = (
    "profile_agg_cctx_capture_under_sharp_rate_mean",
    "profile_agg_cctx_king_unsafe_and_sharp_rate_mean",
    "profile_agg_move_capture_given_pband_tension_sharp_rate_mean",
    "profile_agg_ten_adds_tension_rate_mean",
    "profile_agg_cxp_sharpen_given_pband_tension_sharp_rate_mean",
)

POSITIONAL_FEATURES: tuple[str, ...] = (
    "profile_agg_human_quiet_in_sharp_position_rate_mean",
    "profile_agg_cxp_simplify_given_pband_tension_sharp_rate_mean",
    "profile_agg_human_pawn_break_rate_mean",
    "profile_agg_cctx_simplify_when_complex_rate_mean",
    "profile_agg_pband_tension_quiet_rate_mean",
    "profile_agg_theme_quietMove_rate_mean",
)

STYLE_ZONES: tuple[dict[str, Any], ...] = (
    {
        "min": 0,
        "max": 20,
        "id": "extremely_strategic",
        "label": "Extremely strategic",
        "short_label": "Ext. strategic",
        "color": "#4c78a8",
    },
    {
        "min": 20,
        "max": 40,
        "id": "strategic",
        "label": "Strategic",
        "short_label": "Strategic",
        "color": "#72b7b2",
    },
    {
        "min": 40,
        "max": 60,
        "id": "balanced",
        "label": "Balanced",
        "short_label": "Balanced",
        "color": "#54a24b",
    },
    {
        "min": 60,
        "max": 80,
        "id": "aggressive",
        "label": "Aggressive",
        "short_label": "Aggressive",
        "color": "#f58518",
    },
    {
        "min": 80,
        "max": 100,
        "id": "extremely_aggressive",
        "label": "Extremely aggressive",
        "short_label": "Ext. aggressive",
        "color": "#e45756",
    },
)

ELO_COLUMN = "meta_avg_player_elo_snapshot"
BRACKET_COLUMN = "elo_bracket"


@dataclass(frozen=True)
class StyleCohortData:
    elos: list[float]
    styles: list[float]
    brackets: list[str]
    usernames: list[str]
    user_style: float
    user_elo: float
    username: str
    user_bracket: str
    n_tactical_features: int
    n_positional_features: int


@dataclass(frozen=True)
class StyleCohortArtifacts:
    tactical_cols: list[str]
    positional_cols: list[str]
    tactical_means: dict[str, float]
    tactical_stds: dict[str, float]
    positional_means: dict[str, float]
    positional_stds: dict[str, float]
    elos: list[float]
    styles: list[float]
    brackets: list[str]
    usernames: list[str]
    n_tactical_features: int
    n_positional_features: int


_STYLE_COHORT_ARTIFACTS_CACHE: dict[str, StyleCohortArtifacts] = {}


def _try_load_style_artifacts(corpus_id: str) -> StyleCohortArtifacts | None:
    if corpus_id in _STYLE_COHORT_ARTIFACTS_CACHE:
        return _STYLE_COHORT_ARTIFACTS_CACHE[corpus_id]
    path = default_web_style_artifacts_path(corpus_id)
    if not path.is_file():
        return None
    try:
        obj = joblib.load(path)
    except Exception:
        return None
    try:
        artifacts = StyleCohortArtifacts(**obj)
    except Exception:
        return None
    _STYLE_COHORT_ARTIFACTS_CACHE[corpus_id] = artifacts
    return artifacts


def _save_style_artifacts(corpus_id: str, artifacts: StyleCohortArtifacts) -> None:
    path = default_web_style_artifacts_path(corpus_id)
    try:
        joblib.dump(
            {
                "tactical_cols": artifacts.tactical_cols,
                "positional_cols": artifacts.positional_cols,
                "tactical_means": artifacts.tactical_means,
                "tactical_stds": artifacts.tactical_stds,
                "positional_means": artifacts.positional_means,
                "positional_stds": artifacts.positional_stds,
                "elos": artifacts.elos,
                "styles": artifacts.styles,
                "brackets": artifacts.brackets,
                "usernames": artifacts.usernames,
                "n_tactical_features": artifacts.n_tactical_features,
                "n_positional_features": artifacts.n_positional_features,
            },
            path,
        )
    except Exception:
        # Cache is best-effort only.
        pass


def _build_style_cohort_artifacts(
    corpus_id: str,
) -> StyleCohortArtifacts | None:
    db_path = default_cohort_db_path(corpus_id)
    if not db_path.is_file():
        return None

    try:
        cohort = load_snapshots_df(db_path, corpus_id)
    except (ValueError, OSError):
        return None

    if cohort.empty or BRACKET_COLUMN not in cohort.columns:
        return None

    cohort = cohort.dropna(subset=[BRACKET_COLUMN]).copy()
    tactical_cols = _available_features(cohort, TACTICAL_FEATURES)
    positional_cols = _available_features(cohort, POSITIONAL_FEATURES)
    if not tactical_cols or not positional_cols:
        return None

    tactical_frame = cohort[tactical_cols].astype(float)
    positional_frame = cohort[positional_cols].astype(float)

    tactical_means = {col: float(tactical_frame[col].mean()) for col in tactical_cols}
    tactical_stds = {
        col: float(tactical_frame[col].std(ddof=0)) or 1.0 for col in tactical_cols
    }
    positional_means = {
        col: float(positional_frame[col].mean()) for col in positional_cols
    }
    positional_stds = {
        col: float(positional_frame[col].std(ddof=0)) or 1.0
        for col in positional_cols
    }

    for col in tactical_cols:
        if tactical_stds[col] < 1e-12:
            tactical_stds[col] = 1.0
    for col in positional_cols:
        if positional_stds[col] < 1e-12:
            positional_stds[col] = 1.0

    elos: list[float] = []
    styles: list[float] = []
    brackets: list[str] = []
    usernames: list[str] = []

    for _, row in cohort.iterrows():
        elo = _listed_elo(row)
        if elo is None:
            continue
        style = _style_axis(
            row,
            tactical_cols,
            positional_cols,
            tactical_means,
            tactical_stds,
            positional_means,
            positional_stds,
        )
        if not np.isfinite(style):
            continue
        elos.append(elo)
        styles.append(style)
        brackets.append(str(row[BRACKET_COLUMN]))
        usernames.append(str(row.get("username", "")))

    if len(elos) < 20:
        return None

    return StyleCohortArtifacts(
        tactical_cols=tactical_cols,
        positional_cols=positional_cols,
        tactical_means=tactical_means,
        tactical_stds=tactical_stds,
        positional_means=positional_means,
        positional_stds=positional_stds,
        elos=elos,
        styles=styles,
        brackets=brackets,
        usernames=usernames,
        n_tactical_features=len(tactical_cols),
        n_positional_features=len(positional_cols),
    )


def build_style_cohort_artifacts(corpus_id: str = "standard_600") -> bool:
    """Precompute style cohort artifacts for the web UI."""
    artifacts = _build_style_cohort_artifacts(corpus_id)
    if artifacts is None:
        return False
    _STYLE_COHORT_ARTIFACTS_CACHE[corpus_id] = artifacts
    _save_style_artifacts(corpus_id, artifacts)
    return True


def _available_features(
    cohort: pd.DataFrame,
    features: tuple[str, ...],
) -> list[str]:
    return [col for col in features if col in cohort.columns]


def _listed_elo(row: pd.Series) -> float | None:
    if ELO_COLUMN in row.index:
        val = row.get(ELO_COLUMN)
        if val is not None and not (isinstance(val, float) and np.isnan(val)):
            elo = float(val)
            if elo > 0.0:
                return elo
    bracket = row.get(BRACKET_COLUMN)
    if bracket is not None and str(bracket).strip():
        return bracket_mid_elo(str(bracket))
    return None


def _group_zscore_mean(
    row: pd.Series,
    cols: list[str],
    means: dict[str, float],
    stds: dict[str, float],
) -> float:
    zs: list[float] = []
    for col in cols:
        if col not in row.index:
            continue
        val = row.get(col)
        if val is None or (isinstance(val, float) and np.isnan(val)):
            continue
        zs.append((float(val) - means[col]) / stds[col])
    if not zs:
        return 0.0
    return float(np.mean(zs))


def _style_axis(
    row: pd.Series,
    tactical_cols: list[str],
    positional_cols: list[str],
    tactical_means: dict[str, float],
    tactical_stds: dict[str, float],
    positional_means: dict[str, float],
    positional_stds: dict[str, float],
) -> float:
    tactical = _group_zscore_mean(row, tactical_cols, tactical_means, tactical_stds)
    positional = _group_zscore_mean(row, positional_cols, positional_means, positional_stds)
    return tactical - positional


def _percentile_score(styles: list[float], user_style: float) -> float:
    arr = np.asarray(styles, dtype=float)
    below = float(np.sum(arr < user_style))
    equal = float(np.sum(arr == user_style))
    pct = (below + 0.5 * equal) / len(arr) * 100.0
    return round(min(100.0, max(0.0, pct)), 1)


def _zone_for_score(score: float) -> dict[str, Any]:
    value = min(100.0, max(0.0, score))
    for zone in STYLE_ZONES:
        if zone["min"] <= value < zone["max"]:
            return zone
    return STYLE_ZONES[-1]


def _load_style_cohort_data(
    result: InferenceResult,
    *,
    corpus_id: str = "standard_600",
) -> StyleCohortData | None:
    artifacts = _try_load_style_artifacts(corpus_id)
    if artifacts is None:
        artifacts = _build_style_cohort_artifacts(corpus_id)
        if artifacts is None:
            return None
        _STYLE_COHORT_ARTIFACTS_CACHE[corpus_id] = artifacts
        _save_style_artifacts(corpus_id, artifacts)

    user_row = result.snapshot.to_series()
    user_elo = _listed_elo(user_row)
    if user_elo is None:
        user_elo = float(result.player_elo)
    user_style = _style_axis(
        user_row,
        artifacts.tactical_cols,
        artifacts.positional_cols,
        artifacts.tactical_means,
        artifacts.tactical_stds,
        artifacts.positional_means,
        artifacts.positional_stds,
    )
    if not np.isfinite(user_style):
        return None

    return StyleCohortData(
        elos=artifacts.elos,
        styles=artifacts.styles,
        brackets=artifacts.brackets,
        usernames=artifacts.usernames,
        user_style=user_style,
        user_elo=user_elo,
        username=result.username,
        user_bracket=str(result.snapshot.elo_bracket),
        n_tactical_features=artifacts.n_tactical_features,
        n_positional_features=artifacts.n_positional_features,
    )


def build_style_scale(
    result: InferenceResult,
    *,
    corpus_id: str = "standard_600",
) -> dict[str, Any] | None:
    """
    Map the player onto a 0–100 scale (0 = positional/strategic, 100 = tactical/aggressive)
    using cohort percentiles.
    """
    data = _load_style_cohort_data(result, corpus_id=corpus_id)
    if data is None:
        return None

    score = _percentile_score(data.styles, data.user_style)
    zone = _zone_for_score(score)
    return {
        "score": score,
        "zone_id": zone["id"],
        "zone_label": zone["label"],
        "zones": list(STYLE_ZONES),
        "axis_left": "Positional / strategic",
        "axis_right": "Tactical / aggressive",
        "label": data.username,
        "bracket": data.user_bracket,
        "n_total": len(data.styles),
    }


def build_style_elo_plot(
    result: InferenceResult,
    *,
    corpus_id: str = "standard_600",
    max_points: int = 6000,
    seed: int = 42,
) -> dict[str, Any] | None:
    """Place cohort snapshots and the analyzed player on listed Elo (x) vs style (y)."""
    data = _load_style_cohort_data(result, corpus_id=corpus_id)
    if data is None:
        return None

    n_total = len(data.elos)
    indices = np.arange(n_total)
    if n_total > max_points:
        rng = np.random.default_rng(seed)
        indices = np.sort(rng.choice(n_total, size=max_points, replace=False))

    points = [
        {
            "x": round(float(data.elos[i]), 1),
            "y": round(float(data.styles[i]), 4),
            "bracket": data.brackets[i],
            "username": data.usernames[i],
        }
        for i in indices
    ]

    return {
        "axis_labels": ["Listed Elo", "Tactical ← → Positional"],
        "n_total": n_total,
        "n_shown": len(points),
        "n_tactical_features": data.n_tactical_features,
        "n_positional_features": data.n_positional_features,
        "points": points,
        "user": {
            "x": round(float(data.user_elo), 1),
            "y": round(float(data.user_style), 4),
            "label": data.username,
            "bracket": data.user_bracket,
        },
    }