Files
dojo_cate/src/dojo/report.py
T
eisterman 23ae7a2572 Add Kiai-decided metrics; keep Kiai at +2 (6.6, D47)
Report how often a Tecnica is played and how often Kiai decides a
Kumite or an Exam fight. The tie rule moves to RulesConfig.attacker_wins,
shared by the engine and the metric; games are unchanged. RULES.md drops
the Kiai +1 and catch-up variants from the rules under test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:27:37 +02:00

124 lines
5.7 KiB
Python

"""Simulation output: a plain-text summary per scenario, and CSV files with one row per game
or one row per scenario."""
import csv
import dataclasses
from collections.abc import Iterable
from pathlib import Path
from dojo.metrics import CONTENTION_BELTS, GameMetrics, SeatSummary, Spread, Summary
def format_summary(summary: Summary) -> str:
bots = ", ".join(f"{bot} {rate:.0%}" for bot, rate in summary.win_rate_by_bot.items())
income = ", ".join(
f"{source} +{gain:.2f}/{loss:.2f}"
for source, (gain, loss) in summary.income_per_turn.items()
)
lines = [
f"== {summary.scenario} ({summary.games} games, {summary.finished:.0%} finished)",
f"Rounds {_spread(summary.rounds)}",
f"Minutes {_spread(summary.minutes)}",
f"Kumite {summary.kumite_per_round:.2f}/round; upsets {summary.upset_rate:.0%}, "
f"higher beats lower (no stripes change) {summary.expected_rate:.0%}",
f"Longest chain {_spread(summary.max_chain)} (Kumite in one turn, per game)",
f"Exam {summary.exam_attempts_per_game:.2f} attempts/game, "
f"{summary.exam_success:.0%} passed, {summary.exam_challengers:.2f} challengers/game",
f"Tecnica played in {summary.tecnica_rate:.0%} of Kumite; Kiai decided "
f"{summary.kiai_decided_rate:.0%} of Kumite, {summary.exam_kiai_decided_rate:.0%} of "
"Exam fights",
f"Income/turn {income}",
f"Square income home {summary.square_income_home_per_turn:+.2f}/turn, "
f"away {summary.square_income_away_per_turn:+.2f}/turn (net)",
f"Winner pace {_spread(summary.winner_turns_to_brown)} (own turns to reach Brown)",
f"Lead changes {_spread(summary.lead_changes)}",
f"Last change {_shares(summary.last_lead_change)} (point of the game)",
f"Gap 1st-2nd {_spread(summary.lead_gap)} (per-game mean, stripes)",
f"Winner margin {_spread(summary.winner_margin)} (over best rival at the end)",
f"Winner led {summary.winner_lead_share:.0%} of turns; halfway leader won "
f"{summary.halfway_leader_wins:.0%} of {summary.halfway_games} games",
f"Contention {summary.contenders_at_end:.2f} rivals within one belt at the end; "
f"{summary.out_of_contention:.0%} of player-turns out (>{CONTENTION_BELTS} belts behind)",
f"Worst player {_shares(summary.worst_out_of_contention)} (share of turns out)",
f"Rank swaps {_spread(summary.rank_swaps)}",
f"Leader beaten {summary.leader_kumite_losses:.2f} Kumite lost by the leader per game",
f"Final spread {_spread(summary.final_spread)}",
f"Move options {summary.end_squares_per_move:.1f} end squares/move; can end on "
f"Allenamento {summary.allenamento_reachable:.0%}, Fatica {summary.fatica_reachable:.0%}",
*(f"Seat {seat:<11}{_seat(stats)}" for seat, stats in enumerate(summary.seats)),
f"Wins by bot {bots}",
]
return "\n".join(lines)
def write_games_csv(path: Path, games: Iterable[GameMetrics]) -> None:
fields = [field.name for field in dataclasses.fields(GameMetrics)]
with path.open("w", encoding="utf-8", newline="") as file:
writer = csv.DictWriter(file, fieldnames=fields)
writer.writeheader()
for game in games:
writer.writerow(
{
name: ",".join(map(str, value)) if isinstance(value, tuple) else value
for name, value in dataclasses.asdict(game).items()
}
)
def write_summary_csv(path: Path, summaries: Iterable[Summary]) -> None:
"""One row per scenario; a column missing from a scenario (fewer seats, other bots) is empty."""
rows = [_summary_row(summary) for summary in summaries]
fields = list(dict.fromkeys(name for row in rows for name in row))
with path.open("w", encoding="utf-8", newline="") as file:
writer = csv.DictWriter(file, fieldnames=fields, restval="")
writer.writeheader()
writer.writerows(rows)
def _summary_row(summary: Summary) -> dict[str, object]:
row: dict[str, object] = {}
for field in dataclasses.fields(Summary):
name, value = field.name, getattr(summary, field.name)
match value:
case Spread():
row |= {f"{name}_{stat}": round(v, 3) for stat, v in _numbers(value).items()}
case float():
row[name] = round(value, 4)
case str() | int():
row[name] = value
case _:
pass # Income, seats and bots are flattened below.
for source, (gain, loss) in summary.income_per_turn.items():
row[f"{source}_gain_per_turn"] = round(gain, 4)
row[f"{source}_loss_per_turn"] = round(loss, 4)
for seat, stats in enumerate(summary.seats):
row |= {f"seat{seat}_{stat}": round(v, 4) for stat, v in _numbers(stats).items()}
row |= {f"win_rate_{bot}": round(v, 4) for bot, v in summary.win_rate_by_bot.items()}
return row
def _numbers(record: Spread | SeatSummary) -> dict[str, float]:
return dataclasses.asdict(record)
def _seat(stats: SeatSummary) -> str:
return (
f"won {stats.win_rate:.0%}, final track {stats.final_track:.1f}, "
f"Exam {stats.exam_attempts_per_game:.2f} attempts/game, "
f"defended in {stats.exam_defended:.0%} of games"
)
def _spread(spread: Spread) -> str:
return (
f"mean {spread.mean:.1f} p10 {spread.p10:.1f} median {spread.median:.1f} "
f"p90 {spread.p90:.1f}"
)
def _shares(spread: Spread) -> str:
return (
f"mean {spread.mean:.0%} p10 {spread.p10:.0%} median {spread.median:.0%} "
f"p90 {spread.p90:.0%}"
)