""" regel_van_8.py — Empirische verificatie van Culbertson's regel van 8. Culbertson's bewering: van de 13 slagen in een bridgespel worden er gemiddeld ca. 8 gewonnen door een A, H of V (tophonneur); de overige ca. 5 door kleinere kaarten (B en lager). Het script genereert N willekeurige spellen, kiest per spel het beste contract op basis van een DD-table-analyse, speelt het spel optimaal uit volgens DDS en logt per slag welke kaart de slag wint. Aggregatie geeft het empirische gemiddelde — en levert tegelijk data voor de slagsoorten-decompositie: honneurslagen = slagen gewonnen door A/H/V lengteslagen = declarer-slagen in NT minus honneurslagen NT troefslagen = declarer-slagen troef minus declarer-slagen NT (de bonus van een troefcontract t.o.v. SA) Uitvoer: regel_van_8_per_spel.csv — één regel per spel regel_van_8_samenvatting.txt — aggregatieve cijfers Auteur: Kees van der Weijden (met assistentie van Claude) """ import argparse import csv import random import time from pathlib import Path from statistics import mean, stdev from collections import Counter from endplay import calc_dd_table, generate_deal, solve_board from endplay.types import Card, Denom, Player, Rank # Honneur-definities, centraal gedefinieerd zodat het script makkelijk uit te # breiden is met nieuwe varianten: # AHV = strikt Culbertson: alleen Aas, Heer, Vrouw # AHVB = MWC-honneurs: A, H, V, B (alle kaarten met HCP-waarde) # AHVBT = klassieke bridge-honneurs: A, H, V, B, T HONOR_DEFINITIONS = { "AHV": {Rank.RA, Rank.RK, Rank.RQ}, "AHVB": {Rank.RA, Rank.RK, Rank.RQ, Rank.RJ}, "AHVBT": {Rank.RA, Rank.RK, Rank.RQ, Rank.RJ, Rank.RT}, } # Voor backwards compatibility HONOR_RANKS_AHV = HONOR_DEFINITIONS["AHV"] HONOR_RANKS_AHVB = HONOR_DEFINITIONS["AHVB"] HONOR_RANKS_AHVBT = HONOR_DEFINITIONS["AHVBT"] HONOR_RANKS = HONOR_RANKS_AHV # Strain-iteratievolgorde STRAINS = [Denom.nt, Denom.spades, Denom.hearts, Denom.diamonds, Denom.clubs] def is_honor(card, honor_ranks=HONOR_RANKS_AHV): """True als de kaart in de gekozen honneurset zit.""" return card.rank in honor_ranks def best_contract_for(dd_table): """Vind (declarer, strain, tricks) van het beste contract over alle strains.""" best = None for strain in STRAINS: for player in Player: tricks = dd_table[strain, player] if best is None or tricks > best[2]: best = (player, strain, tricks) return best def best_nt_contract(dd_table): """Vind (declarer, tricks) van het beste NT-contract.""" best = None for player in Player: tricks = dd_table[Denom.nt, player] if best is None or tricks > best[1]: best = (player, tricks) return best def best_trump_contract(dd_table): """Vind (declarer, strain, tricks) van het beste troefcontract (geen NT).""" best = None for strain in [Denom.spades, Denom.hearts, Denom.diamonds, Denom.clubs]: for player in Player: tricks = dd_table[strain, player] if best is None or tricks > best[2]: best = (player, strain, tricks) return best def play_one_trick(deal): """Speel één slag uit optimaal. Retourneert (winnaar, winnende_kaart, voorspel_kleur). """ leader = deal.first cards = [] led_suit = None for i in range(4): sol = solve_board(deal) best_card, _ = max(sol, key=lambda x: x[1]) cards.append(best_card) if i == 0: led_suit = best_card.suit deal.play(best_card) winner = deal.first # winnaar is nieuwe leader winner_idx = (int(winner) - int(leader)) % 4 return winner, cards[winner_idx], led_suit def play_full_deal(deal, declarer, strain): """Speel het hele spel uit. Retourneert lijst van (winnaar, kaart, voorspel_kleur) per slag. """ deal.trump = strain deal.first = Player((int(declarer) + 1) % 4) # LHO van declarer komt uit results = [] for _ in range(13): results.append(play_one_trick(deal)) return results def classify_trick(winner_card, led_suit, honor_ranks): """Classificeer een slag volgens de gescherpte definities (Kees, juni 2026). - honneur: led_suit == winner.suit EN winner.rank ∈ honor_ranks - lengte : led_suit == winner.suit EN winner.rank ∉ honor_ranks - introef: led_suit != winner.suit (ruff — alleen mogelijk met troef) Belangrijke implicatie: een ruff met troef-aas valt onder INTROEF, niet onder HONNEUR. De introef-categorie is dus definitie-onafhankelijk; de drie honneurdefinities verschuiven alleen tussen honneur en lengte. """ if winner_card.suit == led_suit: return "honneur" if winner_card.rank in honor_ranks else "lengte" return "introef" def analyseer_spel(deal_pbn): """Analyseer één spel met gescherpte categorisatie. Per spel doen we: - één DD-tabel-berekening - één play-simulatie in beste NT-contract - één play-simulatie in beste troefcontract Per slag classificeren we als honneur / lengte / introef volgens de gescherpte definities. Honneur en lengte hangen af van honor_ranks; introef is definitie-onafhankelijk (= ruff = led_suit != winner.suit). Per spel rapporteren we per contract en per definitie: - tot_honneur (over alle 13 slagen) - tot_lengte (over alle 13 slagen) - decl_honneur (declarer-zijde) - decl_lengte (declarer-zijde) Plus definitie-onafhankelijk per contract: - tot_introef, decl_introef, decl_tricks """ from endplay.types import Deal d_nt = Deal(deal_pbn) d_troef = Deal(deal_pbn) tab = calc_dd_table(d_nt) def decompose(play_data, declarer_side, honor_ranks): """Tel honneur/lengte/introef voor totaal en declarer-zijde.""" tot_h = tot_l = tot_i = 0 dec_h = dec_l = dec_i = 0 for winner, card, led in play_data: cls = classify_trick(card, led, honor_ranks) is_decl = winner in declarer_side if cls == "honneur": tot_h += 1 if is_decl: dec_h += 1 elif cls == "lengte": tot_l += 1 if is_decl: dec_l += 1 else: # introef tot_i += 1 if is_decl: dec_i += 1 return tot_h, tot_l, tot_i, dec_h, dec_l, dec_i out = {} # ---- Beste NT-contract ---- nt_dec, nt_tricks = best_nt_contract(tab) nt_play = play_full_deal(d_nt, nt_dec, Denom.nt) declarer_side_nt = {nt_dec, Player((int(nt_dec) + 2) % 4)} out["nt_declarer"] = nt_dec.name out["nt_dd_tricks"] = nt_tricks nt_decl_tricks = None for name, ranks in HONOR_DEFINITIONS.items(): th, tl, ti, dh, dl, di = decompose(nt_play, declarer_side_nt, ranks) out[f"nt_tot_honneur_{name}"] = th out[f"nt_tot_lengte_{name}"] = tl out[f"nt_decl_honneur_{name}"] = dh out[f"nt_decl_lengte_{name}"] = dl # introef is def-onafhankelijk; bevestig en sla op via eerste def if nt_decl_tricks is None: out["nt_tot_introef"] = ti # in NT altijd 0 out["nt_decl_introef"] = di # idem nt_decl_tricks = dh + dl + di out["nt_decl_tricks"] = nt_decl_tricks # ---- Beste troefcontract ---- troef_dec, troef_strain, troef_tricks = best_trump_contract(tab) troef_play = play_full_deal(d_troef, troef_dec, troef_strain) declarer_side_troef = {troef_dec, Player((int(troef_dec) + 2) % 4)} out["troef_declarer"] = troef_dec.name out["troef_strain"] = troef_strain.name out["troef_dd_tricks"] = troef_tricks troef_decl_tricks = None for name, ranks in HONOR_DEFINITIONS.items(): th, tl, ti, dh, dl, di = decompose(troef_play, declarer_side_troef, ranks) out[f"troef_tot_honneur_{name}"] = th out[f"troef_tot_lengte_{name}"] = tl out[f"troef_decl_honneur_{name}"] = dh out[f"troef_decl_lengte_{name}"] = dl if troef_decl_tricks is None: out["troef_tot_introef"] = ti out["troef_decl_introef"] = di troef_decl_tricks = dh + dl + di out["troef_decl_tricks"] = troef_decl_tricks # ---- Beste contract — post-hoc afgeleid ---- if nt_tricks > troef_tricks: prefix = "nt_" out["best_strain"] = "nt" out["best_declarer"] = nt_dec.name out["best_dd_tricks"] = nt_tricks else: prefix = "troef_" out["best_strain"] = troef_strain.name out["best_declarer"] = troef_dec.name out["best_dd_tricks"] = troef_tricks # Copy alle category-velden naar best_* out["best_decl_tricks"] = out[f"{prefix}decl_tricks"] out["best_tot_introef"] = out[f"{prefix}tot_introef"] out["best_decl_introef"] = out[f"{prefix}decl_introef"] for name in HONOR_DEFINITIONS: for what in ["tot_honneur", "tot_lengte", "decl_honneur", "decl_lengte"]: out[f"best_{what}_{name}"] = out[f"{prefix}{what}_{name}"] out["troef_advantage"] = troef_tricks - nt_tricks return out def main(): parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) parser.add_argument("--n", type=int, default=200, help="aantal spellen (default 200)") parser.add_argument("--seed", type=int, default=42, help="random seed") parser.add_argument( "--out-dir", type=Path, default=Path("/mnt/user-data/outputs"), help="output directory", ) parser.add_argument( "--name", type=str, default="", help="suffix voor outputbestanden (om batches te onderscheiden)", ) args = parser.parse_args() random.seed(args.seed) args.out_dir.mkdir(parents=True, exist_ok=True) suffix = f"_{args.name}" if args.name else "" csv_path = args.out_dir / f"regel_van_8_per_spel{suffix}.csv" summary_path = args.out_dir / f"regel_van_8_samenvatting{suffix}.txt" print(f"Analyse van {args.n} spellen (seed={args.seed})...") start = time.perf_counter() rows = [] for i in range(args.n): d = generate_deal() row = {"deal": i + 1, "pbn": d.to_pbn(), **analyseer_spel(d.to_pbn())} rows.append(row) if (i + 1) % 25 == 0: elapsed = time.perf_counter() - start rate = (i + 1) / elapsed eta = (args.n - i - 1) / rate print(f" {i+1}/{args.n} klaar ({rate:.1f} spellen/s, ETA {eta:.0f}s)") elapsed = time.perf_counter() - start print(f"Klaar in {elapsed:.1f}s ({args.n/elapsed:.1f} spellen/s)") # CSV uitvoer with csv_path.open("w", newline="") as f: writer = csv.DictWriter(f, fieldnames=rows[0].keys()) writer.writeheader() writer.writerows(rows) print(f"Per-spel-data geschreven naar {csv_path}") # Samenvatting def avg_sd(key): vals = [r[key] for r in rows] return mean(vals), stdev(vals) if len(vals) > 1 else 0.0 lines = [] lines.append(f"REGEL VAN 8 — Culbertson — empirische verificatie") lines.append(f"=" * 75) lines.append(f"N = {args.n} willekeurige spellen, seed = {args.seed}") lines.append(f"Rekentijd: {elapsed:.1f}s") lines.append("") lines.append("HONNEURSLAGEN per honneurdefinitie (over ALLE 13 slagen)") lines.append("Definities (Kees, juni 2026):") lines.append(" honneur = winner-suit == led-suit EN honneur-kaart") lines.append(" lengte = winner-suit == led-suit EN kleintje") lines.append(" introef = winner-suit != led-suit (ruff)") lines.append("-" * 75) defs = list(HONOR_DEFINITIONS.keys()) header = " " * 33 + "".join(f"{d:>14s}" for d in defs) lines.append(header) for label, key_template in [ ("Beste contract (any strain)", "best_tot_honneur_{}"), ("Beste NT-contract", "nt_tot_honneur_{}"), ("Beste troefcontract", "troef_tot_honneur_{}"), ]: row = f" {label:31s}" for d in defs: m, s = avg_sd(key_template.format(d)) row += f" {m:6.2f}±{s:4.2f}" lines.append(row) lines.append("") lines.append("Introefslagen (def-onafhankelijk) over alle 13 slagen:") for label, key in [ ("Beste contract", "best_tot_introef"), ("Beste NT", "nt_tot_introef"), ("Beste troef", "troef_tot_introef"), ]: m, s = avg_sd(key) lines.append(f" {label:30s}: {m:5.2f} ± {s:.2f}") lines.append("") lines.append("SLAGSOORTEN-DECOMPOSITIE voor DECLARER-ZIJDE (troefcontract)") lines.append("-" * 75) m_dd, s_dd = avg_sd("troef_decl_tricks") lines.append(f" Declarer DD-slagen troef: {m_dd:5.2f} ± {s_dd:.2f}") lines.append(" " * 27 + "".join(f"{d:>10s}" for d in defs)) for label, key_template in [ ("honneurslagen", "troef_decl_honneur_{}"), ("lengteslagen", "troef_decl_lengte_{}"), ]: row = f" {label:25s}" for d in defs: m, _ = avg_sd(key_template.format(d)) row += f" {m:7.2f}" lines.append(row) m_i, _ = avg_sd("troef_decl_introef") lines.append(f" introefslagen (ruff) : {m_i:7.2f} (def-onafhankelijk)") lines.append("") m_adv, s_adv = avg_sd("troef_advantage") lines.append(f"Troefvoordeel (DD_troef - DD_NT per declarer): {m_adv:.2f} ± {s_adv:.2f}") lines.append("") text = "\n".join(lines) summary_path.write_text(text) print(f"Samenvatting geschreven naar {summary_path}\n") print(text) if __name__ == "__main__": main()