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
+35 -13
View File
@@ -2,6 +2,7 @@ from pathlib import Path
import pytest
from dojo.rules import RulesConfig
from dojo.sim import BoardFiles, Scenario, matrix, run
BOARD = Path(__file__).parent.parent / "board"
@@ -10,16 +11,35 @@ LINEUP = ("balanced", "random")
def test_matrix_crosses_every_axis() -> None:
scenarios = matrix(["A", "B"], [3, 4], [2, 1], [0], [LINEUP])
scenarios = matrix(["none", "acd"], [3, 4], [2, 1], [0], [LINEUP])
assert len(scenarios) == 8
assert scenarios[0].name == "A p3 kiai+2 start0 balanced,random"
assert scenarios[-1].config.kiai_bonus == 1
assert scenarios[-1].config.brown_requires_kumite
assert scenarios[0].name == "exam:none p3 kiai+2 start0 balanced,random"
assert scenarios[0].config == RulesConfig(exam_defender_black=False)
assert scenarios[-1].config == RulesConfig(
exam_ties_to_defender=True, exam_defender_black=True, exam_rounds=3, kiai_bonus=1
)
@pytest.mark.parametrize(
("exam", "ties", "black", "rounds"),
[
("none", False, False, 1),
("a", True, False, 1),
("c", False, True, 1),
("d", False, False, 3),
("cd", False, True, 3),
],
)
def test_exam_options_map_to_rules(exam: str, ties: bool, black: bool, rounds: int) -> None:
config = Scenario(exam, 4, 2, 0, LINEUP).config
assert (config.exam_ties_to_defender, config.exam_defender_black) == (ties, black)
assert config.exam_rounds == rounds
def test_lineup_fills_the_seats_and_rotates_each_game() -> None:
scenario = Scenario("A", 3, 2, 0, LINEUP)
scenario = Scenario("none", 3, 2, 0, LINEUP)
assert scenario.seating(0) == ("balanced", "random", "balanced")
assert scenario.seating(1) == ("random", "balanced", "balanced")
@@ -27,23 +47,25 @@ def test_lineup_fills_the_seats_and_rotates_each_game() -> None:
@pytest.mark.parametrize(
("variant", "players", "lineup", "message"),
("exam", "players", "lineup", "message"),
[
("C", 4, LINEUP, "unknown variant"),
("A", 4, ("balanced", "cheater"), "unknown bots"),
("A", 4, (), "unknown bots"),
("A", 9, LINEUP, "supported range"),
("b", 4, LINEUP, "unknown Exam options"),
("aa", 4, LINEUP, "unknown Exam options"),
("", 4, LINEUP, "unknown Exam options"),
("none", 4, ("balanced", "cheater"), "unknown bots"),
("none", 4, (), "unknown bots"),
("none", 9, LINEUP, "supported range"),
],
)
def test_invalid_scenarios_are_rejected(
variant: str, players: int, lineup: tuple[str, ...], message: str
exam: str, players: int, lineup: tuple[str, ...], message: str
) -> None:
with pytest.raises(ValueError, match=message):
Scenario(variant, players, 2, 0, lineup)
Scenario(exam, players, 2, 0, lineup)
def test_runs_are_reproducible_and_independent_of_worker_count() -> None:
scenarios = matrix(["A", "B"], [3], [2], [0], [LINEUP])
scenarios = matrix(["none", "acd"], [3], [2], [0], [LINEUP])
serial = run(FILES, scenarios, games=2, seed=5, jobs=1)
parallel = run(FILES, scenarios, games=2, seed=5, jobs=2)