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
+64 -29
View File
@@ -12,26 +12,28 @@ from dojo.events import (
Moved,
Source,
TrackChanged,
TurnSkipped,
TurnStarted,
)
from dojo.metrics import KUMITE_SECONDS, TURN_SECONDS, GameMetrics, measure, summarize
from dojo.rules import VARIANT_A
from dojo.rules import DEFAULT_RULES
CORNERS = (0, 1)
BOTS = ("balanced", "random")
HOME_0 = Pos(0, 1) # quadrant 0
HOME_1 = Pos(0, 14) # quadrant 1
def fought(attacker: int, defender: int, kind: FightKind, *, exam: bool = False) -> KumiteFought:
return KumiteFought(attacker, defender, 0, 0, 4, 3, True, kind, exam, cards=())
def fought(
attacker: int, defender: int, kind: FightKind, *, won: bool = True, exam: bool = False
) -> KumiteFought:
return KumiteFought(attacker, defender, 0, 0, 4, 3, won, kind, exam, cards=())
def run(events: list[Event]) -> GameMetrics:
return measure(events, VARIANT_A, CORNERS, BOTS, scenario="test", seed=3)
def run(events: list[Event], players: int = 2) -> GameMetrics:
corners = tuple(range(players))
bots = ("balanced", "random", "farmer")[:players]
return measure(events, DEFAULT_RULES, corners, bots, scenario="test", seed=3)
def test_measure_counts_fights_income_and_leads() -> None:
def test_measure_counts_fights_income_and_exams() -> None:
metrics = run(
[
TurnStarted(1, 0, 7),
@@ -47,6 +49,8 @@ def test_measure_counts_fights_income_and_leads() -> None:
TurnStarted(2, 0, 6),
fought(1, 0, FightKind.EXPECTED), # Player 1 defends: not part of a chain.
TrackChanged(0, 0, 5, Source.CARD),
TrackChanged(0, 5, 4, Source.CARD),
fought(0, 1, FightKind.EQUAL, won=False, exam=True),
fought(0, 1, FightKind.EQUAL, exam=True),
ExamTaken(0, 1, passed=True),
GameOver(0, 2),
@@ -55,41 +59,72 @@ def test_measure_counts_fights_income_and_leads() -> None:
assert (metrics.kumite, metrics.upsets, metrics.expected_wins) == (3, 1, 1)
assert metrics.max_chain == 2
assert (metrics.exam_attempts, metrics.exam_passes) == (1, 1)
assert (metrics.income_square, metrics.income_kumite, metrics.income_card) == (2, 2, 5)
assert (metrics.exam_attempts, metrics.exam_passes, metrics.exam_challengers) == (1, 1, 1)
assert (metrics.square_gain, metrics.square_loss) == (2, 0)
assert (metrics.kumite_gain, metrics.kumite_loss) == (3, -1)
assert (metrics.card_gain, metrics.card_loss) == (5, -1)
assert (metrics.square_income_home, metrics.square_income_away) == (1, 1)
# Leader: 0 (1-0), tie (1-1) keeps it, 1 (0-4), 0 (5-4).
assert metrics.lead_changes == 2
assert metrics.final_spread == 1
assert (metrics.winner_seat, metrics.winner_bot, metrics.rounds) == (0, "balanced", 2)
assert metrics.minutes == pytest.approx(round((3 * TURN_SECONDS + 4 * KUMITE_SECONDS) / 60, 1))
# Both Exam rounds count as fights for the time estimate.
assert metrics.minutes == pytest.approx(round((3 * TURN_SECONDS + 5 * KUMITE_SECONDS) / 60, 1))
def test_measure_tracks_turns_started_on_14() -> None:
def test_standings_are_sampled_at_turn_ends() -> None:
metrics = run(
[
TrackChanged(0, 0, 14, Source.CARD),
TurnStarted(1, 0, 7),
TrackChanged(0, 0, 9, Source.CARD), # end of turn 1: (9, 0, 0), leader 0
TurnStarted(1, 1, 7),
TurnStarted(2, 0, 7),
TrackChanged(0, 14, 15, Source.KUMITE),
TurnStarted(2, 1, 7),
TurnStarted(3, 0, 7),
TrackChanged(0, 15, 14, Source.SQUARE),
TurnStarted(4, 0, 7),
GameOver(None, 4),
TrackChanged(1, 0, 3, Source.SQUARE),
TrackChanged(1, 3, 10, Source.CARD), # end of turn 2: (9, 10, 0), leader 1
TurnStarted(1, 2, 7),
TrackChanged(2, 0, 10, Source.CARD), # end of turn 3: (9, 10, 10), no strict leader
TurnSkipped(2, 0),
TrackChanged(2, 10, 15, Source.KUMITE), # end of turn 4: (9, 10, 15), leader 2
GameOver(2, 2),
],
players=3,
)
assert metrics.lead_changes == 2
assert metrics.last_lead_change == pytest.approx(4 / 4)
assert metrics.lead_gap_mean == pytest.approx((9 + 1 + 0 + 5) / 4)
assert metrics.winner_margin == 5
assert metrics.winner_lead_share == pytest.approx(1 / 4)
# Halfway is the end of turn 2, led by player 1.
assert metrics.halfway_leader_won is False
assert metrics.contenders_at_end == 0
# Out (more than 6 behind): players 1 and 2 after turn 1, player 2 after turn 2.
assert metrics.out_of_contention_share == pytest.approx(3 / 12)
assert metrics.worst_out_of_contention_share == pytest.approx(0.5)
# 0>1 becomes 1>0; 0>2 becomes 2>0; 1>2 (ties skipped) becomes 2>1.
assert metrics.rank_swaps == 3
assert metrics.winner_turns_to_brown == 1
assert metrics.final_spread == 6
def test_leader_losing_a_kumite_is_counted() -> None:
metrics = run(
[
TurnStarted(1, 0, 7),
TrackChanged(0, 0, 3, Source.CARD),
TurnStarted(1, 1, 7),
fought(1, 0, FightKind.EQUAL), # Leader 0 loses.
fought(1, 0, FightKind.EQUAL, won=False), # The attacker loses: 0 wins.
GameOver(None, 1),
]
)
assert metrics.turns_on_14 == 3
assert metrics.longest_14_streak == 2
assert metrics.winner_seat is None
assert metrics.leader_kumite_losses == 1
assert metrics.winner_margin is None
assert metrics.halfway_leader_won is None
assert metrics.last_lead_change is None
def test_summarize_splits_wins_by_seat_and_bot() -> None:
base = run([GameOver(None, 10)])
base = run([TurnStarted(1, 0, 7), GameOver(None, 10)])
rows = [
replace(base, winner_seat=0, winner_bot="balanced"),
replace(base, lineup="balanced,random", winner_seat=0, winner_bot="balanced"),
replace(base, lineup="random,balanced", winner_seat=1, winner_bot="balanced"),
replace(base, lineup="random,balanced", winner_seat=0, winner_bot="random"),
base,