Drop variant B, adopt Black-belt Exam defender, add one-sidedness metrics

- Remove variant B (Brown cap, +1 for higher beating lower) from rules,
  engine, bots and simulator; a higher belt beating a lower one gains 0.
- Final Exam: the defender fights as a Black belt (RULES.md v0.7). Ties to
  the defender and best-of-3 stay as simulator switches (`--exam`).
- Report stripe income per source as gains and losses.
- Add ten one-sidedness measures sampled at turn ends (lead gap, winner
  margin and lead share, halfway leader, last lead change, contention,
  rank swaps, leader Kumite losses, Exam challengers, winner pace).
- Plan: decisions D28-D33 and the detailed Phase 6.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 17:09:42 +02:00
parent 7b76b10175
commit 4998931cb0
16 changed files with 707 additions and 256 deletions
+36 -20
View File
@@ -5,34 +5,43 @@ import dataclasses
from collections.abc import Iterable
from pathlib import Path
from dojo.events import Source
from dojo.metrics import GameMetrics, Spread, Summary
def format_summary(summary: Summary) -> str:
income = summary.income_per_turn
seats = ", ".join(f"{seat} {rate:.0%}" for seat, rate in enumerate(summary.win_rate_by_seat))
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 {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",
f"Stuck on 14 {summary.turns_on_14_per_game:.2f} turns/game, "
f"longest streak {summary.longest_14_streak} turns",
f"Income/turn square {income[Source.SQUARE]:+.2f} "
f"(home {summary.square_income_home_per_turn:+.2f}, "
f"away {summary.square_income_away_per_turn:+.2f}), "
f"card {income[Source.CARD]:+.2f}, kumite {income[Source.KUMITE]:+.2f}, "
f"exam {income[Source.EXAM]:+.2f}",
f"Lead changes {_spread(summary.lead_changes)}",
f"Final spread {_spread(summary.final_spread)}",
f"Wins by seat {seats}",
f"Wins by bot {bots}",
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"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 (>2 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"Wins by seat {seats}",
f"Wins by bot {bots}",
]
return "\n".join(lines)
@@ -51,3 +60,10 @@ def _spread(spread: Spread) -> str:
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%}"
)