gatchimuchio commited on
Commit
38d6b68
·
verified ·
1 Parent(s): 56193d0

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +833 -0
app.py ADDED
@@ -0,0 +1,833 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py
2
+ # Hugging Face Space (Gradio) for a deterministic "Black-Box Translator" demo (Chess + Shogi).
3
+ # No engines. No learning. Pure rule-based, reproducible heuristics + structured logs.
4
+
5
+ from __future__ import annotations
6
+
7
+ import math
8
+ import os
9
+ import platform
10
+ import re
11
+ import sys
12
+ import traceback
13
+ from dataclasses import dataclass, asdict
14
+ from datetime import datetime, timezone
15
+ from typing import Any, Dict, List, Optional, Tuple
16
+
17
+ import pandas as pd
18
+ import matplotlib.pyplot as plt
19
+ import gradio as gr
20
+
21
+ try:
22
+ from importlib import metadata as importlib_metadata # py3.8+
23
+ except Exception: # pragma: no cover
24
+ import importlib_metadata # type: ignore
25
+
26
+
27
+ # ---------------------------
28
+ # Repro snapshot
29
+ # ---------------------------
30
+
31
+ def _pkg_version(name: str) -> str:
32
+ try:
33
+ return importlib_metadata.version(name)
34
+ except Exception:
35
+ return "unknown"
36
+
37
+ def repro_snapshot() -> Dict[str, Any]:
38
+ return {
39
+ "utc_now": datetime.now(timezone.utc).isoformat(),
40
+ "python": sys.version.replace("\n", " "),
41
+ "platform": platform.platform(),
42
+ "packages": {
43
+ "gradio": _pkg_version("gradio"),
44
+ "pandas": _pkg_version("pandas"),
45
+ "matplotlib": _pkg_version("matplotlib"),
46
+ "chess": _pkg_version("chess"),
47
+ "python-shogi": _pkg_version("python-shogi"),
48
+ },
49
+ }
50
+
51
+
52
+ # ---------------------------
53
+ # Generic scoring/logging
54
+ # ---------------------------
55
+
56
+ AXES_ORDER = [
57
+ "material",
58
+ "king_safety",
59
+ "development",
60
+ "center_control",
61
+ "mobility",
62
+ "tactical_pressure",
63
+ ]
64
+
65
+ DEFAULT_WEIGHTS = {
66
+ "material": 1.8,
67
+ "king_safety": 1.4,
68
+ "development": 1.0,
69
+ "center_control": 1.0,
70
+ "mobility": 0.8,
71
+ "tactical_pressure": 1.2,
72
+ }
73
+
74
+ def clamp(x: float, lo: float = -1.0, hi: float = 1.0) -> float:
75
+ return max(lo, min(hi, x))
76
+
77
+ def weighted_score(axes: Dict[str, float], weights: Dict[str, float]) -> float:
78
+ s = 0.0
79
+ for k, w in weights.items():
80
+ if k in axes and axes[k] is not None:
81
+ s += w * float(axes[k])
82
+ return float(s)
83
+
84
+ def axes_df(before: Dict[str, float], after: Dict[str, float], weights: Dict[str, float]) -> pd.DataFrame:
85
+ rows = []
86
+ for k in AXES_ORDER:
87
+ b = float(before.get(k, 0.0))
88
+ a = float(after.get(k, 0.0))
89
+ d = a - b
90
+ rows.append({
91
+ "axis": k,
92
+ "before": round(b, 4),
93
+ "after": round(a, 4),
94
+ "delta": round(d, 4),
95
+ "weight": float(weights.get(k, 0.0)),
96
+ "weighted_delta": round(d * float(weights.get(k, 0.0)), 4),
97
+ })
98
+ return pd.DataFrame(rows)
99
+
100
+ def build_comment(axis_table: pd.DataFrame, chosen_move: str) -> str:
101
+ df = axis_table.copy()
102
+ df["abs_wd"] = df["weighted_delta"].abs()
103
+ df = df.sort_values("abs_wd", ascending=False)
104
+ top = df.head(2).to_dict("records")
105
+ parts = []
106
+ for r in top:
107
+ sign = "↑" if r["weighted_delta"] >= 0 else "↓"
108
+ parts.append(f"- {r['axis']}: {sign} (Δ={r['delta']}, wΔ={r['weighted_delta']})")
109
+ core = "\n".join(parts) if parts else "- (no decisive axis change detected)"
110
+ return f"**Move:** `{chosen_move}`\n\n**Why (deterministic heuristic):**\n{core}"
111
+
112
+ def build_hds_log(axis_table: pd.DataFrame, score_before: float, score_after: float) -> str:
113
+ df = axis_table.copy()
114
+ df = df.sort_values("weighted_delta", ascending=False)
115
+ top_pos = df.head(2).to_dict("records")
116
+ top_neg = df.tail(2).to_dict("records")
117
+
118
+ def _fmt(rs):
119
+ out = []
120
+ for r in rs:
121
+ sign = "+" if r["weighted_delta"] >= 0 else ""
122
+ out.append(f" - {r['axis']}: Δ={r['delta']} / wΔ={sign}{r['weighted_delta']}")
123
+ return "\n".join(out) if out else " - (none)"
124
+
125
+ lines = []
126
+ lines.append("### HDS Log (deterministic)\n")
127
+ lines.append("**Layer 1 — Validity**")
128
+ lines.append("- Input parsed and move legality checked inside the rules library.")
129
+ lines.append("")
130
+ lines.append("**Layer 2 — Axis deltas (what changed)**")
131
+ lines.append(_fmt(top_pos))
132
+ lines.append(_fmt(top_neg))
133
+ lines.append("")
134
+ lines.append("**Layer 3 — Causal chain (why those deltas happen)**")
135
+ lines.append("- This demo uses only board-state features (no search, no evaluation engine).")
136
+ lines.append("- Axis deltas come from measurable state transitions (piece locations, legal-move counts, checks, center occupancy).")
137
+ lines.append("")
138
+ lines.append("**Layer 4 — Counterfactual hint**")
139
+ lines.append("- Compare with the 'Top alternatives' table below (same heuristic score, same weights).")
140
+ lines.append("")
141
+ lines.append("**Layer 5 — Reproducibility**")
142
+ lines.append(f"- score_before={round(score_before, 4)} / score_after={round(score_after, 4)} / Δ={round(score_after-score_before, 4)}")
143
+ lines.append("- No randomness. Same input => same output (given identical library versions).")
144
+ return "\n".join(lines)
145
+
146
+ def plot_axis_deltas(axis_table: pd.DataFrame) -> Any:
147
+ df = axis_table.copy()
148
+ plt.figure()
149
+ plt.bar(df["axis"], df["weighted_delta"])
150
+ plt.xticks(rotation=30, ha="right")
151
+ plt.title("Weighted delta by axis")
152
+ plt.tight_layout()
153
+ return plt.gcf()
154
+
155
+
156
+ # ---------------------------
157
+ # Chess (python-chess) logic
158
+ # ---------------------------
159
+
160
+ def _import_chess():
161
+ import chess # type: ignore
162
+ return chess
163
+
164
+ PIECE_VALUE_CHESS = {
165
+ "P": 1.0,
166
+ "N": 3.1,
167
+ "B": 3.3,
168
+ "R": 5.1,
169
+ "Q": 9.5,
170
+ "K": 0.0,
171
+ }
172
+
173
+ CENTER_SQUARES_CHESS = ["d4", "e4", "d5", "e5"]
174
+
175
+ def chess_color_from_perspective(board, perspective: str):
176
+ chess = _import_chess()
177
+ if perspective == "side_to_move":
178
+ return board.turn
179
+ if perspective == "white":
180
+ return chess.WHITE
181
+ return chess.BLACK
182
+
183
+ def _chess_material(board, me_color) -> float:
184
+ chess = _import_chess()
185
+ score = 0.0
186
+ for piece_type, v in [
187
+ (chess.PAWN, PIECE_VALUE_CHESS["P"]),
188
+ (chess.KNIGHT, PIECE_VALUE_CHESS["N"]),
189
+ (chess.BISHOP, PIECE_VALUE_CHESS["B"]),
190
+ (chess.ROOK, PIECE_VALUE_CHESS["R"]),
191
+ (chess.QUEEN, PIECE_VALUE_CHESS["Q"]),
192
+ ]:
193
+ my = len(board.pieces(piece_type, me_color))
194
+ op = len(board.pieces(piece_type, not me_color))
195
+ score += v * (my - op)
196
+ return clamp(score / 39.0)
197
+
198
+ def _chess_king_safety(board, me_color) -> float:
199
+ chess = _import_chess()
200
+ king_sq = board.king(me_color)
201
+ if king_sq is None:
202
+ return 0.0
203
+ ring = []
204
+ kf = chess.square_file(king_sq)
205
+ kr = chess.square_rank(king_sq)
206
+ for df in (-1, 0, 1):
207
+ for dr in (-1, 0, 1):
208
+ if df == 0 and dr == 0:
209
+ continue
210
+ f = kf + df
211
+ r = kr + dr
212
+ if 0 <= f <= 7 and 0 <= r <= 7:
213
+ ring.append(chess.square(f, r))
214
+ defenders = 0
215
+ attackers = 0
216
+ for sq in ring:
217
+ p = board.piece_at(sq)
218
+ if p is not None and p.color == me_color:
219
+ defenders += 1
220
+ if board.is_attacked_by(not me_color, sq):
221
+ attackers += 1
222
+ raw = (defenders - attackers) / 8.0
223
+ return clamp(raw)
224
+
225
+ def _chess_development(board, me_color) -> float:
226
+ chess = _import_chess()
227
+ dev = 0
228
+ total = 0
229
+ start = {
230
+ chess.WHITE: {
231
+ chess.KNIGHT: [chess.B1, chess.G1],
232
+ chess.BISHOP: [chess.C1, chess.F1],
233
+ },
234
+ chess.BLACK: {
235
+ chess.KNIGHT: [chess.B8, chess.G8],
236
+ chess.BISHOP: [chess.C8, chess.F8],
237
+ },
238
+ }
239
+ for pt in (chess.KNIGHT, chess.BISHOP):
240
+ total += 2
241
+ squares = list(board.pieces(pt, me_color))
242
+ for s in squares:
243
+ if s not in start[me_color][pt]:
244
+ dev += 1
245
+ return clamp((dev - 1.0) / 1.0)
246
+
247
+ def _chess_center_control(board, me_color) -> float:
248
+ chess = _import_chess()
249
+ score = 0.0
250
+ for name in CENTER_SQUARES_CHESS:
251
+ sq = chess.parse_square(name)
252
+ p = board.piece_at(sq)
253
+ if p is not None:
254
+ score += (1.0 if p.color == me_color else -1.0) * 0.5
255
+ if board.is_attacked_by(me_color, sq):
256
+ score += 0.25
257
+ if board.is_attacked_by(not me_color, sq):
258
+ score -= 0.25
259
+ return clamp(score / 2.0)
260
+
261
+ def _chess_mobility(board, me_color) -> float:
262
+ b = board.copy(stack=False)
263
+ b.turn = me_color
264
+ m = b.legal_moves.count()
265
+ return clamp((min(m, 60) - 30) / 30)
266
+
267
+ def _chess_tactical_pressure(board, me_color) -> float:
268
+ chess = _import_chess()
269
+ b = board.copy(stack=False)
270
+ b.turn = me_color
271
+ total = 0
272
+ checks = 0
273
+ captures = 0
274
+ for mv in b.legal_moves:
275
+ total += 1
276
+ if b.is_capture(mv):
277
+ captures += 1
278
+ b2 = b.copy(stack=False)
279
+ b2.push(mv)
280
+ if b2.is_check():
281
+ checks += 1
282
+ if total == 0:
283
+ return 0.0
284
+ raw = 0.6 * (checks / total) + 0.4 * (captures / total)
285
+ return clamp((raw - 0.15) / 0.35)
286
+
287
+ def evaluate_axes_chess(board, perspective: str) -> Dict[str, float]:
288
+ me_color = chess_color_from_perspective(board, perspective)
289
+ axes = {
290
+ "material": _chess_material(board, me_color),
291
+ "king_safety": _chess_king_safety(board, me_color),
292
+ "development": _chess_development(board, me_color),
293
+ "center_control": _chess_center_control(board, me_color),
294
+ "mobility": _chess_mobility(board, me_color),
295
+ "tactical_pressure": _chess_tactical_pressure(board, me_color),
296
+ }
297
+ return {k: float(v) for k, v in axes.items()}
298
+
299
+ def explain_chess_move_en(fen: str, move_uci: str, perspective: str = "side_to_move",
300
+ weights: Dict[str, float] = DEFAULT_WEIGHTS, topk: int = 8):
301
+ chess = _import_chess()
302
+ board = chess.Board(fen)
303
+
304
+ mv = chess.Move.from_uci(move_uci)
305
+ if mv not in board.legal_moves:
306
+ raise ValueError("Illegal move for the given FEN (UCI).")
307
+
308
+ axes_before = evaluate_axes_chess(board, perspective)
309
+ score_before = weighted_score(axes_before, weights)
310
+
311
+ board_after = board.copy(stack=False)
312
+ board_after.push(mv)
313
+
314
+ axes_after = evaluate_axes_chess(board_after, perspective)
315
+ score_after = weighted_score(axes_after, weights)
316
+
317
+ df_axes = axes_df(axes_before, axes_after, weights)
318
+ comment = build_comment(df_axes, move_uci)
319
+ hds_log = build_hds_log(df_axes, score_before, score_after)
320
+
321
+ alt_rows = []
322
+ for cand in board.legal_moves:
323
+ if cand == mv:
324
+ continue
325
+ b2 = board.copy(stack=False)
326
+ b2.push(cand)
327
+ a2 = evaluate_axes_chess(b2, perspective)
328
+ s2 = weighted_score(a2, weights)
329
+ alt_rows.append({
330
+ "move": cand.uci(),
331
+ "score_after": round(s2, 4),
332
+ "delta_vs_chosen": round(s2 - score_after, 4),
333
+ "gives_check": b2.is_check(),
334
+ })
335
+ alt_df = pd.DataFrame(alt_rows)
336
+ if len(alt_df) > 0:
337
+ alt_df = alt_df.sort_values("score_after", ascending=False).head(topk)
338
+
339
+ fig = plot_axis_deltas(df_axes)
340
+
341
+ return {
342
+ "game": "chess",
343
+ "input": {"fen": fen, "move_uci": move_uci, "perspective": perspective},
344
+ "scores": {"before": score_before, "after": score_after, "delta": score_after - score_before},
345
+ "axes_table": df_axes,
346
+ "alternatives": alt_df,
347
+ "comment_md": comment,
348
+ "hds_md": hds_log,
349
+ "plot_fig": fig,
350
+ "repro": repro_snapshot(),
351
+ }
352
+
353
+
354
+ # ---------------------------
355
+ # Shogi (python-shogi) logic
356
+ # ---------------------------
357
+
358
+ def _try_import_shogi():
359
+ import shogi # type: ignore
360
+ return shogi
361
+
362
+ PIECE_VALUE_SHOGI = {
363
+ "P": 1.0,
364
+ "L": 3.0,
365
+ "N": 3.0,
366
+ "S": 4.0,
367
+ "G": 5.0,
368
+ "B": 8.0,
369
+ "R": 10.0,
370
+ "K": 0.0,
371
+ "+P": 5.0,
372
+ "+L": 5.0,
373
+ "+N": 5.0,
374
+ "+S": 5.0,
375
+ "+B": 9.0,
376
+ "+R": 11.0,
377
+ }
378
+
379
+ def _shogi_square_maps(shogi_mod):
380
+ names = getattr(shogi_mod, "SQUARE_NAMES", None)
381
+ if names is None:
382
+ files = list(range(9, 0, -1))
383
+ ranks = list("abcdefghi")
384
+ names = []
385
+ for r in ranks:
386
+ for f in files:
387
+ names.append(f"{f}{r}")
388
+ name_to_sq = {n: i for i, n in enumerate(names)}
389
+ return names, name_to_sq
390
+
391
+ def _shogi_piece_key(piece):
392
+ sym = None
393
+ if hasattr(piece, "symbol"):
394
+ try:
395
+ sym = piece.symbol()
396
+ except Exception:
397
+ sym = None
398
+ if sym is None:
399
+ sym = str(piece)
400
+
401
+ is_black = True
402
+ if len(sym) >= 1 and sym[-1].isalpha():
403
+ is_black = sym[-1].isupper()
404
+
405
+ promo = sym.startswith("+")
406
+ base = sym[-1].upper() if sym[-1].isalpha() else sym[-1]
407
+ key = f"+{base}" if promo else base
408
+ return key, is_black
409
+
410
+ def _shogi_material(board, shogi_mod, me_black: bool) -> float:
411
+ names, _ = _shogi_square_maps(shogi_mod)
412
+ total = 0.0
413
+ squares = getattr(shogi_mod, "SQUARES", list(range(len(names))))
414
+ for sq in squares:
415
+ try:
416
+ p = board.piece_at(sq)
417
+ except Exception:
418
+ p = None
419
+ if p is None:
420
+ continue
421
+ key, is_black = _shogi_piece_key(p)
422
+ v = PIECE_VALUE_SHOGI.get(key, 0.0)
423
+ total += v * (1.0 if (is_black == me_black) else -1.0)
424
+
425
+ try:
426
+ hands = board.pieces_in_hand # type: ignore
427
+ my_hand = hands[shogi_mod.BLACK if me_black else shogi_mod.WHITE]
428
+ op_hand = hands[shogi_mod.WHITE if me_black else shogi_mod.BLACK]
429
+ symtab = getattr(shogi_mod, "PIECE_SYMBOLS", None)
430
+
431
+ def hand_value(hand_obj, sign: float):
432
+ nonlocal total
433
+ if isinstance(hand_obj, dict):
434
+ it = hand_obj.items()
435
+ else:
436
+ it = enumerate(hand_obj)
437
+ for k, cnt in it:
438
+ if cnt is None:
439
+ continue
440
+ if symtab is not None and isinstance(k, int) and 0 <= k < len(symtab):
441
+ sym = symtab[k]
442
+ else:
443
+ sym = str(k)
444
+ sym = sym.upper()
445
+ v = PIECE_VALUE_SHOGI.get(sym, PIECE_VALUE_SHOGI.get(sym[-1], 0.0))
446
+ total += sign * v * float(cnt)
447
+
448
+ hand_value(my_hand, +1.0)
449
+ hand_value(op_hand, -1.0)
450
+ except Exception:
451
+ pass
452
+
453
+ return clamp(total / 60.0)
454
+
455
+ def _shogi_find_king_square(board, shogi_mod, me_black: bool) -> Optional[int]:
456
+ names, _ = _shogi_square_maps(shogi_mod)
457
+ squares = getattr(shogi_mod, "SQUARES", list(range(len(names))))
458
+ for sq in squares:
459
+ p = board.piece_at(sq)
460
+ if p is None:
461
+ continue
462
+ key, is_black = _shogi_piece_key(p)
463
+ if key == "K" and (is_black == me_black):
464
+ return sq
465
+ return None
466
+
467
+ def _shogi_neighbors(sq_name: str, names_set: set) -> List[str]:
468
+ m = re.fullmatch(r"([1-9])([a-i])", sq_name)
469
+ if not m:
470
+ return []
471
+ f = int(m.group(1))
472
+ r = m.group(2)
473
+ ranks = "abcdefghi"
474
+ ri = ranks.index(r)
475
+ out = []
476
+ for df in (-1, 0, 1):
477
+ for dr in (-1, 0, 1):
478
+ if df == 0 and dr == 0:
479
+ continue
480
+ nf = f + df
481
+ nri = ri + dr
482
+ if 1 <= nf <= 9 and 0 <= nri <= 8:
483
+ cand = f"{nf}{ranks[nri]}"
484
+ if cand in names_set:
485
+ out.append(cand)
486
+ return out
487
+
488
+ def _shogi_king_safety(board, shogi_mod, me_black: bool) -> float:
489
+ names, name_to_sq = _shogi_square_maps(shogi_mod)
490
+ king_sq = _shogi_find_king_square(board, shogi_mod, me_black)
491
+ if king_sq is None:
492
+ return 0.0
493
+ king_name = names[king_sq]
494
+ names_set = set(names)
495
+ ring = _shogi_neighbors(king_name, names_set)
496
+
497
+ defenders = 0
498
+ enemies_near = 0
499
+ for n in ring:
500
+ sq = name_to_sq.get(n)
501
+ if sq is None:
502
+ continue
503
+ p = board.piece_at(sq)
504
+ if p is None:
505
+ continue
506
+ _, is_black = _shogi_piece_key(p)
507
+ if is_black == me_black:
508
+ defenders += 1
509
+ else:
510
+ enemies_near += 1
511
+
512
+ raw = (defenders - enemies_near) / 8.0
513
+ return clamp(raw)
514
+
515
+ def _shogi_development(board, shogi_mod, me_black: bool) -> float:
516
+ names, _ = _shogi_square_maps(shogi_mod)
517
+ squares = getattr(shogi_mod, "SQUARES", list(range(len(names))))
518
+ ranks = "abcdefghi"
519
+ adv = []
520
+ for sq in squares:
521
+ p = board.piece_at(sq)
522
+ if p is None:
523
+ continue
524
+ key, is_black = _shogi_piece_key(p)
525
+ if is_black != me_black:
526
+ continue
527
+ base = key[-1]
528
+ if base in ("K", "P"):
529
+ continue
530
+ sqn = names[sq]
531
+ m = re.fullmatch(r"([1-9])([a-i])", sqn)
532
+ if not m:
533
+ continue
534
+ ri = ranks.index(m.group(2))
535
+ a = (8 - ri) if me_black else ri
536
+ adv.append(a / 8.0)
537
+ if not adv:
538
+ return 0.0
539
+ return clamp((sum(adv) / len(adv) - 0.35) / 0.35)
540
+
541
+ def _shogi_center_control(board, shogi_mod, me_black: bool) -> float:
542
+ names, _ = _shogi_square_maps(shogi_mod)
543
+ squares = getattr(shogi_mod, "SQUARES", list(range(len(names))))
544
+ score = 0.0
545
+ for sq in squares:
546
+ p = board.piece_at(sq)
547
+ if p is None:
548
+ continue
549
+ _, is_black = _shogi_piece_key(p)
550
+ sqn = names[sq]
551
+ m = re.fullmatch(r"([1-9])([a-i])", sqn)
552
+ if not m:
553
+ continue
554
+ f = int(m.group(1))
555
+ r = m.group(2)
556
+ if 4 <= f <= 6 and r in ("d", "e", "f"):
557
+ score += 1.0 if (is_black == me_black) else -1.0
558
+ return clamp(score / 6.0)
559
+
560
+ def _shogi_mobility(board, shogi_mod, me_black: bool) -> float:
561
+ b = shogi_mod.Board(board.sfen()) if hasattr(board, "sfen") else board
562
+ try:
563
+ b.turn = shogi_mod.BLACK if me_black else shogi_mod.WHITE
564
+ except Exception:
565
+ pass
566
+ try:
567
+ m = len(list(b.legal_moves))
568
+ except Exception:
569
+ m = 0
570
+ return clamp((min(m, 250) - 80) / 80)
571
+
572
+ def _shogi_tactical_pressure(board, shogi_mod, me_black: bool) -> float:
573
+ names, name_to_sq = _shogi_square_maps(shogi_mod)
574
+ b = shogi_mod.Board(board.sfen()) if hasattr(board, "sfen") else board
575
+ try:
576
+ b.turn = shogi_mod.BLACK if me_black else shogi_mod.WHITE
577
+ except Exception:
578
+ pass
579
+
580
+ def move_to_name(move_str: str) -> Optional[str]:
581
+ m = re.fullmatch(r"([1-9][a-i])([1-9][a-i])(\+)?", move_str)
582
+ if m:
583
+ return m.group(2)
584
+ m = re.fullmatch(r"[PLNSGBRK]\*([1-9][a-i])", move_str)
585
+ if m:
586
+ return m.group(1)
587
+ return None
588
+
589
+ total = 0
590
+ checks = 0
591
+ captureish = 0
592
+
593
+ try:
594
+ legal = list(b.legal_moves)
595
+ except Exception:
596
+ legal = []
597
+
598
+ for mv in legal:
599
+ total += 1
600
+ mv_usi = mv.usi() if hasattr(mv, "usi") else str(mv)
601
+
602
+ dest = move_to_name(mv_usi)
603
+ if dest is not None:
604
+ sq = name_to_sq.get(dest)
605
+ if sq is not None:
606
+ p = b.piece_at(sq)
607
+ if p is not None:
608
+ _, is_black = _shogi_piece_key(p)
609
+ if is_black != me_black:
610
+ captureish += 1
611
+
612
+ try:
613
+ b2 = shogi_mod.Board(b.sfen())
614
+ b2.push_usi(mv_usi)
615
+ if hasattr(b2, "is_check") and b2.is_check():
616
+ checks += 1
617
+ except Exception:
618
+ pass
619
+
620
+ if total == 0:
621
+ return 0.0
622
+ raw = 0.6 * (checks / total) + 0.4 * (captureish / total)
623
+ return clamp((raw - 0.10) / 0.30)
624
+
625
+ def evaluate_axes_shogi(board, shogi_mod, perspective: str) -> Dict[str, float]:
626
+ if perspective == "side_to_move":
627
+ me_black = (board.turn == shogi_mod.BLACK)
628
+ elif perspective == "black":
629
+ me_black = True
630
+ else:
631
+ me_black = False
632
+
633
+ axes = {
634
+ "material": _shogi_material(board, shogi_mod, me_black),
635
+ "king_safety": _shogi_king_safety(board, shogi_mod, me_black),
636
+ "development": _shogi_development(board, shogi_mod, me_black),
637
+ "center_control": _shogi_center_control(board, shogi_mod, me_black),
638
+ "mobility": _shogi_mobility(board, shogi_mod, me_black),
639
+ "tactical_pressure": _shogi_tactical_pressure(board, shogi_mod, me_black),
640
+ }
641
+ return {k: float(v) for k, v in axes.items()}
642
+
643
+ def explain_shogi_move_en(sfen: str, move_usi: str, perspective: str = "side_to_move",
644
+ weights: Dict[str, float] = DEFAULT_WEIGHTS, topk: int = 8):
645
+ shogi_mod = _try_import_shogi()
646
+ board = shogi_mod.Board(sfen)
647
+
648
+ legal = list(board.legal_moves)
649
+ legal_usi = [(m.usi() if hasattr(m, "usi") else str(m)) for m in legal]
650
+ if move_usi not in legal_usi:
651
+ raise ValueError("Illegal move for the given SFEN (USI).")
652
+
653
+ axes_before = evaluate_axes_shogi(board, shogi_mod, perspective)
654
+ score_before = weighted_score(axes_before, weights)
655
+
656
+ board_after = shogi_mod.Board(board.sfen())
657
+ board_after.push_usi(move_usi)
658
+
659
+ axes_after = evaluate_axes_shogi(board_after, shogi_mod, perspective)
660
+ score_after = weighted_score(axes_after, weights)
661
+
662
+ df_axes = axes_df(axes_before, axes_after, weights)
663
+ comment = build_comment(df_axes, move_usi)
664
+ hds_log = build_hds_log(df_axes, score_before, score_after)
665
+
666
+ alt_rows = []
667
+ for cand in legal_usi:
668
+ if cand == move_usi:
669
+ continue
670
+ try:
671
+ b2 = shogi_mod.Board(board.sfen())
672
+ b2.push_usi(cand)
673
+ a2 = evaluate_axes_shogi(b2, shogi_mod, perspective)
674
+ s2 = weighted_score(a2, weights)
675
+ gives_check = (b2.is_check() if hasattr(b2, "is_check") else False)
676
+ alt_rows.append({
677
+ "move": cand,
678
+ "score_after": round(s2, 4),
679
+ "delta_vs_chosen": round(s2 - score_after, 4),
680
+ "gives_check": gives_check,
681
+ })
682
+ except Exception:
683
+ continue
684
+ alt_df = pd.DataFrame(alt_rows)
685
+ if len(alt_df) > 0:
686
+ alt_df = alt_df.sort_values("score_after", ascending=False).head(topk)
687
+
688
+ fig = plot_axis_deltas(df_axes)
689
+
690
+ return {
691
+ "game": "shogi",
692
+ "input": {"sfen": sfen, "move_usi": move_usi, "perspective": perspective},
693
+ "scores": {"before": score_before, "after": score_after, "delta": score_after - score_before},
694
+ "axes_table": df_axes,
695
+ "alternatives": alt_df,
696
+ "comment_md": comment,
697
+ "hds_md": hds_log,
698
+ "plot_fig": fig,
699
+ "repro": repro_snapshot(),
700
+ }
701
+
702
+
703
+ # ---------------------------
704
+ # Gradio UI
705
+ # ---------------------------
706
+
707
+ def _format_repro_md(repro: Dict[str, Any]) -> str:
708
+ p = repro.get("packages", {})
709
+ lines = []
710
+ lines.append("### Repro snapshot")
711
+ lines.append(f"- utc_now: `{repro.get('utc_now')}`")
712
+ lines.append(f"- python: `{repro.get('python')}`")
713
+ lines.append(f"- platform: `{repro.get('platform')}`")
714
+ lines.append("- packages:")
715
+ for k, v in p.items():
716
+ lines.append(f" - {k}: `{v}`")
717
+ return "\n".join(lines)
718
+
719
+ def run_shogi(sfen: str, move_usi: str, perspective: str):
720
+ try:
721
+ report = explain_shogi_move_en(sfen.strip(), move_usi.strip(), perspective=perspective)
722
+ return (
723
+ report["comment_md"],
724
+ report["hds_md"],
725
+ report["axes_table"],
726
+ report["alternatives"],
727
+ report["plot_fig"],
728
+ _format_repro_md(report["repro"]),
729
+ )
730
+ except Exception as e:
731
+ tb = traceback.format_exc(limit=2)
732
+ return (
733
+ f"**Error:** {e}\n\n```\n{tb}\n```",
734
+ "",
735
+ pd.DataFrame(),
736
+ pd.DataFrame(),
737
+ None,
738
+ _format_repro_md(repro_snapshot()),
739
+ )
740
+
741
+ def run_chess(fen: str, move_uci: str, perspective: str):
742
+ try:
743
+ report = explain_chess_move_en(fen.strip(), move_uci.strip(), perspective=perspective)
744
+ return (
745
+ report["comment_md"],
746
+ report["hds_md"],
747
+ report["axes_table"],
748
+ report["alternatives"],
749
+ report["plot_fig"],
750
+ _format_repro_md(report["repro"]),
751
+ )
752
+ except Exception as e:
753
+ tb = traceback.format_exc(limit=2)
754
+ return (
755
+ f"**Error:** {e}\n\n```\n{tb}\n```",
756
+ "",
757
+ pd.DataFrame(),
758
+ pd.DataFrame(),
759
+ None,
760
+ _format_repro_md(repro_snapshot()),
761
+ )
762
+
763
+ ABOUT_MD = r"""
764
+ ## Black-Box Translator (Deterministic Demo)
765
+
766
+ - **No engine / no search / no learning.** Only deterministic heuristics + structured logs.
767
+ - **Goal:** Make a 3rd party able to reproduce the same explanation from the same input.
768
+
769
+ **Inputs**
770
+ - Chess: `FEN` + `UCI move`
771
+ - Shogi: `SFEN` + `USI move`
772
+
773
+ **Output**
774
+ - One **human-readable comment**
775
+ - A **structured log** (HDS-style layers)
776
+ - Axis table + top alternatives + a small chart
777
+
778
+ **Important prohibition**
779
+ - This demo must NOT be used for: emotion total-formalization, self/ego design, or any use that directly enables ranking/scoring humans.
780
+ """
781
+
782
+ def build_ui():
783
+ with gr.Blocks(title="HDS Black-Box Translator (Demo)") as demo:
784
+ gr.Markdown(ABOUT_MD)
785
+
786
+ with gr.Tabs():
787
+ with gr.Tab("Shogi"):
788
+ with gr.Row():
789
+ sfen = gr.Textbox(
790
+ label="SFEN",
791
+ lines=2,
792
+ value="lnsgkgsnl/1r5b1/p1ppppppp/9/9/9/P1PPPPPPP/1B5R1/LNSGKGSNL b - 1",
793
+ )
794
+ with gr.Row():
795
+ move = gr.Textbox(label="USI move", value="7g7f")
796
+ persp = gr.Dropdown(label="Perspective", choices=["side_to_move", "black", "white"], value="side_to_move")
797
+ btn = gr.Button("Explain (Shogi)")
798
+ comment = gr.Markdown()
799
+ hds = gr.Markdown()
800
+ axes = gr.Dataframe(label="Axes", interactive=False)
801
+ alts = gr.Dataframe(label="Top alternatives", interactive=False)
802
+ fig = gr.Plot(label="Axis deltas")
803
+ repro = gr.Markdown()
804
+ btn.click(run_shogi, inputs=[sfen, move, persp], outputs=[comment, hds, axes, alts, fig, repro])
805
+
806
+ with gr.Tab("Chess"):
807
+ with gr.Row():
808
+ fen = gr.Textbox(
809
+ label="FEN",
810
+ lines=2,
811
+ value="r1bqkbnr/pppp1ppp/2n5/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 2 3",
812
+ )
813
+ with gr.Row():
814
+ move = gr.Textbox(label="UCI move", value="f3g5")
815
+ persp = gr.Dropdown(label="Perspective", choices=["side_to_move", "white", "black"], value="side_to_move")
816
+ btn = gr.Button("Explain (Chess)")
817
+ comment = gr.Markdown()
818
+ hds = gr.Markdown()
819
+ axes = gr.Dataframe(label="Axes", interactive=False)
820
+ alts = gr.Dataframe(label="Top alternatives", interactive=False)
821
+ fig = gr.Plot(label="Axis deltas")
822
+ repro = gr.Markdown()
823
+ btn.click(run_chess, inputs=[fen, move, persp], outputs=[comment, hds, axes, alts, fig, repro])
824
+
825
+ with gr.Accordion("Repro snapshot (now)", open=False):
826
+ gr.Markdown(_format_repro_md(repro_snapshot()))
827
+
828
+ return demo
829
+
830
+
831
+ if __name__ == "__main__":
832
+ ui = build_ui()
833
+ ui.launch()