Create app.py
Browse files
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()
|