"""
Script: starvote_larry_hastings.py
Description: Runs a STAR Voting election with detailed tiebreaker analysis and matrix visualization.
"""

import math
import os
import re
import sys
import textwrap
from collections import defaultdict
from decimal import ROUND_HALF_UP, Decimal
from pathlib import Path

import starvote
from starvote import Tiebreaker

# Optional cross-check against RCV-IRV. The sibling engine vendors pyrankvote
# and the score->rank conversion. If it isn't present, IRV comparison is simply
# skipped and STAR tabulation is unaffected.
try:
    _IRV_ENGINE = os.path.join(
        os.path.dirname(os.path.abspath(__file__)),
        "..",
        "06_Other",
        "RCV_IRV",
        "RCV_IRV_tabulation_engine",
    )
    if _IRV_ENGINE not in sys.path:
        sys.path.insert(0, _IRV_ENGINE)
    import pyrankvote as _pyrankvote
    from pyrankvote import Ballot as _IRVBallot, Candidate as _IRVCandidate
    from rcv_irv_tabulation import score_ballot_to_ranking as _score_to_rank
    _IRV_AVAILABLE = True
except Exception:  # pragma: no cover - IRV cross-check is optional
    _IRV_AVAILABLE = False

# --- ANSI Color Codes ---
# Enabled for a real terminal AND for PyCharm's run console (which sets
# PYCHARM_HOSTED and renders ANSI). Disabled when piped to a plain file, so
# redirected output doesn't get littered with escape sequences. Set NO_COLOR
# to force plain output anywhere.
_USE_COLOR = os.environ.get("NO_COLOR") is None and (
    sys.stdout.isatty() or os.environ.get("PYCHARM_HOSTED") == "1"
)
if _USE_COLOR:
    COLOR_GREEN = "\033[92m"
    COLOR_RED = "\033[91m"
    COLOR_BLUE = "\033[94m"
    COLOR_RESET = "\033[0m"
    # Section-header palette (bold). Distinct color per phase.
    COLOR_HEADER = "\033[1;95m"  # bold magenta — banner / fallback
    COLOR_SCORING = "\033[1;96m"  # bold cyan   — Scoring Round (+ its tiebreakers)
    COLOR_RUNOFF = "\033[1;93m"  # bold yellow — Automatic Runoff (+ its tiebreakers)
    COLOR_WINNER = "\033[1;92m"  # bold green  — Winner / Winners
    COLOR_DIM = "\033[2;90m"  # faint gray  — round-separator rule
else:
    COLOR_GREEN = COLOR_RED = COLOR_BLUE = COLOR_RESET = ""
    COLOR_HEADER = COLOR_SCORING = COLOR_RUNOFF = COLOR_WINNER = ""
    COLOR_DIM = ""


def header_color(label):
    """Pick a section-header color by phase, so tiebreaker sub-headers inherit
    the color of their parent round."""
    low = label.lower()
    if "runoff" in low:
        return COLOR_RUNOFF
    if "winner" in low:
        return COLOR_WINNER
    if "scoring" in low:
        return COLOR_SCORING
    return COLOR_HEADER


# --- Ballot marker characters ---
# Any of these (and an empty cell) tabulate as 0; every ballot still counts.
# A legend is printed listing only the markers that actually appear in the data.
MARKER_MEANINGS = {
    "-": "Blank — no score / voter left the candidate blank",
    "~": "Race-level abstention — voter abstained from the entire race",
    "&": "Candidate-level abstention — explicitly abstained for this candidate",
    "?": "Spoiled / voided ballot — overvote, protest mark, or invalid format",
    "%": "Spoiled & re-issued — ballot voided and a replacement was issued",
}

# Ballot-level markers apply to the WHOLE ballot, so they may not be mixed with
# scores — a valid row is the marker in every column (e.g. "~,~,~,~").
BALLOT_LEVEL_MARKERS = {"~", "?", "%"}
# The rest (-, &, ^, empty) are per-candidate and may be mixed freely.


# ---
# 1. TIEBREAKER CLASS
# ---
class LotNumberTiebreaker(Tiebreaker):
    def __init__(self, lot_numbers=None, silent=False):
        self.lot_numbers = lot_numbers or []
        self.silent = silent
        self.order_map = {}
        self.info_printed = False
        self.expl = ""
        # Every call starvote made, in order — one entry per
        # `[Tiebreaker: Lot Number Priority]` banner the report prints. Kept
        # because a caller that never reads the printed report still has to be
        # able to say a lot fired and where: `result_json.py` reported
        # `tiebreaks: []` — a POSITIVE claim that no rung ran — on 23 of the 39
        # cases in this library whose seat the lot actually decided,
        # single-winner ones included. starvote calls the lot from five places
        # — a STAR round's Scoring Round and its Automatic Runoff, both of
        # which Bloc STAR runs once per seat, plus one per PR method — and the
        # only one the builder could see was the first, replayed by
        # `resolve_finalists()` on single-winner races. Recording the calls
        # here is the one honest fix: the count is not re-derived, it is read off
        # the object that broke the tie. See `events` in result_json._lot_ties.
        self.events = []

    def initialize(self, options, ballots):
        # Determine candidate order from the first ballot keys
        ballots = list(ballots)
        first_ballot = ballots[0]
        cands_in_csv_order = list(first_ballot.keys())

        # Best score each candidate got from anybody. Kept for the banner: a tie
        # among candidates whose best score is 0 is a tie for LACK OF SUPPORT,
        # which is a different event from a close race that ran out of rungs,
        # and the generic banner's advice ("verify the tied candidates'
        # 5-counts") sends the reader hunting for a five that no ballot cast.
        self.best_score = {
            c: max((b.get(c, 0) or 0) for b in ballots)
            for c in cands_in_csv_order
        }

        # Check if the user provided lot numbers
        if not self.lot_numbers:
            # DEV MODE: Auto-generate a fallback sequence from CSV column order
            self.lot_numbers = cands_in_csv_order
            self.expl = (
                "*** No official tie-breaking lot numbers were provided.\n"
                "    Ties are resolved using a fallback order: CSV column order."
            )
        else:
            # PRODUCTION MODE: Use the provided numbers
            self.expl = (
                "*(Ties are resolved by choosing the tied candidate with the "
                "highest-priority official lot number.)*"
            )

        # Create an O(1) lookup map: {Candidate: Priority_Index}
        self.order_map = {c: i for i, c in enumerate(self.lot_numbers)}

    def __call__(self, options, tie, desired, exception):
        # We only enter this function if an actual tie has occurred.

        # Print the explanation if this is the first tie we've encountered
        if not self.info_printed and not self.silent:
            print(f"\n{self.expl}")
            print(f"    Lot-number priority order: {self.lot_numbers}")
            self.info_printed = True

        # Sort tied candidates by their assigned lot number priority
        ranked = sorted(tie, key=lambda c: self.order_map.get(c, float("inf")))
        winners = ranked[:desired]

        # Recorded whether or not we print: a silent run is still a run that
        # reached the lot, and the machine-readable contract has to say so.
        # `header` is starvote's own section label ("Bloc STAR: Round 2:
        # Automatic Runoff Round"), which is what names the round and the
        # ladder step; `text` is the tie description it raised with.
        header = getattr(options, "header", None)
        text = str(exception)
        if header and text.startswith(f"{header}: "):
            text = text[len(header) + 2:]
        self.events.append({
            "method": getattr(getattr(options, "method", None), "name", None),
            "header": header,
            "text": text,
            "tied": list(tie),
            "desired": desired,
            "advanced": list(winners),
            "eliminated": [c for c in tie if c not in winners],
        })

        if not self.silent:
            print("\n[Tiebreaker: Lot Number Priority]")
            print(f"  Tie among: {tie}")
            print(f"  Resolved: {winners} (selected by lot-number priority).")
            # Rare, audit-worthy event: the ballots could not break this tie, so
            # the pre-published lot order chose among the tied candidates. Flag it
            # the way method divergences are flagged — a strange phenomenon worth
            # naming rather than burying in the tiebreak trace.
            for line in self.lot_banner(options, tie):
                print(line)

        return winners

    # The banner's middle sentence — WHICH rungs came back equal — is a fact
    # about the PATH that reached the lot, not about the election, and starvote
    # has two kinds of path. STAR and Bloc STAR run the official ladder first
    # (head-to-head, then five-star — see `star_voting` in the vendored module)
    # and call this tiebreaker only when every rung tied. The three PR-family
    # methods — Allocated Score, SSS, RRV — call `break_tie` the moment a
    # selection round's weighted score total ties: no pairwise rung, no
    # five-star rung, straight from the tie to the lot. Until 2026-08-21 one
    # hard-coded STAR sentence served every path, so a PR-family mirror claimed
    # rungs that never ran (the Lackner–Skowron shadow case is where it was
    # caught). The banner now reads `options.method` — the method starvote is
    # actually running — so the sentence cannot drift from the ladder again.
    # Wording locked by tests/test_lot_number_tiebreak.py; the ladders
    # themselves: 07_Concepts/tabulation_engines/tiebreak_ladders.md.
    def lot_banner(self, options, tie=None):
        """The `[Lot-decided tie — rare]` banner, worded for the path that
        reached the lot (see the note above). Returns the lines to print."""
        method = getattr(options, "method", None)
        lines = ["", "[Lot-decided tie — rare]"]

        # One cause the rung-by-rung wording gets wrong: every tied candidate
        # scored 0 on every ballot. The rungs did all run and did all come back
        # equal, so the generic sentence is not false — but it invites the
        # reader to audit a close race, and there is no race. Deliberately
        # per-TIE and not per-election: on a two-seat Bloc where one seat rests
        # on a real preference and the other on nothing, an election-level
        # "degenerate" verdict would be wrong about half the result, while this
        # one lands on exactly the seat the lot paid for. Threshold-free by
        # construction — the condition is "nobody scored anybody", not "hardly
        # anybody did", so there is no support floor smuggled in here.
        best = getattr(self, "best_score", None)
        if tie and best and all(best.get(c, 1) == 0 for c in tie):
            lines += [
                "  ⚠ The ballots did not break this tie, and had nothing to break",
                "    it with: not one ballot scored ANY of these candidates above 0,",
                "    so every rung was comparing zero with zero. The pre-published",
                "    LOT order chose among them — the result here was set by lot,",
                "    not by the votes. Nothing to verify in the rounds above; this",
                "    is a tie for lack of support, not a close race.",
            ]
            return lines

        if method in (starvote.allocated, starvote.sss, starvote.rrv):
            lines += [
                f"  ⚠ The ballots did not break this tie: {method.name} has one",
                "    deterministic rung per seat — the round's weighted score total —",
                "    and the tied candidates came back equal on it, so the pre-published",
                "    LOT order chose among them — the result here was set by lot, not by",
                "    the votes. No head-to-head or five-star rung runs on this path: a",
                "    tie on the weighted total goes straight to the lot. Verify the tied",
                "    candidates' totals in the round above.",
            ]
        elif method in (starvote.star, starvote.bloc):
            lines += [
                "  ⚠ The ballots did not break this tie: the deterministic rungs",
                "    (pairwise / score, then five-star) all came back equal, so the",
                "    pre-published LOT order chose among the tied candidates — the",
                "    result here was set by lot, not by the votes. Usually the",
                '    "dead rung": no tied candidate held a score-5 vote (five-star',
                "    counts fives, not fours). Verify the tied candidates' 5-counts.",
            ]
        else:
            # A path this banner has not been taught: say only what is true on
            # every path, and name no rungs.
            lines += [
                "  ⚠ The ballots did not break this tie, so the pre-published LOT",
                "    order chose among the tied candidates — the result here was set",
                "    by lot, not by the votes.",
            ]
        return lines


# ---
# 2. HELPER FUNCTIONS
# ---
# Map a YAML "voting_method" string to a starvote method object.
METHOD_BY_NAME = {
    "star": starvote.star,
    "bloc": starvote.bloc,
    "bloc star": starvote.bloc,
    "sss": starvote.sss,
    "sequentially spent score": starvote.sss,
    "rrv": starvote.rrv,
    "reweighted range voting": starvote.rrv,
    "allocated": starvote.allocated,
    "allocated score voting": starvote.allocated,
}

# ---------------------------------------------------------------------------
# The method-alias table — ONE list, in one place.
#
# These name sets used to be spelled out inline in __main__'s dispatch, which
# meant any second consumer (the JSON result builder, a conformance runner, a
# survey script) had to copy them and could silently drift. `classify_method()`
# is the single source: __main__ dispatches from it, and `result_json.py`
# reports the normalized family in the machine-readable result.
# ---------------------------------------------------------------------------
IRV_NAMES = {"rcv", "irv", "rcv_irv", "rcv-irv", "rcv/irv",
             "ranked_choice", "instant_runoff"}
STV_NAMES = {"stv", "single_transferable_vote", "rcv_stv"}
RANKED_ROBIN_NAMES = {"rankedrobin", "ranked_robin", "rcv_rr", "rcvrr", "rr",
                      "copeland", "consensus", "consensus_voting",
                      "consensus_choice"}
PLURALITY_NAMES = {"plurality", "choose_one", "chooseone", "choose1",
                   "fptp", "first_past_the_post"}
APPROVAL_NAMES = {"approval", "approve", "av", "approval_voting",
                  "approval_single_winner", "approval_multi_winner"}


def ranked_ballot_body(ballots_text):
    """The ballot rows with trailing '# comments' stripped.

    Ranked detection keys on '>', and a comment may legitimately contain an
    '->' arrow, so the test is always run against this body, never the raw text.
    """
    return "\n".join(ln.split("#")[0] for ln in (ballots_text or "").splitlines())


def classify_method(method_name, ballots_text=""):
    """Resolve a file's `voting_method:` (plus its ballot style) to one family.

    Returns a dict with the normalized name, the boolean flags __main__'s
    dispatch uses, and a single `family` label for machine-readable output:
    `score` · `approval` · `plurality` · `ranked_robin` · `irv` · `stv`.

    `known` is False for an explicit method name we do not recognize — the
    caller decides whether that is an error (it is, in the CLI: silently
    falling back to STAR turned typos into wrong counts).
    """
    import difflib

    declared = str(method_name or "").strip()
    lowered = declared.lower()
    # Normalize BOTH hyphens and spaces to underscores so multi-word names
    # ("Bloc STAR", "Ranked Robin") match the tables above and the score names.
    norm = lowered.replace("-", "_").replace(" ", "_")

    is_rcv = lowered in (IRV_NAMES | STV_NAMES)
    is_stv = lowered in STV_NAMES
    # Approval is matched fuzzily on purpose (tolerant of "Arroval").
    is_approval = (
        "approval" in norm
        or norm in {"approve", "av"}
        or bool(difflib.get_close_matches(norm, ["approval"], cutoff=0.6))
    )
    is_rr = norm in RANKED_ROBIN_NAMES
    is_plurality = norm in PLURALITY_NAMES

    known_score_names = {k.replace(" ", "_") for k in METHOD_BY_NAME}
    known = bool(declared) and (
        is_rcv or is_approval or is_rr or is_plurality
        or norm in known_score_names
    )

    ranked_ballots = ">" in ranked_ballot_body(ballots_text)

    if is_rr:
        family = "ranked_robin"
    elif is_plurality:
        family = "plurality"
    elif is_approval:
        family = "approval"
    elif is_stv:
        family = "stv"
    elif is_rcv or (not declared and ranked_ballots):
        family = "irv"
    else:
        family = "score"

    return {
        "declared": declared or None,
        "declared_lower": lowered,
        "normalized": norm,
        "family": family,
        "is_rcv": is_rcv,
        "is_stv": is_stv,
        "is_approval": is_approval,
        "is_rr": is_rr,
        "is_plurality": is_plurality,
        "ranked_ballots": ranked_ballots,
        "known": known,
    }


def _find_race(data, race_index=0):
    """Locate the race-like mapping that holds `ballots`, tolerating both the
    full schema and flattened forms:
      - election.races[i]          (full schema)
      - election.{ballots,...}     (single race under election, no races list)
      - races[i]                   (races list at top level)
      - {ballots,...}              (fully flat, top level)
    """
    if isinstance(data, dict):
        if isinstance(data.get("election"), dict):
            el = data["election"]
            return el["races"][race_index] if "races" in el else el
        if "races" in data:
            return data["races"][race_index]
        if "ballots" in data:
            return data
    raise KeyError("could not find a 'ballots' block in the YAML file")


# Output-control options a YAML file may set (under `options:` or top level).
OPTION_KEYS = (
    "show_matrix",
    "matrix_finalists_only",
    "show_condorcet",
    "show_score_counts",
    "show_runoff_percent",
    "brief",
    "collapse_ballots",
    "count_separator",
    "show_irv",
    "show_description",
    # Ranked Robin only: the Smith-set analysis in the on-screen echo. OFF by
    # default (the _tabulated mirror always carries it), and deliberately separate
    # from show_matrix so a file can opt into one without the other.
    "show_smith_set",
)

# The GLOBAL on-screen defaults — the house style, applied to every run.
# Since 2026-08-09 the teaching case files carry NO `options:` block at all;
# a file may still set one to override any of these, but that is reserved for
# the option-demo files and special renders. Two of these are auto-adjusted
# per election before the file's own options apply (see the CLI main):
#   * show_matrix is switched OFF for multi-winner races (a "Top 2 Finalist"
#     matrix is a single-winner concept) and for 2-candidate races (the
#     matrix would just echo the runoff).
# The `_tabulated` mirror ignores all of this — it always renders full detail
# (FULL_RENDER_OVERRIDES below). `--full` puts that same render on screen.
DEFAULT_OPTIONS = {
    "show_matrix": True,             # finalists head-to-head (auto-off: multi-winner, 2 candidates)
    "matrix_finalists_only": True,   # False = the full N×N grid
    "show_condorcet": False,
    "show_score_counts": False,
    "show_irv": False,               # vestigial: the divergence block always prints
    "show_description": False,       # the description stays in the file & the mirror
    "show_runoff_percent": True,     # the self-reconciling two-line runoff summary
    "brief": True,
    "collapse_ballots": True,
    "count_separator": "×",
    # Ranked Robin path only (read in run_ranked_robin, not via the globals):
    "show_smith_set": False,         # the mirror always carries the Smith block
}

# What the always-full `_tabulated` mirror forces on, regardless of any option
# above or in the file. `--full` applies the same set to the on-screen render.
# (count_separator is deliberately NOT forced — the file's choice is kept.)
# For a MULTI-WINNER race the forced-on matrix renders as plain head-to-head
# data — retitled "Preference Matrix", no "Top 2 Finalist" markers — because
# the finalists come from a silent seats=1 STAR analysis (see print_matrix).
FULL_RENDER_OVERRIDES = dict(
    show_matrix=True,
    matrix_finalists_only=False,
    show_condorcet=True,
    show_score_counts=True,
    show_runoff_percent=True,
    brief=False,
    collapse_ballots=True,  # collapsed counts are clearer than a raw dump
    show_irv=True,
    show_description=True,  # the saved file always keeps the full context
    full_report=True,  # expand the runoff line into the "Runoff math" funnel
)


def _strip_inline_comment(s):
    """Drop a trailing '# comment' from a single-line YAML scalar, the way real
    YAML would: '#' starts a comment only at the start of the value or after
    whitespace, and never inside a quoted string. (The PyYAML-less fallback
    needs this — `voting_method: STAR   # note` must yield 'STAR'.)"""
    quote = None
    for i, ch in enumerate(s):
        if quote:
            if ch == quote:
                quote = None
        elif ch in "\"'":
            quote = ch
        elif ch == "#" and (i == 0 or s[i - 1] in " \t"):
            return s[:i].strip()
    return s.strip()


# Human-context text fields the lite reader must not drop (plain scalars or
# `|-` block scalars). Same lookup preference as the PyYAML path's _pick().
_LITE_TITLE_KEYS = ("election_title", "title")
_LITE_DESCRIPTION_KEYS = (
    "scenario_description", "race_description", "election_description"
)


def _yaml_lite(text):
    """Extract just the fields we need from the STAR election schema, without
    requiring PyYAML: the first race's `ballots` block (a YAML `|` block
    scalar), `num_winners`, `voting_method`, any output `options`, and the
    human context (title / description — plain values or block scalars).
    Inline '# comments' after a value are stripped, as real YAML would.
    Returns a dict: {ballots, seats, method_name, options, title, description}."""
    lines = text.splitlines()

    def _block_scalar(idx, base):
        """Dedented text of the indented block below a 'key: |' at lines[idx]
        (key indented `base` columns). None if the block is empty."""
        block = []
        for nxt in lines[idx + 1:]:
            if nxt.strip() == "":
                block.append("")
                continue
            if len(nxt) - len(nxt.lstrip()) <= base:
                break  # dedent -> end of block
            block.append(nxt)
        nonempty = [b for b in block if b.strip()]
        if not nonempty:
            return None
        cut = min(len(b) - len(b.lstrip()) for b in nonempty)
        return "\n".join(b[cut:] if b.strip() else "" for b in block).strip("\n")

    seats = None
    method_name = None
    options = {}
    texts = {}
    for idx, ln in enumerate(lines):
        m = re.match(r"\s*num_winners:\s*(\d+)", ln)
        if m and seats is None:
            seats = int(m.group(1))
        m = re.match(r"\s*voting_method:\s*(\S.*?)\s*$", ln)
        if m and method_name is None:
            method_name = _strip_inline_comment(m.group(1)).strip("\"'")
        m = re.match(r"\s*([a-z_]+):\s*(\S.*?)\s*$", ln)
        if m and m.group(1) in OPTION_KEYS and m.group(1) not in options:
            options[m.group(1)] = _strip_inline_comment(m.group(2)).strip("\"'")
        # Title / description: TOP-LEVEL (column 0) keys only, so text inside
        # another block scalar (e.g. video_script) can't shadow the real fields.
        m = re.match(r"([a-z_]+):\s*(.*?)\s*$", ln)
        if m and m.group(1) in _LITE_TITLE_KEYS + _LITE_DESCRIPTION_KEYS \
                and m.group(1) not in texts:
            val = m.group(2)
            if re.match(r"[|>][+-]?\s*$", val):  # literal/folded block scalar
                val = _block_scalar(idx, 0)
            else:
                val = _strip_inline_comment(val).strip("\"'")
            if val:
                texts[m.group(1)] = val

    ballots_text = None
    for idx, ln in enumerate(lines):
        key = re.match(r"(\s*)ballots:\s*\|", ln)
        if key:
            ballots_text = _block_scalar(idx, len(key.group(1)))
            break

    return {
        "ballots": ballots_text,
        "seats": seats,
        "method_name": method_name,
        "options": options,
        "title": next((texts[k] for k in _LITE_TITLE_KEYS if k in texts), None),
        "description": next(
            (texts[k] for k in _LITE_DESCRIPTION_KEYS if k in texts), None),
    }


# Flipped (once) when load_election falls back to the PyYAML-less reader. The
# lite reader covers ballots/seats/method/options/title/description, but still
# ignores blocs, lot_numbers, quorum and eligible_voters — enough to change a
# report (or even a tie-break), so degraded runs must never overwrite committed
# _tabulated mirrors (see _skip_degraded_mirror).
_NO_PYYAML_FALLBACK = False


def _warn_no_pyyaml():
    """Mark the run as degraded and warn ONCE, loudly, on stderr."""
    global _NO_PYYAML_FALLBACK
    if not _NO_PYYAML_FALLBACK:
        _NO_PYYAML_FALLBACK = True
        print(
            "WARNING: PyYAML not installed — using the built-in minimal YAML "
            "reader (blocs / lot_numbers / quorum / eligible_voters are "
            "ignored; existing _tabulated mirrors will NOT be overwritten). "
            "Run via the repo .venv, or `pip install pyyaml`, for full output.",
            file=sys.stderr,
        )


def _skip_degraded_mirror(out_path):
    """True -> leave `out_path` alone. In the PyYAML-less fallback the parse is
    degraded, so overwriting an EXISTING _tabulated mirror would silently strip
    content from a committed file. A brand-new mirror (nothing to clobber) is
    still written — the stderr warning already flags the whole run as degraded."""
    if _NO_PYYAML_FALLBACK and Path(out_path).exists():
        print(
            f"NOTE: degraded run (no PyYAML) — existing mirror left untouched: "
            f"{Path(out_path).name}",
            file=sys.stderr,
        )
        return True
    return False


KEY_COMPONENTS_HELP = """\
An election file (in YAML format) needs three things:
  - voting_method : STAR (default) | Approval | "Bloc STAR" (aka bloc) | sss | rrv | allocated | RCV_IRV
  - num_winners   : how many seats to fill (1 = single-winner)
  - ballots       : a 0-5 score grid -- a header row of candidate names, then one
                    row per voter

Minimal example (copy & paste):

  voting_method: STAR
  num_winners: 1
  ballots: |-
    Ann,Bob,Cal
    5,4,0
    3,5,2
"""


def load_election(path, race_index=0):
    """Load an election from a file. Returns a dict:
        {"ballots": str, "seats": int|None, "method": obj|None, "options": dict}

    - .yaml / .yml : reads the race; pulls the `ballots` block, `num_winners`
      -> seats, `voting_method` -> method, and any output `options`. Uses
      PyYAML when available, else a built-in extractor (no install needed).
    - anything else : raw text as ballots; seats/method/options empty.
    """
    p = Path(path)
    if not p.is_absolute() and not p.exists():
        # Resolve relative to this script, so it works regardless of cwd.
        p = Path(__file__).resolve().parent / path
    text = p.read_text(encoding="utf-8")

    if not str(path).lower().endswith((".yaml", ".yml")):
        return {
            "ballots": text,
            "seats": None,
            "method": None,
            "options": {},
            "title": None,
            "description": None,
        }

    title = description = None
    try:
        import yaml  # use PyYAML when present (most robust)

        try:
            data = yaml.safe_load(text)
        except yaml.YAMLError as e:
            mark = getattr(e, "problem_mark", None)
            where = (f" near line {mark.line + 1}, column {mark.column + 1}"
                     if mark is not None else "")
            problem = (getattr(e, "problem", None) or "invalid YAML syntax").strip()
            # Reference template first, then the specific error LAST — a terminal
            # shows the final lines closest to the prompt, so the file-specific
            # message is the most visible thing after the scroll.
            print(
                KEY_COMPONENTS_HELP + "\n"
                f"Error: could not parse '{p.name}'{where} — {problem}.\n"
                "       Most common cause: the ballots grid must sit under a literal\n"
                "       block scalar — write `ballots: |-` and indent every row beneath\n"
                "       it. Any `#` comment line inside that block must be indented too\n"
                "       (inside a block, `#` is data, and a line at the left margin ends\n"
                "       the block early). Move candidate-legend comments ABOVE `ballots:`."
            )
            sys.exit(1)
        # Duplicate top-level keys: YAML silently keeps only the LAST one, so
        # an earlier ballots:/voting_method: block would vanish without a trace.
        _dups = []
        for _key in ("ballots", "voting_method", "num_winners",
                     "expected_winners", "lot_numbers", "options"):
            _n = len(re.findall(rf"(?m)^{_key}\s*:", text))
            if _n > 1:
                _dups.append(f"'{_key}:' appears {_n} times")
        if _dups:
            print(
                f"Error: duplicate top-level key(s) in '{p.name}': "
                f"{'; '.join(_dups)}.\n"
                "       YAML keeps only the LAST occurrence — the earlier data\n"
                "       would be silently dropped. Keep exactly one of each key:\n"
                "       one election per file."
            )
            sys.exit(1)

        # ONE election per file (house rule). A multi-race file would be
        # silently truncated to its first race, so it is an error instead.
        # (Multi-race BetterVoting JSON exports are fine — the converter,
        # YAML_library/1_positive/01_convert_json_yaml.py, splits them.)
        _races = None
        if isinstance(data, dict):
            if isinstance(data.get("election"), dict) and \
                    isinstance(data["election"].get("races"), list):
                _races = data["election"]["races"]
            elif isinstance(data.get("races"), list):
                _races = data["races"]
        if _races is not None and len(_races) > 1 and race_index == 0:
            _titles = ", ".join(
                str((r or {}).get("title") or (r or {}).get("race_id")
                    or f"race {i + 1}")
                for i, r in enumerate(_races))
            print(
                f"Error: '{p.name}' contains {len(_races)} races ({_titles}).\n"
                "       This library counts ONE election per file — split each\n"
                "       race into its own YAML file."
            )
            sys.exit(1)

        try:
            race = _find_race(data, race_index)
            ballots_text = race["ballots"]
        except (KeyError, TypeError, IndexError):
            print(
                f"Error: no 'ballots:' block found in '{p.name}'.\n"
                "(If this is the old nested schema 'election_parameters -> races ->\n"
                " race_1 -> ballots', convert it to the flat form shown below.)\n\n"
                + KEY_COMPONENTS_HELP
            )
            sys.exit(1)
        if not isinstance(ballots_text, str):
            print(
                f"Error: 'ballots:' in '{p.name}' is not a text block "
                f"(got a YAML {type(ballots_text).__name__}).\n"
                "       Write it as a literal block — 'ballots: |-' on its own line,\n"
                "       then one indented row per line:\n\n"
                "  ballots: |-\n"
                "    Ann,Bob,Cal\n"
                "    5,4,0\n"
                "    3,5,2"
            )
            sys.exit(1)
        seats = None
        if "num_winners" in race:
            try:
                seats = int(race["num_winners"])
            except (TypeError, ValueError):
                print(
                    f"Error: num_winners must be a whole number, got "
                    f"{race['num_winners']!r}.\n"
                    "       Example: num_winners: 1"
                )
                sys.exit(1)
            if seats < 1:
                print(f"Error: num_winners must be at least 1, got {seats}.\n"
                      "       A race elects at least one winner.")
                sys.exit(1)
        method_name = race.get("voting_method")
        # Collect options from every level, most-specific last so it wins:
        # top-level  <  `election:` wrapper  <  the race itself. (BetterVoting-style
        # nested files put the block under `election.options`, so it must be read
        # here too — otherwise the whole options block is silently ignored.)
        options = {}
        if isinstance(data, dict) and isinstance(data.get("options"), dict):
            options.update(data["options"])
        _el_wrap = data.get("election") if isinstance(data, dict) else None
        if isinstance(_el_wrap, dict) and isinstance(_el_wrap.get("options"), dict):
            options.update(_el_wrap["options"])
        if isinstance(race.get("options"), dict):
            options.update(race["options"])

        # Optional human-readable context, looked up on the race first, then the
        # top-level mapping (and an `election:` wrapper if present).
        top = data if isinstance(data, dict) else {}
        el = top.get("election") if isinstance(top.get("election"), dict) else {}

        def _pick(*keys):
            for src in (race, el, top):
                if isinstance(src, dict):
                    for k in keys:
                        v = src.get(k)
                        if v:
                            return str(v).strip()
            return None

        title = _pick("election_title", "title")
        description = _pick(
            "scenario_description", "race_description", "election_description"
        )

        def _num(*keys):
            for src in (race, el, top):
                if isinstance(src, dict):
                    for k in keys:
                        if src.get(k) is not None:
                            return src.get(k)
            return None

        eligible_voters = _num("eligible_voters", "electorate", "registered_voters")
        if eligible_voters is not None:
            eligible_voters = int(eligible_voters)
        quorum = _num("quorum", "minimum_quorum")

        # Optional candidate blocs for the vote-splitting check, e.g.
        #   blocs:
        #     Chocolate: [DarkChoco, MilkChoco]
        blocs = None
        for _src in (race, el, top):
            if isinstance(_src, dict) and isinstance(_src.get("blocs"), dict):
                blocs = _src.get("blocs")
                break

        # Optional official tie-breaking lot order — a list of candidate IDs in
        # priority order (index 0 = highest priority, wins ties). Sourced from the
        # election provider (e.g. BetterVoting's `perm` / `tieBreakOrder`) and
        # written into the YAML by the JSON->YAML converter so re-tabulation
        # reproduces the provider's exact tiebreak instead of a fallback order.
        lot_numbers = None
        for _src in (race, el, top):
            if isinstance(_src, dict) and isinstance(_src.get("lot_numbers"), list):
                lot_numbers = [str(x).strip() for x in _src.get("lot_numbers")]
                break
    except ImportError:
        _warn_no_pyyaml()
        lite = _yaml_lite(text)
        ballots_text = lite["ballots"]
        seats = lite["seats"]
        method_name = lite["method_name"]
        options = lite["options"]
        title = lite["title"]
        description = lite["description"]
        eligible_voters = quorum = blocs = lot_numbers = None

    method = (
        METHOD_BY_NAME.get(str(method_name).strip().lower()) if method_name else None
    )
    return {
        "ballots": _normalize_ballot_separators(ballots_text),
        "seats": seats,
        "method": method,
        "method_name": method_name,
        "options": options,
        "title": title,
        "description": description,
        "eligible_voters": eligible_voters,
        "quorum": quorum,
        "blocs": blocs,
        "lot_numbers": lot_numbers,
    }


_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")


def strip_ansi(s):
    """Remove ANSI color escape sequences (for saving plain text)."""
    return _ANSI_RE.sub("", s)


def save_results_to_file(path, winners, report):
    """Append (or replace) a top-level `expected_results:` block in the file,
    holding the winners and the plain-text tabulation report. Comments and
    formatting elsewhere in the file are preserved (only this block is rewritten).
    """
    p = Path(path)
    if not p.is_absolute() and not p.exists():
        p = Path(__file__).resolve().parent / path
    text = p.read_text(encoding="utf-8")

    # Drop any previous top-level expected_results block (to end of file).
    text = re.sub(r"\n*^expected_results:.*\Z", "", text, flags=re.S | re.M).rstrip(
        "\n"
    )

    winner_lines = "\n".join(f"  - {w}" for w in winners) or "  []"
    report_body = "\n".join(
        ("    " + ln).rstrip() for ln in strip_ansi(report).splitlines()
    )
    block = (
        "\n\nexpected_results:\n"
        f"  winners:\n{winner_lines}\n"
        "  report: |-\n"
        f"{report_body}\n"
    )
    p.write_text(text + "\n" + block, encoding="utf-8")


def tabulated_output_path(src_path):
    """Where to write the plain-text tabulation for an election file.

    The copy goes into a '<folder>_tabulated' subfolder NESTED INSIDE the source
    file's own folder, and the file itself also gets a '_tabulated' suffix.
    Example:
        .../method_comparisons/black_curtain/cases/foo.yaml
        -> .../method_comparisons/black_curtain/cases/cases_tabulated/foo_tabulated.txt
    """
    p = Path(src_path).resolve()
    out_dir = p.parent / (p.parent.name + "_tabulated")
    return out_dir / (p.stem + "_tabulated.txt")


def ensure_filename_comment(path):
    """Make sure a YAML election file ends with a '# file: <name>' comment that
    matches its CURRENT name. Rewrites only when missing/stale, so a normal run
    (already correct) changes nothing — no needless edits or editor reloads.
    """
    p = Path(path)
    if p.suffix.lower() not in (".yaml", ".yml"):
        return
    try:
        text = p.read_text(encoding="utf-8")
    except OSError:
        return
    lines = text.splitlines()
    # Drop trailing blanks and any existing '# file:' trailer.
    while lines and (not lines[-1].strip() or lines[-1].lstrip().startswith("# file:")):
        lines.pop()
    desired = "\n".join(lines).rstrip() + f"\n\n# file: {p.name}\n"
    if desired != text:
        try:
            p.write_text(desired, encoding="utf-8")
        except OSError:
            pass  # read-only / locked file: skip silently, don't break the run


def write_tabulated_copy(src_path, output_text):
    """Write the (ANSI-stripped) tabulation text under the '<folder>_tabulated'
    mirror folder nested inside the source file's folder. Returns the path written."""
    out_path = tabulated_output_path(src_path)
    if _skip_degraded_mirror(out_path):
        return out_path
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(strip_ansi(output_text), encoding="utf-8")
    return out_path


def write_composed_tabulated(src_path, results_text):
    """Write the standard '_tabulated' mirror: a provenance header, the ORIGINAL
    election file copied as-is, then the (ANSI-stripped) tabulation results.
    Returns the path written.

    House rule: EVERY tabulated YAML gets this full-context mirror, so every
    method path that writes a PRIMARY mirror goes through here — STAR, Approval,
    RCV-IRV, Ranked Robin (incl. Bloc RR) and Plurality (incl. SNTV). Only
    AUXILIARY mirrors (the method-tagged RCV-IRV / RCV-RR reports generated
    alongside a STAR run, written via `out_path`) stay bare: the primary mirror
    beside them already carries the source file.

    The header carries NO wall-clock or mtime stamps: mirrors are committed to
    git, and regenerating them must yield byte-identical files whenever the
    tabulation content is identical — timestamps would make every regeneration
    a repo-wide diff of pure environment churn."""
    try:
        original = Path(src_path).read_text(encoding="utf-8")
    except OSError:
        original = ""
    divider = "=" * 70
    src_name = Path(src_path).name
    out_name = tabulated_output_path(src_path).name
    composed = (
        f"{divider}\n"
        f"SOURCE FILE:     {src_name}\n"
        f"TABULATED FILE:  {out_name}\n"
        f"{divider}\n\n"
        f"{original.rstrip()}\n\n"
        f"{divider}\n"
        f"TABULATION RESULTS\n"
        f"{divider}\n\n"
        f"{strip_ansi(results_text).lstrip()}"
    )
    return write_tabulated_copy(src_path, composed)


def aux_tabulated_path(src_path, method_tag):
    """Path for an AUXILIARY method mirror (e.g. the round-by-round RCV-IRV or
    Ranked Robin report generated alongside a STAR run). Same '<folder>_tabulated'
    mirror folder (nested inside the source folder) as the STAR copy, but the
    filename carries a method tag so it never collides with the primary
    '<stem>_tabulated.txt':
        .../split_voting/_main/04_star_wars_vote_split.yaml
        -> .../split_voting/_main/_main_tabulated/04_star_wars_vote_split_RCV-IRV_tabulated.txt
    """
    p = Path(src_path).resolve()
    out_dir = p.parent / (p.parent.name + "_tabulated")
    return out_dir / f"{p.stem}_{method_tag}_tabulated.txt"


def method_mirror_link(out_path, src_path):
    """Display path for a generated mirror, relative to the source file's folder
    ('<folder>_tabulated/<name>') — clickable in most IDE terminals. Deliberately
    NOT an absolute file:// URL: this line lands inside the committed _tabulated
    mirrors, and an absolute path would differ on every machine/checkout."""
    out_path = Path(out_path).resolve()
    try:
        short = out_path.relative_to(Path(src_path).resolve().parent)
    except ValueError:
        short = out_path
    return str(short)


def build_irv_report(candidates, ballots, priority, title=None):
    """Round-by-round RCV-IRV report text (ANSI-free) for the SAME ballots a STAR
    run tabulated, rendered exactly like the standalone RCV-IRV engine: a header
    plus pyrankvote's per-round elimination table. Scores are converted to ranks
    (higher score = higher preference, 0 = unranked; equal non-zero scores broken
    by `priority`). Returns None if the IRV engine isn't importable."""
    if not _IRV_AVAILABLE or not candidates or not ballots:
        return None
    order = [c for c in (priority or candidates) if c in candidates]
    for c in candidates:
        if c not in order:
            order.append(c)
    cand_objs = {c: _IRVCandidate(c) for c in candidates}
    pv_ballots = [
        _IRVBallot(ranked_candidates=[cand_objs[n] for n in _score_to_rank(b, order)])
        for b in ballots
    ]
    try:
        import random
        random.seed(0)      # reproducible elimination-tie resolution (see
        result = _pyrankvote.instant_runoff_voting(   # compute_irv_winner)
            list(cand_objs.values()), pv_ballots)
    except Exception:
        return None
    label = "RCV / Instant-Runoff Voting (single winner)"
    L = [f"--- {label} ---"]
    if title:
        L.append(f"  {title}")
    L.append(f" Tabulating {len(ballots)} ballots "
             "(converted from score ballots; 0 = unranked, equal scores broken "
             "by candidate priority).")
    L.append("")
    # Ballots: show how each score ballot becomes the STRICT ranking IRV reads
    # (0-scores are unranked/dropped; equal non-zero scores broken by priority),
    # mirroring the Ranked Robin report's ballot block so a reader can follow the
    # elimination — e.g. see which candidate falls off the low ballots.
    L.append("Ballots:")
    L.append("   the ranking RCV-IRV reads (0 = unranked, equal scores broken by "
             "priority);")
    L.append(f"   the source score ballot follows in () per column: {', '.join(candidates)}")
    seen_rows, counts = [], {}
    for b in ballots:
        scores = tuple(b.get(c, 0) for c in candidates)
        if scores not in counts:
            counts[scores] = 0
            seen_rows.append(scores)
        counts[scores] += 1
    for scores in seen_rows:
        b = dict(zip(candidates, scores))
        ranking = _score_to_rank(b, order)      # 0s dropped, ties by priority
        rank_txt = " > ".join(ranking) if ranking else "(all unranked)"
        score_txt = ", ".join(str(s) for s in scores)
        L.append(f"   {counts[scores]:>3} ×   {rank_txt}      ({score_txt})")
    L.append("")
    L.append(str(result))
    L.append("")
    L.append(f"Winner(s) — {label}")
    winners = result.get_winners()
    L += [f"  {wn.name}" for wn in winners] or ["  (no winner)"]
    L.append("")
    # RCV-IRV is NOT Smith-efficient, so this block is a real pass/fail rather than
    # a restatement: it says whether the eliminations walked out of the set of
    # candidates that collectively beat everyone else.
    smith_block = format_smith_set(
        candidates, calculate_preference_matrix(candidates, ballots),
        winner=winners[0].name if winners else None,
        method_label="RCV-IRV", smith_efficient=False)
    if smith_block:
        L += smith_block
        L.append("")
    L.append("NOTE: a generated cross-method view of the STAR ballots, for "
             "comparison only — not the official STAR result.")
    return "\n".join(L)


def _normalize_ballot_separators(text):
    """Forgive the one common copy-paste mistake: a ballots grid separated by
    *spaces* instead of commas/tabs (e.g. an aligned slide table). When the block
    is unambiguous, rewrite it to comma-separated so the normal parser accepts it;
    otherwise leave it untouched so the parser reports the error.

    Acts only when (a) no content line already has a comma or tab, and (b) the
    header and every data row split into the SAME number of whitespace tokens —
    so a multi-word candidate name can never be silently split apart.

    (Deliberately NOT a general delimiter-sniffer: comma is the one canonical
    format, space-alignment is the one mistake worth auto-fixing, and everything
    else gets a clear error rather than clever guessing.)
    """
    if not text:
        return text
    raw = text.split("\n")
    content = []  # (line_index, comment-stripped content) for non-blank lines
    for i, ln in enumerate(raw):
        body = ln if ln.strip().startswith("#,") else ln.split("#", 1)[0]
        s = body.strip()
        if s:
            content.append((i, s))
    if len(content) < 2:
        return text
    if any(("," in s or "\t" in s) for _, s in content):
        return text  # already delimited — don't touch it
    if any(">" in s for _, s in content):
        # RANKED ballots have no columns to align, so a space inside one is part
        # of a candidate's NAME and never a delimiter. Without this the rescue
        # reads "14:Labrador>Golden Retriever>German Shepherd>Cat>Parrot" as
        # three whitespace tokens, finds every ballot equally "ragged" (each
        # ranks the same five names, so each splits the same way), calls that a
        # uniform grid and rewrites it to "…>Golden, Retriever>German,
        # Shepherd>…". The column-count guard below cannot catch it: it is
        # designed for a score grid, where a two-word header makes the header
        # WIDER than its rows — on ranked ballots the two-word name sits on
        # every line, so the counts agree and the guard waves it through.
        # Damage is silent, because the mangling is CONSISTENT: the winner is
        # still right, and only the printed names give it away — the Smith
        # block rendered "Outside (4): Golden, Retriever, German, Shepherd,
        # Cat, Parrot", six tokens for four candidates, two of them nonsense.
        # (`--json` was the one caller that failed loudly: the answer key
        # "Golden Retriever" no longer matched any emitted candidate, so
        # election_json dropped the `expected` block entirely.)
        return text
    token_lists = [(i, s.split()) for i, s in content]
    counts = {len(toks) for _, toks in token_lists}
    if len(counts) != 1 or counts == {1}:
        return text  # ragged columns (e.g. multi-word names) — let parser error
    out = list(raw)
    for i, toks in token_lists:
        out[i] = ", ".join(toks)
    return "\n".join(out)


def parse_ballots_from_string(ballot_string):
    """
    Parses ballot data. Supports two formats per line:
    1. Standard CSV: 0,5,2
    2. Compact Underscore: 052_225_323

    Includes validation to warn on length mismatches.
    """
    lines = []
    for line in ballot_string.strip().split("\n"):
        line = line.strip()
        if line.startswith("#,"):
            clean_line = line
        else:
            clean_line = line.split("#")[0].strip()
        if clean_line:
            lines.append(clean_line)

    if not lines:
        return [], [], []

    # Parse Headers
    headers = [name.strip() for name in re.split(r"[,\t]+", lines[0]) if name.strip()]
    if headers and headers[0] == "#":
        headers.pop(0)

    # The first column may carry a "Count" label documenting the colon-weight
    # prefix (e.g. "Count:Chocolate"). It's not part of the candidate name.
    if headers and re.match(r"(?i)^count\s*:", headers[0]):
        headers[0] = headers[0].split(":", 1)[1].strip()

    ballots = []
    display_rows = []  # parallel to ballots; keeps blanks visible as "-"

    def cell_to_score(cell):
        # An empty cell or any marker character counts as 0 (no support),
        # same as an explicit 0. (Markers: see MARKER_MEANINGS.)
        cell = cell.strip()
        if cell == "" or cell in MARKER_MEANINGS:
            return 0
        return int(cell)  # may raise ValueError -> caller falls through

    def display_cell(cell, score):
        # Keep the original marker visible in the echo; blank empty cell -> "-".
        cell = cell.strip()
        if cell == "":
            return "-"
        if cell in MARKER_MEANINGS:
            return cell
        return str(score)

    for line_num, line in enumerate(lines[1:], start=2):
        # 1. Attempt Standard CSV Parse first. Use a single-delimiter split so
        #    blank cells keep their position (e.g. "5,5,,0" or "5,5,4,-").
        parts = re.split(r"[,\t]", line)
        weight = 1

        # Handle "Weight x Score" repetition, e.g. "9:5", "9x5", "9 × 5".
        # Accepted separators: ":", "x"/"X", "×".
        wmatch = re.match(r"\s*(\d+)\s*[:xX×]\s*(.*)", parts[0])
        if wmatch:
            weight = int(wmatch.group(1))
            parts[0] = wmatch.group(2)

        cells = [p.strip() for p in parts]

        # Whole-ballot markers (~, ?, %) apply to the entire race. They may only
        # appear as a full row of one marker (e.g. "~,~,~,~") or a lone token
        # ("~"); mixing with scores is an error.
        present_ballot_markers = {c for c in cells if c in BALLOT_LEVEL_MARKERS}
        if present_ballot_markers:
            marker = next(iter(present_ballot_markers))
            non_blank = [c for c in cells if c != ""]
            is_whole_ballot = len(present_ballot_markers) == 1 and all(
                c == marker for c in non_blank
            )
            if not is_whole_ballot:
                full_row = ",".join([marker] * len(headers))
                print(
                    f"{COLOR_RED}Error (Line {line_num}):{COLOR_RESET} "
                    f"'{marker}' is a whole-ballot marker "
                    f"({MARKER_MEANINGS[marker].split(' — ')[0]}),\n"
                    f"  so it cannot be mixed with scores.\n"
                    f"  Got:  {line}\n"
                    f"  Fix:  use a full row '{full_row}', or remove '{marker}'."
                )
                sys.exit(1)
            # Valid whole-ballot row: every candidate scores 0, ballot counts.
            ballot = {h: 0 for h in headers}
            display = ",".join([marker] * len(headers))
            for _ in range(weight):
                ballots.append(ballot)
                display_rows.append(display)
            continue

        # Check if this matches Standard CSV (Score count == Header count)
        if len(cells) == len(headers):
            try:
                scores = [cell_to_score(p) for p in cells]
                ballot = {h: s for h, s in zip(headers, scores)}
                # Display keeps the original markers visible (source stays
                # faithful) even though they tabulate as 0.
                display = ",".join(display_cell(c, s) for c, s in zip(cells, scores))
                for _ in range(weight):
                    ballots.append(ballot)
                    display_rows.append(display)
                continue  # Successfully parsed as CSV
            except ValueError:
                pass  # Fall through

        # 2. Attempt Compact Underscore Format
        segments = line.split("_")
        for seg in segments:
            seg = seg.strip()
            if not seg:
                continue

            # PLAUSIBILITY CHECK
            if seg.isdigit():
                if len(seg) == len(headers):
                    scores = [int(char) for char in seg]
                    ballot = {h: s for h, s in zip(headers, scores)}
                    ballots.append(ballot)
                    display_rows.append(",".join(str(s) for s in scores))
                else:
                    # Found a digit-only chunk with wrong length -> WARN USER
                    print(
                        f"{COLOR_RED}Warning (Line {line_num}):{COLOR_RESET} "
                        f"Segment '{seg}' has {len(seg)} digits, but expected {len(headers)} "
                        f"for candidates {headers}. Ignored."
                    )

    return headers, ballots, display_rows


def calculate_preference_matrix(candidates, ballots):
    """
    Generates the pairwise preference matrix from already-parsed ballots.
    """
    if not ballots or not candidates:
        return None

    num_ballots = len(ballots)
    matrix = defaultdict(lambda: defaultdict(tuple))

    for c_i in candidates:
        for c_j in candidates:
            if c_i == c_j:
                matrix[c_i][c_j] = (0, 0, num_ballots)
                continue

            for_i = 0
            against_i = 0
            no_pref = 0

            for ballot in ballots:
                s_i = ballot.get(c_i, 0)
                s_j = ballot.get(c_j, 0)

                if s_i > s_j:
                    for_i += 1
                elif s_j > s_i:
                    against_i += 1
                else:
                    no_pref += 1

            matrix[c_i][c_j] = (for_i, against_i, no_pref)

    return matrix


def _all_pairs_draw(members, matrix):
    """True when every head-to-head AMONG `members` is a pairwise DRAW.

    This is the dead-heat / cycle test, and it lives in one place on purpose: a
    group of co-top candidates who merely DRAW each other is a **dead heat**, not
    a Condorcet cycle — nobody beats anybody, so there is no directed loop to
    resolve. Both the Ranked Robin winner line and the Smith-set block ask this
    question about the same matrix, so they share the answer; two independent
    tests is exactly how the two lines came to contradict each other.

    Vacuously True for a group of fewer than two (no pair to decide), so callers
    should already know they have several members.
    """
    return all(matrix[a][b][0] == matrix[a][b][1]
               for a in members for b in members if a != b)


def _group_shape(members, matrix):
    """Classify a group of co-top candidates — the Ranked Robin leaders, or the
    Smith set — into the THREE shapes it can actually take:

        "dead heat"  every head-to-head among them DRAWS; nobody beats anybody.
        "cycle"      their wins close a directed loop (rock-paper-scissors).
        "mixed"      some beat others, but no loop closes — a group held open by
                     draws rather than by a circle of wins.

    That third shape is the easy one to miss, which is exactly why the test lives
    in one place. **"Not all draws" does NOT imply "cycle":** `A beats B, B draws
    C, C draws A` contains a win and still no loop, and a TWO-member group settled
    by a single head-to-head can never be a loop at all (a 2-cycle would need A to
    beat B and B to beat A at once). Calling either one a Condorcet cycle is not a
    loose word — it is false, and it points the reader at cycle-resolution rules
    that have no cycle to resolve.

    Vacuously "dead heat" for a group of fewer than two, so callers should already
    know they have several members.
    """
    if _all_pairs_draw(members, matrix):
        return "dead heat"
    # _find_beats_cycle wants an ADJACENCY dict (who does each member beat?),
    # restricted to the group — a win over an outsider says nothing about whether
    # the group itself closes a loop.
    beats = {a: [b for b in members
                 if b != a and matrix[a][b][0] > matrix[a][b][1]]
             for a in members}
    return "cycle" if _find_beats_cycle(list(members), beats) else "mixed"


def smith_set(candidates, matrix):
    """The Smith set: the smallest non-empty group of candidates such that every
    member beats every candidate OUTSIDE the group head-to-head.

    Returned as a list, ordered by Copeland score (the report's own standings
    order), so the caller can print it top-down.

    Why walking the standings is enough: if the Smith set has k of n candidates,
    every member beats all n-k outsiders (score >= n-k) while an outsider can only
    beat other outsiders (score <= n-k-1). So every member STRICTLY outscores every
    non-member, which makes the Smith set a prefix of the Copeland ordering. We
    still *verify* dominance for each prefix rather than trusting the order, and
    return the smallest prefix that dominates — dominating sets are nested, so the
    smallest dominating set is the Smith set.

    A pairwise DRAW does not qualify as "beats", so a drawn matchup keeps both
    candidates in the set. That is exactly where Smith and Schwartz part company.
    """
    if not candidates or not matrix:
        return []
    beats, wins, draws = set(), {c: 0 for c in candidates}, {c: 0 for c in candidates}
    for a in candidates:
        for b in candidates:
            if a == b:
                continue
            fa, ag, _ = matrix[a][b]
            if fa > ag:
                beats.add((a, b))
                wins[a] += 1
            elif fa == ag:
                draws[a] += 1
    cope = {c: wins[c] + 0.5 * draws[c] for c in candidates}
    order = sorted(candidates, key=lambda c: (-cope[c], candidates.index(c)))
    for k in range(1, len(order) + 1):
        inside, outside = order[:k], order[k:]
        if all((a, b) in beats for a in inside for b in outside):
            return inside
    return list(order)                      # unreachable: the full field dominates


def format_smith_set(candidates, matrix, winner=None, method_label=None,
                     smith_efficient=False):
    """Report block naming the Smith set, whether it is a lone Condorcet winner, a
    top cycle, an all-draws dead heat or a mixed group held open by draws, and
    whether `winner` landed inside it.

    `smith_efficient` marks methods that CANNOT leave the set (Ranked Robin /
    Copeland), so the line can say "guaranteed" instead of "it happened to hold".
    Returns a list of lines (empty when there is nothing to say).
    """
    if not candidates or not matrix or len(candidates) < 2:
        return []
    club = smith_set(candidates, matrix)
    if not club:
        return []
    outside = [c for c in candidates if c not in club]
    n, k = len(candidates), len(club)

    L = ["--- Smith Set (the generalized Condorcet winner) ---",
         "The smallest group whose every member beats every candidate outside it —",
         "the honest answer to \"who is even in contention?\".",
         f"   Smith set ({k} of {n}): {', '.join(club)}"]
    L.append(f"   Outside ({len(outside)}):{' ' * max(1, 9 - len(str(len(outside))))}"
             f"{', '.join(outside) if outside else '—'}")
    if k == 1:
        L.append(f"   One member ⇒ {club[0]} is the Condorcet winner, beating every "
                 "rival head-to-head.")
    else:
        # Several members means NO Condorcet winner — but not necessarily a cycle,
        # and not necessarily a dead heat either. Same classifier the Ranked Robin
        # winner line uses (asked here about the SET, which is what this sentence
        # is about), so the two lines cannot contradict each other.
        shape = _group_shape(club, matrix)
        L.append("   More than one member ⇒ NO Condorcet winner: the top of the "
                 "tournament is a")
        if shape == "dead heat":
            L.append("   dead heat (its members DRAW each other head-to-head), so the "
                     "strongest")
            L.append("   \"candidate\" is a set, not a person. No member beats another, "
                     "so there is no")
            L.append("   loop for Minimax / Ranked Pairs / Schulze to disagree about — "
                     "which member")
            L.append("   wins is left to the tiebreak, not to a cycle rule. See")
            L.append("   05_Ranked_Robin/01_Learn/rr_tiebreak_lh_vs_bv.md.")
        elif shape == "mixed":
            L.append("   group held open by draws, so the strongest \"candidate\" is a "
                     "set, not a")
            L.append("   person. Some members DO beat others, but no member beats them "
                     "all — a draw")
            L.append("   blocks the sweep. No loop closes either, so there is no cycle "
                     "for Minimax /")
            L.append("   Ranked Pairs / Schulze to resolve: which member wins is left "
                     "to the")
            L.append("   tiebreak, not to a cycle rule. See")
            L.append("   05_Ranked_Robin/01_Learn/rr_tiebreak_lh_vs_bv.md.")
        else:
            L.append("   cycle, so the strongest \"candidate\" is a set, not a person. "
                     "Which member of")
            L.append("   the set should win is exactly what Minimax / Ranked Pairs / "
                     "Schulze disagree")
            L.append("   about — see 05_Ranked_Robin/01_Learn/cycle_resolution.md.")

    # The Copeland leaders are always inside the Smith set, but need not BE it —
    # the win-loss table's top block can understate how wide the contention is.
    wins = {c: sum(1 for b in candidates
                   if b != c and matrix[c][b][0] > matrix[c][b][1]) for c in candidates}
    draws = {c: sum(1 for b in candidates
                    if b != c and matrix[c][b][0] == matrix[c][b][1]) for c in candidates}
    cope = {c: wins[c] + 0.5 * draws[c] for c in candidates}
    best = max(cope.values())
    leaders = [c for c in club if cope[c] == best]
    if len(leaders) < k:
        L.append(f"   Note: the Copeland leaders ({', '.join(leaders)}) are only part of "
                 "the set — the")
        L.append("   win–loss table's top block understates how wide the contention is.")

    if winner is not None and winner in candidates:
        label = method_label or "Winner"
        if winner in club:
            L.append(f"   {label} winner {winner} is INSIDE the Smith set. ✓")
            if smith_efficient:
                L.append("      Guaranteed: Ranked Robin (Copeland) is Smith-efficient — "
                         "every member of")
                L.append("      the set outscores every outsider, so the top of the "
                         "win–loss table is")
                L.append("      always inside the set, however the tie among them is "
                         "then broken.")
            else:
                L.append(f"      Not guaranteed — {label} is not Smith-efficient — but "
                         "it holds here.")
        else:
            beaters = ", ".join(club)
            L.append(f"   {label} winner {winner} is OUTSIDE the Smith set. ✗")
            L.append(f"      Every member of the set ({beaters}) beats {winner} "
                     "head-to-head, yet")
            L.append(f"      {label} elected {winner} anyway. {label} is not "
                     "Smith-efficient (nor")
            L.append("      Condorcet-efficient) — this is the shape a center squeeze "
                     "leaves behind.")

    # A draw can only ever happen INSIDE the set (members beat outsiders strictly),
    # and a draw is enough to keep a candidate in Smith but not in Schwartz.
    if any(matrix[a][b][0] == matrix[a][b][1] for a in club for b in club if a != b):
        L.append("   Fine print: this set contains a pairwise DRAW, and a draw is enough "
                 "to keep a")
        L.append("   candidate in the Smith set but not in the tighter Schwartz set — "
                 "so Schwartz")
        L.append("   may be smaller here.")
    L.append("   More: 07_Concepts/topics/smith_set.md")
    return L


def _pairwise_preference_count(cand, group, ballots):
    """Ballots on which `cand` outscores another member of `group`, summed over
    every other member — the tally STAR's FIRST scoring-round tiebreaker prints
    ("the candidate preferred in the most head-to-head matchups advances").

    Delegates to starvote's own preference round rather than recounting, so
    this can never disagree with the number the engine puts on screen.
    """
    return starvote._preference_round(ballots, list(group))[0].get(cand, 0)


def _five_star_count(cand, ballots, max_score=5):
    """Ballots giving `cand` a top score — STAR's SECOND scoring-round
    tiebreaker ("the candidate with the most votes of score 5 advances")."""
    return starvote._maximum_score_count_round(ballots, max_score, [cand]).get(cand, 0)


def resolve_finalists(ballots, order_map=None, maximum_score=5):
    """The candidates that actually advance to the Automatic Runoff, and why.

    Replays starvote's OWN Scoring Round ladder — total score, then the
    head-to-head preference round, then the five-star count, then lot — by
    calling starvote's own round functions, so the matrix '*' markers and
    `matrix_finalists_only` name the pair the runoff really used.

    Ranking by total score alone (what this did before) stars the wrong
    candidate whenever the second slot is decided by one of the later rungs:
    with Ana 15, Ben 14, Cora 14, score order hands the slot to Ben while the
    head-to-head rung advances Cora — so the matrix starred a candidate the
    report had just eliminated, and `matrix_finalists_only` filtered the grid
    down to a matchup that never happened. See
    01_STAR/03_Criteria/tie_break_ladder (bv2276_qhjyr2_second_finalist_tie,
    the case that exposed it; bv2180_fp62p2_ice_cream_ladder for a three-way
    tie that runs all the way to the five-star rung).

    Returns (finalists, tiebreak). `tiebreak` is None when the score alone
    settled both slots; otherwise it records who was tied, at what score,
    which rung broke it, and who advanced — everything the matrix needs to
    say that its '*' followed the tiebreak rather than the score.
    """
    if order_map is None:
        order_map = {}

    scores = starvote._scoring_round(ballots)
    if len(scores) <= 1:
        return list(scores), None

    first, second, tie = starvote._compute_first_and_second_from_score(scores, None)
    if not tie:
        # Two candidates tied at the top is NOT a tie to break — they are
        # simply both finalists. Only a tie that overflows the two slots
        # reaches the ladder below.
        return [c for c in (first, second) if c is not None], None

    tied = list(tie)
    tied_score = scores[tied[0]]

    # Rung 1: of the tied candidates, whoever wins the most head-to-head
    # matchups. This is the rung that most often decides a real election.
    # Three or more tied: the rung is matchups WON (whoever loses the most is
    # eliminated), not summed pairwise preference votes. Same guard as
    # starvote._star_round, so this replay cannot disagree with the count.
    if len(tie) > 2:
        preferences, _no_preference = starvote._matchups_won_round(ballots, tie)
    else:
        preferences, _no_preference = starvote._preference_round(ballots, tie)
    first, second, tie = starvote._compute_first_and_second_from_score(
        preferences, first
    )
    rung = "head-to-head"

    if tie:
        # Rung 2: most ballots awarding them the maximum score.
        fives = starvote._maximum_score_count_round(ballots, maximum_score, tie)
        first, second, tie = starvote._compute_first_and_second_from_score(
            fives, first
        )
        rung = "five-star"

        if tie:
            # Rung 3 (the "dead rung"): the pre-published lot order — the
            # same order LotNumberTiebreaker applies, replayed silently
            # here because this is an analysis pass, not the count.
            needed = 1 if first else 2
            picked = sorted(
                tie, key=lambda c: order_map.get(c, float("inf"))
            )[:needed]
            if needed == 1:
                second = picked[0]
            else:
                first, second = picked
            rung = "lot"

    finalists = [c for c in (first, second) if c is not None]
    tiebreak = {
        "tied": tied,
        "score": tied_score,
        "rung": rung,
        "advanced": [c for c in tied if c in finalists],
        "eliminated": [c for c in tied if c not in finalists],
    }
    return finalists, tiebreak


def resolve_runoff(ballots, finalists, order_map=None, maximum_score=5):
    """Replay the Automatic Runoff ladder over the two finalists.

    The mirror of `resolve_finalists()` for the SECOND of STAR's two ladders,
    and it exists for the same reason: the rungs run inside starvote's own
    `_star_round`, which reports nothing back, so a caller that needs to know
    *which rung decided the seat* has no way to ask. `resolve_finalists()`
    replays the scoring-round ladder for the matrix markers; this replays the
    runoff ladder for the machine-readable result contract, which without it
    reported `tiebreaks: []` -- its positive claim that the ballots alone
    decided -- on 16 committed cases whose runoff was an exact tie, several of
    them the tie-break lesson cases themselves.

    The rungs, in the order `_star_round` runs them, calling the same starvote
    functions so this replay cannot disagree with the count:
      0. `_preference_round` -- who more voters prefer. No tie, no ladder.
      1. `_scoring_round` over the tied pair -- the higher total score.
      2. `_maximum_score_count_round` -- most ballots at the scale maximum
         (the rung that is dead whenever neither finalist holds a 5).
      3. the published lot order.

    Returns (winner, tiebreak). `tiebreak` is None when more voters simply
    preferred one finalist; otherwise it records who tied, at how many
    preferences, and which rung broke it.
    """
    if order_map is None:
        order_map = {}
    finalists = list(finalists)
    if len(finalists) != 2:
        return (finalists[0] if finalists else None), None

    prefs, _no_preference = starvote._preference_round(ballots, finalists)
    first, tie = starvote._compute_first_from_scores(prefs)
    if not tie:
        return first, None

    tied = list(tie)
    tied_at = prefs[tied[0]]

    scores = starvote._scoring_round(ballots, tie)
    first, tie = starvote._compute_first_from_scores(scores)
    rung = "score"

    if tie:
        fives = starvote._maximum_score_count_round(ballots, maximum_score, tie)
        first, tie = starvote._compute_first_from_scores(fives)
        rung = "five-star"

        if tie:
            first = sorted(tie, key=lambda c: order_map.get(c, float("inf")))[0]
            rung = "lot"

    return first, {
        "tied": tied,
        "preferred_by": tied_at,
        "rung": rung,
        "advanced": [first],
        "eliminated": [c for c in tied if c != first],
    }


def get_top_two_finalists(ballots, order_map=None, maximum_score=5):
    """The finalists alone — see resolve_finalists() for how they were settled."""
    return resolve_finalists(ballots, order_map, maximum_score)[0]


def _names(candidates):
    """'Ben and Cora' / 'Ada, Ben and Cora' — for prose, not for columns."""
    names = list(candidates)
    if len(names) <= 1:
        return "".join(names)
    return f"{', '.join(names[:-1])} and {names[-1]}"


def finalist_tiebreak_note(tiebreak, finalists_only=False):
    """Say that a '*' followed the Scoring Round tiebreak, not the score.

    Without this the matrix silently contradicts itself for a reader who only
    knows the scores: the candidate with the higher total is unstarred, and
    under `matrix_finalists_only` they are missing from the grid altogether,
    with nothing on the page explaining where they went.
    """
    rung_phrase = {
        "head-to-head": "the head-to-head rung",
        "five-star": "the five-star rung",
        "lot": "the lot rung (the ballots could not separate them)",
    }[tiebreak["rung"]]

    sentences = [
        f"{_names(tiebreak['tied'])} tied at {tiebreak['score']} in the Scoring "
        f"Round, and {rung_phrase} advanced {_names(tiebreak['advanced'])}.",
        "The * marks who advanced, not who scored highest.",
    ]
    if finalists_only and tiebreak["eliminated"]:
        gone = _names(tiebreak["eliminated"])
        if tiebreak["rung"] == "head-to-head":
            sentences.append(
                f"{gone} is filtered out of this grid, so the head-to-head that "
                "settled it is not shown — see the Scoring Round."
                if len(tiebreak["eliminated"]) == 1 else
                f"{gone} are filtered out of this grid, so the head-to-head that "
                "settled it is not shown — see the Scoring Round."
            )
        else:
            sentences.append(
                f"{gone} is filtered out of this grid — see the Scoring Round "
                "for how the tie was settled."
                if len(tiebreak["eliminated"]) == 1 else
                f"{gone} are filtered out of this grid — see the Scoring Round "
                "for how the tie was settled."
            )

    return textwrap.wrap(
        " ".join(sentences),
        width=78,
        initial_indent="        Note: ",
        subsequent_indent="              ",
        # "head-to-head" and hyphenated candidate names must not be split
        # across lines — the wrap would read as two different words.
        break_on_hyphens=False,
        break_long_words=False,
    )


def print_matrix(
    candidates, matrix, finalists=None, star_winner=None, finalists_only=False,
    tiebreak=None, seats=1,
):
    if not candidates or not matrix:
        return
    if finalists is None:
        finalists = []
    # Multi-winner: the finalists (and any finalist tiebreak) come from the
    # silent seats=1 STAR analysis, and a Top-2 runoff is a single-winner
    # concept — so a Bloc/PR report shows the grid as plain head-to-head data:
    # retitled, no finalist markers, and a legend line saying the actual count
    # happens in the method rounds below.
    multiwinner = seats > 1
    if multiwinner:
        finalists = []
        tiebreak = None
    # Optionally restrict the grid to just the two finalists — the decisive
    # head-to-head that determines the STAR runoff.
    if finalists_only and finalists:
        candidates = [c for c in candidates if c in finalists]
    if multiwinner:
        print("\n--- Preference Matrix ---")
    else:
        print("\n--- Runoff (Preference) Matrix ---")
    print("Head-to-head / pairwise comparison")
    print(
        f"Legend: {COLOR_GREEN}For{COLOR_RESET} - {COLOR_BLUE}Equal Support{COLOR_RESET} - {COLOR_RED}Against{COLOR_RESET}"
    )
    if multiwinner:
        print(f"        Informational only — not part of the {seats}-winner "
              "count below,")
        print("        so no Top-2 finalists are marked.")
    else:
        print("        * indicates Top 2 Finalist")
    # Only when a tie actually reached the ladder — which is rare, so the
    # house "less is more" default is untouched for ordinary elections.
    if tiebreak:
        for line in finalist_tiebreak_note(tiebreak, finalists_only):
            print(line)
        print()   # keep the prose off the column header

    # +4 = 2 for the "* "/"  " finalist prefix + 2 for an even left/right margin,
    # so the longest name still centers instead of sitting flush to the divider.
    col_width = max((len(c) + 4 for c in candidates), default=10)
    # Pad every For/Equal/Against number to the same width so the three columns
    # line up across rows (e.g. "34 -  0 - 66" lines up with "24 - 34 - 42").
    val_w = max(
        (len(str(v)) for c1 in candidates for c2 in candidates
         if c1 != c2 for v in matrix[c1][c2]),
        default=1,
    )
    data_len = val_w * 3 + len(" - ") * 2   # "F - E - A", each value width val_w
    col_width = max(col_width, data_len, 10)
    row_label_width = col_width + 4
    header = " " * row_label_width + " | "

    for cand in candidates:
        display_name = f"* {cand}" if cand in finalists else f"  {cand}"
        header += f"{display_name:^{col_width}} |"
    print(header)
    print("-" * len(header))

    for cand_i in candidates:
        prefix = "* " if cand_i in finalists else "  "
        row_label = f"{prefix}{cand_i} >"
        row_str = f"{row_label:>{row_label_width}} | "
        for cand_j in candidates:
            if cand_i == cand_j:
                row_str += f"{'---':^{col_width}} |"
            else:
                for_val, against_val, no_pref_val = matrix[cand_i][cand_j]
                fv = f"{for_val:>{val_w}}"
                ev = f"{no_pref_val:>{val_w}}"
                av = f"{against_val:>{val_w}}"
                raw_str = f"{fv} - {ev} - {av}"
                padding = col_width - len(raw_str)
                l_pad = padding // 2
                colored_tuple = (
                    f"{COLOR_GREEN}{fv}{COLOR_RESET} - "
                    f"{COLOR_BLUE}{ev}{COLOR_RESET} - "
                    f"{COLOR_RED}{av}{COLOR_RESET}"
                )
                row_str += f"{' ' * l_pad}{colored_tuple}{' ' * (padding - l_pad)} |"
        print(row_str)


def print_condorcet(candidates, matrix, star_winner=None, finalists=None,
                    ballots=None, priority=None):
    """Print the Condorcet analysis line on its own (independent of the matrix).

    Also prints a `[Condorcet Loser]` block — but ONLY when a (strict or weak)
    Condorcet loser exists, so elections without one stay unchanged. STAR and
    Ranked Robin structurally can't elect a strict Condorcet loser (the runoff /
    the zero-win Copeland record filters them), so the line's audit value is for
    the comparison methods: Choose-One (Plurality) can elect the Condorcet loser
    outright (Burlington 2009), Approval can in constructions, and STAR can seat
    a WEAK Condorcet loser via the score tiebreaker — those get flagged inline.
    """
    if not candidates or not matrix:
        return
    print("\n[Condorcet Winner]")
    print(f"  {analyze_condorcet(candidates, matrix, star_winner, finalists)}")

    found = analyze_condorcet_loser(candidates, matrix)
    if not found:
        return
    losers, kind = found

    # Which methods (if any) elected one of these losers — the audit flag.
    method_winners = []
    if star_winner is not None:
        method_winners.append(("STAR", star_winner))
    if ballots:
        fc_counts, _ = first_choice_counts(candidates, ballots, priority)
        _order = [c for c in (priority or candidates) if c in candidates]
        for _c in candidates:
            if _c not in _order:
                _order.append(_c)
        _prank = {c: i for i, c in enumerate(_order)}
        plurality = (min(candidates, key=lambda c: (-fc_counts[c], _prank[c]))
                     if any(v > 0 for v in fc_counts.values()) else None)
        if plurality is not None:
            method_winners.append(("Choose-One (Plurality)", plurality))
        appr = approval_winner(candidates, ballots, priority)
        if appr is not None:
            method_winners.append(("Approval", appr))
    flags = []
    for loser in losers:
        methods = [m for m, w in method_winners if w == loser]
        if methods:
            who = f"{loser} " if len(losers) > 1 else ""
            flags.append(f"{who}elected by {', '.join(methods)}")
    flag = f" — {'; '.join(flags)}!" if flags else ""

    print("\n[Condorcet Loser]")
    if kind == "strict":
        print(f"  Condorcet Loser: {losers[0]} — loses every head-to-head "
              f"matchup{flag}")
    elif kind == "weak":
        print(f"  No strict Condorcet loser; weak Condorcet loser: {losers[0]} "
              f"(never wins a matchup){flag}")
    else:
        print(f"  No strict Condorcet loser; jointly weak Condorcet losers: "
              f"{', '.join(losers)} (winless — pairwise ties){flag}")


def compute_irv_winner(candidates, ballots, priority):
    """
    Tabulate the same election under RCV-IRV and return the winner's name
    (or None if unavailable / no winner).

    The STAR ballots are *scores*; IRV needs *ranks*. Conversion (see
    rcv_irv_tabulation.score_ballot_to_ranking): higher score = higher
    preference, score 0 = unranked. IRV cannot represent equal ranks, so ties
    between equal non-zero scores are broken by `priority` order — the same
    left-to-right candidate priority STAR uses for its tiebreaks. This keeps the
    two methods' tiebreak philosophy aligned (documented for the comparison).
    """
    if not _IRV_AVAILABLE or not candidates or not ballots:
        return None, 0, 0
    order = [c for c in (priority or candidates) if c in candidates]
    for c in candidates:  # include any candidate missing from priority
        if c not in order:
            order.append(c)

    cand_objs = {c: _IRVCandidate(c) for c in candidates}
    pv_ballots = []
    tie_ballots = 0  # ballots whose score->rank order depended on the tiebreak
    for b in ballots:
        # A consequential tie = two ranked (non-zero) candidates share a score,
        # so their relative rank is decided by candidate priority order.
        nonzero = [b[c] for c in candidates if b.get(c, 0) > 0]
        if len(nonzero) != len(set(nonzero)):
            tie_ballots += 1
        ranking = _score_to_rank(b, order)
        pv_ballots.append(_IRVBallot(ranked_candidates=[cand_objs[n] for n in ranking]))

    try:
        # pyrankvote breaks elimination ties with random.choice(); unseeded,
        # the [Divergence from STAR] block could flip between runs on a
        # genuinely tied profile. Seed for reproducibility (matches the
        # RCV-IRV engine's own seeding in rcv_irv_tabulation.run).
        import random
        random.seed(0)
        result = _pyrankvote.instant_runoff_voting(
            list(cand_objs.values()), pv_ballots
        )
        winners = result.get_winners()
        winner = winners[0].name if winners else None
        return winner, tie_ballots, len(ballots)
    except Exception:  # pragma: no cover
        return None, 0, 0


def approval_winner(candidates, ballots, priority):
    """
    Approval winner (single): a candidate is approved on a ballot for every
    score of 3, 4, or 5 (stars). The candidate with the most approvals wins;
    a tie is broken by `priority` order (left-to-right CSV column sequence) —
    the same tiebreak STAR uses — so a single winner is returned.
    """
    approvals = {
        c: sum(1 for b in ballots if b.get(c, 0) >= 3) for c in candidates
    }
    if not approvals:
        return None
    top = max(approvals.values())
    order = [c for c in (priority or candidates) if c in candidates]
    for c in candidates:  # any candidate missing from priority falls in last
        if c not in order:
            order.append(c)
    tied = [c for c in candidates if approvals[c] == top]
    tied.sort(key=lambda c: order.index(c))
    return tied[0]


def _approval_raw_problems(ballots_text):
    """
    Validate Approval ballots against the RAW source rows (not the parsed
    ballots, which silently drop malformed rows). Returns (headers, problems)
    where each problem is (row_number, raw_line, reason). Catches non-numeric
    cells (e.g. 'a'), out-of-range values (not 0/1), and wrong column counts.
    """
    lines = []
    for raw in ballots_text.strip().splitlines():
        line = raw if raw.strip().startswith("#,") else raw.split("#")[0]
        line = line.strip()
        if line:
            lines.append(line)
    if len(lines) < 2:
        return [], []

    headers = [h.strip() for h in re.split(r"[,\t]+", lines[0]) if h.strip()]
    if headers and headers[0] == "#":
        headers.pop(0)
    if headers and re.match(r"(?i)^count\s*:", headers[0]):
        headers[0] = headers[0].split(":", 1)[1].strip()
    count_col = bool(headers) and headers[0].lower() == "count"
    if count_col:
        headers.pop(0)

    problems = []
    for i, line in enumerate(lines[1:], 1):
        parts = re.split(r"[,\t]", line)
        m = re.match(r"\s*(\d+)\s*[:xX×]\s*(.*)", parts[0])  # weight prefix
        if m:
            parts = [m.group(2)] + parts[1:]
        cells = [p.strip() for p in parts]
        if count_col and cells:
            cells = cells[1:]
        if len(cells) != len(headers):
            problems.append((i, line,
                             f"has {len(cells)} value(s), expected {len(headers)}"))
            continue
        # Valid Approval cell: "1" = approved; "0", blank, or a recognized
        # marker (e.g. "-") = not approved. Anything else is an error.
        bad = [f"{h}={c}" for h, c in zip(headers, cells)
               if not (c in ("0", "1", "") or c in MARKER_MEANINGS)]
        if bad:
            problems.append((i, line, "invalid: " + ", ".join(bad)))
    return headers, problems


def approval_tally(candidates, ballots, seats=1, priority=None):
    """The Approval count itself — no printing.

    Any non-zero mark is one approval; the `seats` most-approved candidates
    win, ties broken by `priority` (the published lot order, else CSV column
    order). Shared with `result_json.py` so the machine-readable result cannot
    drift from the printed one.
    """
    counts = {c: sum(1 for b in ballots if b.get(c, 0) > 0) for c in candidates}
    order = [c for c in (priority or candidates) if c in candidates]
    for c in candidates:
        if c not in order:
            order.append(c)
    ranked = sorted(candidates, key=lambda c: (-counts[c], order.index(c)))

    seats = max(1, min(int(seats), len(candidates)))
    winners = ranked[:seats]

    total = len(ballots)
    abstentions = sum(1 for b in ballots
                      if all(b.get(c, 0) == 0 for c in candidates))
    return {
        "counts": counts, "order": order, "ranked": ranked,
        "seats": seats, "winners": winners,
        "total": total, "abstentions": abstentions,
    }


def tabulate_approval(ballots_text, seats=1, priority=None, options=None):
    """
    Tabulate an Approval Voting election. Ballots are approvals, so ANY non-zero
    score counts as one approval (unlike the 3+ stars threshold the STAR
    comparison block uses on 0..5 score ballots).

    Single-winner: the most-approved candidate wins. Multi-winner (seats >= 2)
    uses block/at-large approval: the `seats` most-approved candidates win.
    Ties are broken by candidate priority order (left-to-right CSV columns).

    `options` honors the shared echo flags: `collapse_ballots` (default ON —
    "N × ballot"; OFF — one row per voter) and `count_separator` (default ×).
    """
    # Validate raw rows first, so malformed ballots error instead of being
    # silently dropped by the shared parser.
    header_names, problems = _approval_raw_problems(ballots_text)
    if problems:
        print(
            f"{COLOR_RED}Error: Approval ballots may only use scores {{0, 1}} "
            f"(0 = not approved, 1 = approved).{COLOR_RESET}\n"
            f"  Offending ballot(s)  [{','.join(header_names)}]:"
        )
        for i, line, reason in problems:
            print(f"    ballot {i}: {line}   ({reason})")
        print(f"  Accepted marks: 1 (approved), 0 / blank / a marker "
              f"({', '.join(MARKER_MEANINGS)}) = not approved.")
        print("  Fix or remove these rows. If they are 0..5 score ballots, set "
              "voting_method to STAR.")
        sys.exit(1)

    candidates, ballots, display_rows = parse_ballots_from_string(ballots_text)
    if not ballots:
        print("Error: No valid ballots found in input.\n       Separate columns with commas (recommended), tabs, or consistent spaces —\n       e.g. a header 'A, B, C' then rows like '5, 4, 0'. Other delimiters (like\n       '|' or ';') aren't supported, and every row needs the same number of\n       values as the header.")
        sys.exit(1)

    t = approval_tally(candidates, ballots, seats=seats, priority=priority)
    counts, order, ranked = t["counts"], t["order"], t["ranked"]
    seats, winners = t["seats"], t["winners"]
    total, abstentions = t["total"], t["abstentions"]
    label = "single winner" if seats == 1 else f"{seats} winners"

    print(f"\n{COLOR_HEADER}--- Approval Voting ({label}) ---{COLOR_RESET}")
    print(f" Tabulating {total} ballots (any non-zero score = approval).")
    if abstentions:
        cast = total - abstentions
        print(f" Abstentions: {abstentions} of {total} ballots approved no one "
              f"({cast} ballot{'' if cast == 1 else 's'} cast an approval).")

    # Echo the ballots (same options the STAR / Ranked Robin paths honor):
    # collapse_ballots — default ON, "N × ballot"; OFF — one row per voter.
    _opts = options or {}
    def _truthy(v, default=True):
        if isinstance(v, str):
            return v.strip().lower() not in {"false", "f", "no", "n", "0", "off"}
        return default if v is None else bool(v)
    _collapse = _truthy(_opts.get("collapse_ballots"))
    _sep = str(_opts.get("count_separator", "×")) or "×"
    rows = [",".join("1" if b.get(c, 0) > 0 else "0" for c in candidates)
            for b in ballots]
    print("\nBallots:")
    print(f"   columns = {', '.join(candidates)}"
          "      (1 = approve; 0 = not approved)")
    if _collapse:
        seen = []
        for r in rows:
            if r not in seen:
                seen.append(r)
        cnt = {r: rows.count(r) for r in seen}
        for r in seen:
            print(f"   {cnt[r]:>3} {_sep} {r}")
    else:
        for r in rows:
            print(f"   {r}")
    # The echo above is pure 0/1 — a blank or a marker in the source file is
    # normalized to 0 before it gets here, so the reader never sees one. Name
    # only the marks the file actually used, with their meaning, instead of
    # alluding to "a marker" that appears nowhere on screen.
    print_marker_legend(
        markers_used(display_rows),
        caption="(these all count as not approved — the echo above shows them as 0)",
    )
    print()
    name_w = max(len(c) for c in candidates)
    for i, c in enumerate(ranked):
        tag = " -- Elected" if i < seats else ""
        pct = round(100 * counts[c] / total) if total else 0
        print(f"   {c.ljust(name_w)} -- {counts[c]} ({pct}%){tag}")

    # Flag a tie straddling the cut and spell out exactly how it was resolved.
    if seats < len(candidates) and counts[ranked[seats - 1]] == counts[ranked[seats]]:
        cutoff = counts[ranked[seats - 1]]
        tied = sorted((c for c in candidates if counts[c] == cutoff),
                      key=lambda c: order.index(c))
        above = sum(1 for c in candidates if counts[c] > cutoff)
        remaining_seats = seats - above           # seats the tied group splits
        elected_tied = [c for c in tied if c in winners]
        missed_tied = [c for c in tied if c not in winners]
        seat_word = "seat" if remaining_seats == 1 else "seats"
        prio = " > ".join(tied)                    # their left-to-right priority
        print(
            f"  Note: {', '.join(tied)} each have {cutoff} approval"
            f"{'' if cutoff == 1 else 's'} and tie for the last {remaining_seats} "
            f"{seat_word}."
        )
        print(
            f"        Candidate priority order ({prio}) broke the tie: "
            f"{', '.join(elected_tied)} elected, {', '.join(missed_tied)} not elected."
        )

    # --- Approval distribution: how many candidates each ballot approved. The
    # bullet-vote-vs-broad signal (are voters approving one, or many?) — the
    # approval analogue of STAR's score distribution. ---
    per_ballot = [sum(1 for c in candidates if b.get(c, 0) > 0) for b in ballots]
    total_appr = sum(per_ballot)
    avg = total_appr / total if total else 0.0
    lo = min(per_ballot) if per_ballot else 0
    hi = max(per_ballot) if per_ballot else 0
    print("\n[Approval Distribution] (how many candidates each ballot approved)")
    print(f"   {total_appr} approval{'' if total_appr == 1 else 's'} across "
          f"{total} ballot{'' if total == 1 else 's'} — average {avg:.1f} of "
          f"{len(candidates)} (range {lo}–{hi}).")
    hist = {}
    for k in per_ballot:
        hist[k] = hist.get(k, 0) + 1
    for k in sorted(hist):
        nb = hist[k]
        lbl = "approved none" if k == 0 else f"approved {k}"
        print(f"     {lbl}: {nb} ballot{'' if nb == 1 else 's'}")

    # --- Co-approval matrix: of the voters who approved the ROW candidate, the
    # % who ALSO approved the COLUMN candidate. Coalition structure, not just
    # totals — the approval analogue of STAR's preference matrix. Opt-in on
    # screen via `options: { show_matrix: true }`; the _tabulated mirror forces
    # it on. ---
    if _truthy(_opts.get("show_matrix"), default=False) and len(candidates) >= 2:
        cols = ranked
        rw = max(len(c) for c in cols)
        cw = max(6, rw)

        def _corow(label, cells):
            return "   " + label.ljust(rw) + "  | " + " | ".join(cells) + " |"

        print("\n[Co-Approval Matrix]")
        print(" Of the voters who approved the ROW candidate, the % who ALSO "
              "approved the COLUMN candidate.")
        header = _corow("", [c.center(cw) for c in cols])
        print(header)
        print("   " + "-" * (len(header) - 3))
        for r in cols:
            denom = counts[r]
            cells = []
            for c in cols:
                if c == r:
                    cells.append("--".center(cw))
                elif denom == 0:
                    cells.append("·".center(cw))
                else:
                    both = sum(1 for b in ballots
                               if b.get(r, 0) > 0 and b.get(c, 0) > 0)
                    cells.append(f"{round(100 * both / denom)}%".center(cw))
            print(_corow(r, cells))

    word = "Winner" if seats == 1 else "Winners"
    print(f"\n{COLOR_WINNER}{word} — Approval Voting ({label}){COLOR_RESET}")
    print(f"  {', '.join(winners)}")


def ballots_for_pairwise(ballots_text):
    """Parse RANKED ('A>B>C', '=' for an equal rank) or SCORE ballots into the
    (candidates, ballots) pair the pairwise matrix reads.

    Ranked ballots become rank *scores* (top rank = highest number, unranked = 0),
    which is exactly what a head-to-head comparison reads: only the ORDER matters,
    and equal ranks land as Equal Support. Score ballots pass through unchanged.

    Returns (candidates, ballots, display_rows, is_ranked) — `display_rows` is the
    human-readable ballot line each method's report echoes.
    """
    import re as _re

    clean = "\n".join(ln.split("#")[0] for ln in ballots_text.splitlines())
    if ">" in clean:                                    # ranked ballots
        # Each ballot is a weak order: '>' separates rank levels (most→least
        # preferred), '=' ties candidates within a level (Ava=Bianca>Cara).
        # Equal-ranked candidates share a rank, so the pairwise matrix scores
        # them as Equal Support against each other — exactly how Ranked Robin
        # treats a tie. (A strict ballot A>B>C is the all-singleton case, so
        # this stays identical for ballots without '='.)
        voters, seen = [], []
        for ln in ballots_text.splitlines():
            ln = ln.split("#")[0].strip()
            if not ln:
                continue
            m = _re.match(r"(\d+)\s*[:xX×]\s*(.+)", ln)
            w, rest = (int(m.group(1)), m.group(2)) if m else (1, ln)
            groups = [[c.strip() for c in grp.split("=") if c.strip()]
                      for grp in rest.split(">")]
            groups = [g for g in groups if g]           # drop empty levels
            for g in groups:
                for c in g:
                    if c not in seen:
                        seen.append(c)
            voters += [groups] * w
        candidates = seen
        ballots = []
        for groups in voters:
            ng = len(groups)
            ranked = {}
            for i, g in enumerate(groups):              # level 0 = most preferred
                for c in g:
                    ranked[c] = ng - i                  # tied candidates share a rank
            ballots.append({c: ranked.get(c, 0) for c in candidates})
        display_rows = [" > ".join("=".join(g) for g in groups) for groups in voters]
        return candidates, ballots, display_rows, True

    candidates, ballots, _ = parse_ballots_from_string(ballots_text)

    def _weak_rank(b):
        # The ranking a pairwise count actually reads from a score ballot: order by
        # score (high to low); EQUAL scores are a tie ("=") — no head-to-head
        # preference — which is exactly how the pairwise matrix treats them.
        groups = {}
        for c in candidates:
            groups.setdefault(b.get(c, 0), []).append(c)
        return " > ".join("=".join(groups[s]) for s in sorted(groups, reverse=True))

    display_rows = [_weak_rank(b)
                    + "      (" + ", ".join(str(b.get(c, 0)) for c in candidates) + ")"
                    for b in ballots]
    return candidates, ballots, display_rows, False


def ranked_robin_tally(ballots_text, lot_numbers=None, num_winners=1):
    """The Ranked Robin count itself — no printing, no files.

    Split out of `run_ranked_robin()` so the machine-readable result
    (`result_json.py`) reports the SAME record, the same Copeland scores and
    the same seat order the printed report does, rather than a second
    implementation of the ladder that can drift from it.
    """
    candidates, ballots, display_rows, is_ranked = ballots_for_pairwise(ballots_text)
    n = len(ballots)
    priority = [c for c in (lot_numbers or candidates) if c in candidates]
    for c in candidates:
        if c not in priority:
            priority.append(c)
    matrix = calculate_preference_matrix(candidates, ballots)

    wins, losses, ties, margin = ({c: [] for c in candidates},
                                  {c: [] for c in candidates},
                                  {c: [] for c in candidates},
                                  {c: 0 for c in candidates})
    raw_pairs = []                                  # (winner, verb, loser, hi, lo)
    for i, a in enumerate(candidates):
        for b in candidates[i + 1:]:
            fa, ag, _ = matrix[a][b]
            margin[a] += fa - ag
            margin[b] += ag - fa
            if fa > ag:
                wins[a].append(b); losses[b].append(a)
                raw_pairs.append((a, "beats", b, fa, ag))
            elif ag > fa:
                wins[b].append(a); losses[a].append(b)
                raw_pairs.append((b, "beats", a, ag, fa))
            else:
                ties[a].append(b); ties[b].append(a)
                raw_pairs.append((a, "ties", b, fa, ag))

    # Copeland score = wins + ½·ties (the academic standard tally, and what
    # BetterVoting and pref_voting both score). Rank by that score, then total
    # margin, then lot order.
    #
    # Rank by `cope`, NOT by the raw win count. Those two agree whenever every
    # head-to-head is decided, and come apart the moment a pairwise TIE exists —
    # a drawn matchup is worth ½ to the Copeland score and nothing to a raw win
    # count. Ranking on raw wins while printing the Copeland column made the
    # report contradict its own table, and could elect a candidate the table
    # ranked below two others (e.g. 6:A=B=D>C / 7:A=C=D>B / 6:B>C>A=D elected B
    # at Copeland 1 over A and D at 2). Note wins+½·ties and the other common
    # convention wins−losses are affine transforms of each other, so they always
    # give the SAME ranking; raw wins is the odd one out.
    cope = {c: len(wins[c]) + 0.5 * len(ties[c]) for c in candidates}

    # Ranked Robin's tie-break ladder, in the order the METHOD defines it —
    # electowiki.org/wiki/Ranked_Robin#Degrees_of_ties, the Equal Vote protocol:
    #
    #   1st Degree  the tied candidates are FINALISTS; elect the one with the
    #               greatest sum of win margins over THE OTHER FINALISTS.
    #   2nd Degree  still tied: the greatest sum of win margins over ALL candidates.
    #   then        lot (the spec stops here for public elections; it defines a 3rd
    #               and 4th Degree but recommends lots or a re-run instead).
    #
    # The pool is the whole point, and getting it wrong changes winners. Until
    # 2026-08-19 this engine ranked ties by TOTAL margin — the 2nd Degree — with
    # no 1st Degree at all, which meant a candidate could lose the finalists' own
    # head-to-head and still be elected because they had run up a bigger score
    # against an also-ran. That fired on 11 of this repo's 100 Ranked Robin
    # cases, every one of them a two-way tie whose head-to-head was decisive: for
    # exactly two finalists the 1st Degree IS the head-to-head, which is why
    # BetterVoting's head-to-head rung was right and this engine's was not.
    # See 05_Ranked_Robin/03_Criteria/rr_tiebreaks/degrees_of_ties.md.
    def _margin_over(c, pool):
        """Sum of win margins for `c` over the other candidates in `pool`."""
        return sum(matrix[c][o][0] - matrix[c][o][1] for o in pool if o != c)

    # Rank within each Copeland tier by the degrees, so row 1 of the printed
    # record table is always the winner (a separate winner rule and display sort
    # is how a report ends up contradicting its own table).
    first_degree, order = {}, []
    for score in sorted(set(cope.values()), reverse=True):
        tier = [c for c in candidates if cope[c] == score]
        for c in tier:
            first_degree[c] = _margin_over(c, tier)
        order += sorted(tier, key=lambda c: (-first_degree[c], -margin[c],
                                             priority.index(c)))
    top = cope[order[0]]
    leaders = [c for c in candidates if cope[c] == top]
    winner = order[0]
    # Which rung actually decided it — reported, and read by result_json.py.
    if len(leaders) == 1:
        rung = None
    elif len([c for c in leaders if first_degree[c] == first_degree[winner]]) == 1:
        rung = "1st Degree"
    elif len([c for c in leaders if first_degree[c] == first_degree[winner]
              and margin[c] == margin[winner]]) == 1:
        rung = "2nd Degree"
    else:
        rung = "lot"
    # Bloc Ranked Robin: for N seats, elect the top N by the same ladder.
    # num_winners is clamped to the field size.
    num_winners = max(1, min(int(num_winners or 1), len(candidates)))
    winners = order[:num_winners]
    # Did the last seat come down to a lot tie-break? (Nth and (N+1)th identical on
    # Copeland score AND both degrees, so only the pre-published lot separated them.)
    cutoff_lot_tie = (num_winners < len(candidates)
                      and cope[order[num_winners - 1]] == cope[order[num_winners]]
                      and first_degree[order[num_winners - 1]] == first_degree[order[num_winners]]
                      and margin[order[num_winners - 1]] == margin[order[num_winners]])

    return {
        "candidates": candidates, "ballots": ballots,
        "display_rows": display_rows, "is_ranked": is_ranked,
        "n": n, "priority": priority, "matrix": matrix,
        "wins": wins, "losses": losses, "ties": ties, "margin": margin,
        "raw_pairs": raw_pairs, "copeland": cope, "order": order,
        "first_degree": first_degree, "rung": rung,
        "top": top, "leaders": leaders, "winner": winner, "winners": winners,
        "num_winners": num_winners, "cutoff_lot_tie": cutoff_lot_tie,
    }


def run_ranked_robin(ballots_text, file_path=None, lot_numbers=None, options=None,
                     silent=False, out_path=None, num_winners=1):
    """Tabulate and report a Ranked Robin (RCV-RR / Copeland) election.

    Ranked Robin reads the *whole* ballot: it compares every pair of candidates
    head-to-head and elects whoever wins the most matchups (ties broken by the
    method's own degrees — margins among the tied finalists, then margins over
    the whole field — and only then by lot order). Prints the ballots, the round-robin (pairwise)
    table, and each candidate's win-loss record. Accepts ranked ballots
    ("A>B>C") or score ballots; both reduce to the same pairwise comparison.

    `silent=True` suppresses the on-screen echo (used when generating the report
    as an auxiliary mirror during a STAR run). `out_path` overrides the mirror
    location (default: the standard '<stem>_tabulated.txt'); pass the method-tagged
    aux path so the RR report doesn't clobber the STAR copy.
    """
    from collections import Counter as _Counter

    t = ranked_robin_tally(ballots_text, lot_numbers=lot_numbers,
                           num_winners=num_winners)
    candidates, ballots = t["candidates"], t["ballots"]
    display_rows, _is_ranked = t["display_rows"], t["is_ranked"]
    n, priority, matrix = t["n"], t["priority"], t["matrix"]
    wins, losses, ties, margin = t["wins"], t["losses"], t["ties"], t["margin"]
    raw_pairs, cope, order = t["raw_pairs"], t["copeland"], t["order"]
    first_degree, rung = t["first_degree"], t["rung"]
    top, leaders = t["top"], t["leaders"]
    winner, winners = t["winner"], t["winners"]
    num_winners, cutoff_lot_tie = t["num_winners"], t["cutoff_lot_tie"]
    seats_label = "single winner" if num_winners == 1 else f"{num_winners} winners"

    # --- Aligned head-to-head list (names padded into columns) ---
    nw = max((len(c) for c in candidates), default=4)
    sw = max((len(str(v)) for *_, hi, lo in raw_pairs for v in (hi, lo)), default=1)
    pair_lines = []
    for w_, verb, l_, hi, lo in raw_pairs:
        pair_lines.append(
            f"   {w_:<{nw}}  {verb:<5} {l_:<{nw}}   {hi:>{sw}} – {lo:>{sw}}")

    # --- Aligned win-loss record table (with Copeland score + margin columns) ---
    def _beats(c):
        return ", ".join(sorted(wins[c], key=lambda x: order.index(x))) or "—"
    HC, HR, HK, HM, HF = "Candidate", "W–L–T", "Copeland", "Margin", "vs finalists"
    recs = {c: f"{len(wins[c])}–{len(losses[c])}–{len(ties[c])}" for c in candidates}
    cand_w = max(nw, len(HC))
    rec_w = max(max((len(r) for r in recs.values()), default=0), len(HR))
    cope_w = max(max((len(f"{cope[c]:g}") for c in candidates), default=1), len(HK))
    marg_w = max(max((len(f"{margin[c]:+d}") for c in candidates), default=2), len(HM))
    # The 1st Degree column earns its space only when there IS a tie for the lead:
    # it is the number that decides the winner, and without it the table looks like
    # it contradicts itself (the Margin column can point at a different candidate,
    # because that column is the 2nd Degree). Blank for everyone but the finalists —
    # a margin among finalists means nothing to a candidate who isn't one.
    _fin_col = len(leaders) > 1
    _fin = {c: ((f"{first_degree[c]:+d}" if first_degree[c] else "0")
                if c in leaders else "—") for c in candidates}
    fin_w = max(max((len(v) for v in _fin.values()), default=1), len(HF)) if _fin_col else 0
    record_lines = [
        f"   {'#':>2}  {HC:<{cand_w}}  {HR:<{rec_w}}  {HK:>{cope_w}}  "
        f"{HM:>{marg_w}}  " + (f"{HF:>{fin_w}}  " if _fin_col else "") + "Beats"
    ]
    for idx, c in enumerate(order, 1):
        record_lines.append(
            f"   {idx:>2}  {c:<{cand_w}}  {recs[c]:<{rec_w}}  "
            f"{cope[c]:>{cope_w}g}  {margin[c]:>+{marg_w}d}  "
            + (f"{_fin[c]:>{fin_w}}  " if _fin_col else "") + f"{_beats(c)}")

    # Full N×N pairwise matrix — the Ranked Robin tally itself (the summable
    # heart of the count). Each cell reads For - Equal Support - Against for the
    # ROW candidate vs the COLUMN candidate. Compact for the on-screen echo,
    # always shown in the _tabulated mirror (house rule: minimal echo, full mirror).
    def _matrix_lines():
        cells = [str(v) for a in candidates for b in candidates if a != b
                 for v in matrix[a][b]]
        vw = max((len(x) for x in cells), default=1)
        data = vw * 3 + 6                       # "F - E - A"
        colw = max(max((len(c) for c in candidates), default=4) + 2, data, 9)
        rlw = max((len(c) for c in candidates), default=4) + 2
        out = ["--- Pairwise (Round-Robin) Matrix ---",
               "Head-to-head / pairwise comparison — the Ranked Robin tally",
               "Legend: For - Equal Support - Against   (row vs column)"]
        head = " " * (rlw + 2) + " | "
        for c in candidates:
            head += f"{c:^{colw}} |"
        out.append(head)
        out.append("-" * len(head))
        for a in candidates:
            row = f"{a:>{rlw}} > | "
            for b in candidates:
                if a == b:
                    row += f"{'---':^{colw}} |"
                else:
                    fa, ag, nop = matrix[a][b]
                    cell = f"{fa:>{vw}} - {nop:>{vw}} - {ag:>{vw}}"
                    row += f"{cell:^{colw}} |"
            out.append(row)
        return out

    # --- Echo options (shared by STAR; the RR path honors these three) ---
    _opts = options or {}
    def _truthy(v, default=True):
        if isinstance(v, str):
            return v.strip().lower() not in {"false", "f", "no", "n", "0", "off"}
        return default if v is None else bool(v)
    # show_matrix: the full pairwise table ON SCREEN. Default ON since
    # 2026-08-09 — the round-robin table IS the method, so hiding it made every
    # case file opt back in. `show_matrix: false` still gives the compact echo.
    _echo_full = _truthy(_opts.get("show_matrix"), default=True)
    # show_smith_set: the Smith-set analysis. OFF on screen by default (house rule:
    # the mirror always carries it), and deliberately separate from show_matrix so
    # a file can opt the echo into one without dragging in the other.
    _show_smith = _truthy(_opts.get("show_smith_set"), default=False)
    # collapse_ballots: default ON — show "N × ballot"; OFF — one row per voter.
    _collapse = _truthy(_opts.get("collapse_ballots"))
    # count_separator: the glyph between count and ballot (× : x X); default ×.
    _sep = str(_opts.get("count_separator", "×")) or "×"

    def _build(full, smith=None):
        # smith: None → follow `full` (the mirror's everything-on build). The
        # echo passes smith=_show_smith so the Smith block stays a separate
        # opt-in even though the matrix now defaults ON.
        smith_on = full if smith is None else smith
        L = [f"--- Ranked Robin (RCV-RR / Copeland) Method ({seats_label}) ---",
             f" Tabulating {n} ballots "
             f"({'ranked' if _is_ranked else 'score'} ballots).", ""]
        L.append("Ballots:")
        if not _is_ranked:          # score input: show how scores become RR's ranking
            L.append("   the ranking Ranked Robin reads (\"=\" = tied);"
                     f" source scores follow in () per column: {', '.join(candidates)}")
        if _collapse:
            cnt, seenr = _Counter(display_rows), []
            for r in display_rows:
                if r not in seenr:
                    seenr.append(r)
            for r in seenr:
                L.append(f"   {cnt[r]:>3} {_sep} {r}")
        else:
            for r in display_rows:               # one row per voter
                L.append(f"   {r}")
        L.append("")
        L.append("Round-Robin — every pair, head-to-head (For – Against):")
        L += pair_lines
        L.append("")
        if full:                                # full N×N grid → _tabulated mirror
            L += _matrix_lines()
            L.append("")
        L.append("Win–loss record — Copeland score = wins + ½·ties "
                 "(highest score wins; ties broken by the Ranked Robin degrees, "
                 "then lot order):")
        L += record_lines
        L.append("")
        if num_winners > 1:
            # Bloc Ranked Robin: the top N of the win-loss record fill the seats.
            L.append(f"Winners — Ranked Robin (RCV-RR), {num_winners} seats "
                     f"(Bloc — the top {num_winners} by record):")
            for idx, c in enumerate(winners, 1):
                rec = f"{len(wins[c])}–{len(losses[c])}–{len(ties[c])}"
                L.append(f"   {idx}. {c}   ({rec}, Copeland {cope[c]:g}, margin {margin[c]:+d})")
            if cutoff_lot_tie:
                a, b = winners[-1], order[num_winners]
                L.append(f"   *** the last seat was a tie ({a} and {b} share wins and "
                         f"margin) — decided by lot order.")
        elif len(leaders) == 1:
            # A STRICT Condorcet winner must beat every opponent — no losses AND no
            # DRAWS. Testing only `not losses[winner]` called an unbeaten-but-drawing
            # candidate "the Condorcet winner", which is false: a draw is not a win.
            # That candidate is a WEAK Condorcet winner, and the difference is exactly
            # what Smith vs Schwartz turns on, so the report has to keep them apart.
            if not losses[winner] and not ties[winner]:
                why = "beats every opponent head-to-head — the Condorcet winner."
            elif not losses[winner]:
                drawn = ", ".join(sorted(ties[winner], key=lambda x: order.index(x)))
                why = (f"unbeaten, but draws {drawn} — a *weak* Condorcet winner, not a "
                       f"strict one (highest Copeland score, {top:g}).")
            elif not ties[winner]:
                # No draws for the winner ⇒ their Copeland score IS their win count,
                # and no rival can hold more raw wins (more wins with a tied-or-lower
                # Copeland score is impossible). So the plainer phrasing is exact here,
                # which is nearly every election.
                why = f"the most head-to-head wins ({top:g})."
            else:
                why = f"the highest Copeland score ({top:g} = wins + ½·ties)."
            L.append(f"Winner — Ranked Robin (RCV-RR): {winner}")
            L.append(f"   {why}")
        else:
            L.append(f"Winner — Ranked Robin (RCV-RR): {winner}")
            # "Tie for the most wins" is the accurate lead. Only call it a
            # *Condorcet cycle* when the tied leaders actually beat around a loop.
            # Tying on the overall tally says nothing about the shape of the
            # head-to-heads underneath it: the leaders may all DRAW (a co-top dead
            # heat), or some may beat others without any loop closing — two
            # leaders split by one decisive head-to-head are the common case, and
            # a 2-cycle cannot exist. One shared classifier, so the Smith-set block
            # below reaches the same verdict about the same matrix (it asks about
            # the Smith set, which the leaders are always inside).
            shape = _group_shape(leaders, matrix)
            # Lead with "most wins" only when it is literally true: with no draws
            # anywhere among the leaders, tying on Copeland IS tying on wins, and
            # that phrasing is the friendlier one. Once a draw is in play the two
            # differ, and naming the Copeland score is the only accurate option.
            tie_on = ("tie for the most wins ("
                      + ", ".join(leaders) + ")"
                      if not any(ties[l] for l in leaders) else
                      f"tie on the highest Copeland score ({top:g}): "
                      + ", ".join(leaders))
            # Name the rung that actually fired. "Resolved by total margin" was
            # both the old ladder and the old wording; the ladder now starts one
            # rung earlier (margins among the finalists), so the report has to
            # say which rung separated them or the reader cannot check the answer.
            if rung == "1st Degree":
                how = (f"Resolved by the 1st Degree tiebreaker: {winner} has the greatest "
                       f"sum of win margins over the other finalists "
                       f"({first_degree[winner]:+d}).")
            elif rung == "2nd Degree":
                how = (f"The finalists are level on margins against each other, so the 2nd "
                       f"Degree decides: {winner} leads on margins over the whole field "
                       f"({margin[winner]:+d}).")
            else:
                how = ("Neither the 1st nor the 2nd Degree tiebreaker separates them — "
                       "resolved by lot order.")
            if shape == "dead heat":
                L.append(f"   *** {len(leaders)} candidates {tie_on} — a dead heat (they "
                         f"draw head-to-head, not a cycle). {how}")
            elif shape == "mixed":
                L.append(f"   *** {len(leaders)} candidates {tie_on} — tied on the tally, "
                         f"not a cycle (some of them beat others head-to-head, but no loop "
                         f"closes). {how}")
            else:
                L.append(f"   *** {len(leaders)} candidates {tie_on} — a Condorcet cycle "
                         f"(no candidate beats all others). {how} (This is "
                         "where Minimax / Ranked Pairs / Schulze differ — see "
                         "05_Ranked_Robin/01_Learn/cycle_resolution.md.)")
        if smith_on or _show_smith:
            # The Smith set reads the same pairwise matrix Ranked Robin counts, so
            # nothing is discarded in the translation — it is RR's native yardstick,
            # and RR always passes it. Single-winner only: with several seats
            # "the winner is inside the set" has no single referent.
            block = format_smith_set(
                candidates, matrix,
                winner=winner if num_winners == 1 else None,
                method_label="Ranked Robin (RCV-RR)", smith_efficient=True)
            if block:
                L.append("")
                L += block
        return "\n".join(L)

    # On-screen echo shows the pairwise matrix by default (it IS the method —
    # flipped 2026-08-09); `show_matrix: false` gives the compact echo, and the
    # Smith block stays a separate `show_smith_set` opt-in either way. The
    # _tabulated mirror is ALWAYS full regardless.
    plain = _build(full=_echo_full, smith=_show_smith)
    hdr = f"--- Ranked Robin (RCV-RR / Copeland) Method ({seats_label}) ---"
    win = (f"Winner — Ranked Robin (RCV-RR): {winner}" if num_winners == 1
           else f"Winners — Ranked Robin (RCV-RR), {num_winners} seats "
                f"(Bloc — the top {num_winners} by record):")
    colored = plain.replace(hdr, f"{COLOR_HEADER}{hdr}{COLOR_RESET}") \
                   .replace(win, f"{COLOR_WINNER}{win}{COLOR_RESET}")
    if not silent:
        print(colored)
    if out_path is not None and not _skip_degraded_mirror(out_path):
        try:
            Path(out_path).parent.mkdir(parents=True, exist_ok=True)
            Path(out_path).write_text(strip_ansi(_build(full=True)),
                                      encoding="utf-8")
        except Exception:
            pass
    elif file_path:
        try:
            # PRIMARY mirror: same composed format as the STAR / Approval paths
            # (provenance header + the original file + the results), always full.
            write_composed_tabulated(file_path, _build(full=True))
        except Exception:
            pass
    return winners if num_winners > 1 else winner


def plurality_single_tally(ballots_text, lot_numbers=None):
    """Single-winner Choose-One count — no printing.

    Note the rule this pins, which is NOT "each ballot's highest score": a
    ballot marking two candidates is an OVERVOTE and counts for nobody. Shared
    with `result_json.py` so the machine-readable result says the same.
    """
    candidates, ballots, _ = parse_ballots_from_string(ballots_text)
    priority = [c for c in (lot_numbers or candidates) if c in candidates]
    for c in candidates:
        if c not in priority:
            priority.append(c)

    # parse_ballots_from_string ALREADY expands weighted rows ("3:1,0,0" -> three
    # ballots), so every parsed ballot counts as exactly one voter. Never re-apply
    # the row weights on top of that.
    marks = [[c for c in candidates if b.get(c, 0) > 0] for b in ballots]
    over = [i for i, m in enumerate(marks) if len(m) > 1]      # overvote -> spoiled
    blank = [i for i, m in enumerate(marks) if not m]
    votes = {c: sum(1 for m in marks if len(m) == 1 and m[0] == c)
             for c in candidates}
    n = len(ballots)
    order = sorted(candidates, key=lambda c: (-votes[c], priority.index(c)))
    top = votes[order[0]] if order else 0
    tied = [c for c in order if votes[c] == top]
    return {
        "candidates": candidates, "ballots": ballots, "priority": priority,
        "marks": marks, "overvotes": over, "blanks": blank,
        "votes": votes, "n": n, "order": order,
        "tied_at_top": tied,
        "winner": (tied[0] if len(tied) == 1 else min(tied, key=priority.index))
        if order else None,
    }


def plurality_multi_tally(ballots_text, lot_numbers=None, num_winners=2):
    """Multi-winner Plurality (SNTV / Block / Limited) — no printing.

    Every MARK is a vote here (unlike the single-winner rule above, which
    spoils an overvote): the ballot styles differ, the tally does not, so the
    variant is identified from votes-per-voter rather than counted differently.
    """
    candidates, ballots, _ = parse_ballots_from_string(ballots_text)
    priority = [c for c in (lot_numbers or candidates) if c in candidates]
    for c in candidates:
        if c not in priority:
            priority.append(c)
    votes = {c: sum(1 for b in ballots if b.get(c, 0) > 0) for c in candidates}
    order = sorted(candidates, key=lambda c: (-votes[c], priority.index(c)))
    num_winners = max(1, min(int(num_winners or 1), len(candidates)))
    winners = order[:num_winners]
    n = len(ballots)
    abstain = sum(1 for b in ballots if not any(b.get(c, 0) > 0 for c in candidates))
    cutoff_lot_tie = (num_winners < len(candidates)
                      and votes[order[num_winners - 1]] == votes[order[num_winners]])

    # Which multi-member plurality variant this is, from votes-per-voter:
    #   1 mark -> SNTV · k == seats -> Block voting · 1 < k < seats -> Limited.
    per = [sum(1 for c in candidates if b.get(c, 0) > 0) for b in ballots]
    cast = {k for k in per if k > 0}
    k = next(iter(cast)) if len(cast) == 1 else None
    if cast == {1}:
        variant = "SNTV (single non-transferable vote)"
    elif k == num_winners:
        variant = "Block Voting (plurality-at-large)"
    elif k is not None and 1 < k < num_winners:
        variant = "Limited Voting"
    else:
        variant = "Multi-winner Plurality"
    return {
        "candidates": candidates, "ballots": ballots, "priority": priority,
        "votes": votes, "order": order, "num_winners": num_winners,
        "winners": winners, "n": n, "abstain": abstain,
        "cutoff_lot_tie": cutoff_lot_tie, "votes_per_voter": k,
        "variant": variant,
    }


def run_plurality_single(ballots_text, file_path=None, lot_numbers=None,
                         silent=False, out_path=None):
    """Single-winner Choose-One (Plurality) — counted the way it is actually run:
    show the marked ballots, count the marks, most marks wins.

    This used to fall through to the STAR path, which is arithmetically
    equivalent for single-mark ballots but prints a Scoring Round and an
    Automatic Runoff — machinery choose-one does not have, and confusing on the
    one method that has none. Row labels come from each ballot row's trailing
    `#` comment when present ("# Sushi-lover"), so the printed grid matches the
    ballot as a reader would draw it.
    """
    t = plurality_single_tally(ballots_text, lot_numbers=lot_numbers)
    candidates, ballots, priority = t["candidates"], t["ballots"], t["priority"]
    marks, over, blank = t["marks"], t["overvotes"], t["blanks"]
    votes, n, order = t["votes"], t["n"], t["order"]

    # Row labels come from each raw row's trailing `#` comment — usable only when
    # the rows map 1:1 to ballots (i.e. no weighted rows). Otherwise collapse
    # identical ballots and label them "N ×".
    labels = []
    for raw in ballots_text.strip().split("\n")[1:]:
        body, _, comment = raw.partition("#")
        if body.strip():
            labels.append(comment.strip())
    if len(labels) == len(ballots):
        rows = [(labels[i], marks[i]) for i in range(len(ballots))]
    else:
        rows, seen = [], {}
        for m in marks:
            key = tuple(m)
            if key in seen:
                rows[seen[key]] = (rows[seen[key]][0] + 1, m)
            else:
                seen[key] = len(rows)
                rows.append((1, m))
        rows = [(f"{cnt} ×", m) for cnt, m in rows]
    top = votes[order[0]]
    tied = [c for c in order if votes[c] == top]
    winner = tied[0] if len(tied) == 1 else min(tied, key=priority.index)

    banner = "--- Choose-One / Plurality Voting Method (single winner) ---"
    lw = max([len(str(lbl)) for lbl, _ in rows] + [len("Count the marks:")])
    cw = [max(len(c), 5) + 2 for c in candidates]
    L = [banner, f" Tabulating {n} ballots.", ""]
    L.append("  " + " " * lw + "".join(c.center(w) for c, w in zip(candidates, cw)))
    for lbl, m in rows:
        cells = "".join(("X" if c in m else "-").center(w) for c, w in zip(candidates, cw))
        L.append(f"  {str(lbl):<{lw}}" + cells)
    L.append("")
    L.append("  Count the marks:  "
             + " · ".join(f"{c} {votes[c]}" for c in order))
    if blank:
        L.append(f"  ({len(blank)} ballot(s) marked nobody.)")
    if over:
        L.append(f"  ({len(over)} ballot(s) marked more than one candidate — "
                 f"an overvote, which spoils a choose-one ballot; not counted.)")
    L.append("")
    if len(tied) > 1:
        L.append(f" A {len(tied)}-way tie for first: "
                 + ", ".join(tied) + f" — {top} mark(s) each.")
        L.append("   Counting the marks is all a choose-one ballot can do, so the "
                 "ballots cannot break it;")
        L.append(f"   the pre-published lot order decides: {priority}.")
        L.append("")
        L.append("[Lot-decided tie — rare]")
        L.append("  ⚠ The result here was set by lot, not by the votes.")
        L.append("")
    L.append(f"Winner — Choose-One / Plurality Voting Method (single winner)")
    L.append(f" {winner}"
             + (f"   ({votes[winner]} of {n} marks"
                + (", by lot" if len(tied) > 1 else "") + ")"))
    report = "\n".join(L)
    if not silent:
        print(report.replace(banner, f"{COLOR_HEADER}{banner}{COLOR_RESET}"))
    if out_path is not None and not _skip_degraded_mirror(out_path):
        try:
            Path(out_path).parent.mkdir(parents=True, exist_ok=True)
            Path(out_path).write_text(strip_ansi(report), encoding="utf-8")
        except Exception:
            pass
    elif file_path:
        try:
            # PRIMARY mirror: composed like the STAR / Approval paths.
            write_composed_tabulated(file_path, report)
        except Exception:
            pass
    return winner


def run_plurality_multi(ballots_text, file_path=None, lot_numbers=None,
                        num_winners=2, silent=False, out_path=None):
    """Multi-winner Plurality — SNTV / Bloc Plurality (single non-transferable
    vote): each voter marks one candidate; the N candidates with the most
    first-choice votes win. Ties broken by lot order. (Single-winner Plurality
    runs its own choose-one report via run_plurality_single — it no longer
    falls through to the STAR path.)"""
    t = plurality_multi_tally(ballots_text, lot_numbers=lot_numbers,
                              num_winners=num_winners)
    candidates, ballots, priority = t["candidates"], t["ballots"], t["priority"]
    votes, order = t["votes"], t["order"]
    num_winners, winners = t["num_winners"], t["winners"]
    n, abstain, cutoff_lot_tie = t["n"], t["abstain"], t["cutoff_lot_tie"]
    k, vlabel = t["votes_per_voter"], t["variant"]
    vline = ("First-choice votes"
             if vlabel.startswith("SNTV") else "Votes")
    banner = f"--- {vlabel} — {num_winners} winners ---"
    winners_line = f"Winners — {vlabel}, {num_winners} seats:"

    nw = max((len(c) for c in candidates), default=4)
    L = [banner,
         f" Tabulating {n} ballots ("
         + (f"{k} vote{'s' if k and k > 1 else ''}/voter" if k else "mixed votes/voter")
         + (f"; {abstain} abstained" if abstain else "") + ").", "",
         f"{vline} (most votes fill the seats):"]
    for c in order:
        tag = "  <- Elected" if c in winners else ""
        L.append(f"   {c:<{nw}}  {votes[c]:>4}{tag}")
    L.append("")
    L.append(winners_line)
    for i, c in enumerate(winners, 1):
        L.append(f"   {i}. {c}   ({votes[c]} votes)")
    if cutoff_lot_tie:
        a, b = winners[-1], order[num_winners]
        L.append(f"   *** the last seat tied on votes ({a} and {b}) — decided by lot order.")
    report = "\n".join(L)
    if not silent:
        print(report.replace(banner, f"{COLOR_HEADER}{banner}{COLOR_RESET}"))
    if out_path is not None and not _skip_degraded_mirror(out_path):
        try:
            Path(out_path).parent.mkdir(parents=True, exist_ok=True)
            Path(out_path).write_text(strip_ansi(report), encoding="utf-8")
        except Exception:
            pass
    elif file_path:
        try:
            # PRIMARY mirror: composed like the STAR / Approval paths.
            write_composed_tabulated(file_path, report)
        except Exception:
            pass
    return winners


def condorcet_winner(candidates, ballots):
    """
    Condorcet winner: the candidate who wins every head-to-head pairwise
    matchup by strict majority. Returns the name, or None if none exists.
    """
    for c in candidates:
        wins_all = True
        for o in candidates:
            if c == o:
                continue
            for_c = sum(1 for b in ballots if b.get(c, 0) > b.get(o, 0))
            against_c = sum(1 for b in ballots if b.get(o, 0) > b.get(c, 0))
            if not (for_c > against_c):
                wins_all = False
                break
        if wins_all:
            return c
    return None


def copeland_winner(candidates, ballots, priority):
    """
    Ranked Robin (RCV-RR / Copeland) winner from score ballots: the candidate with
    the highest Copeland score (win = 1, DRAW = ½ to each side), ties broken by
    Ranked Robin's own degrees — 1st Degree (margins among the tied finalists),
    then 2nd Degree (margins over the whole field) — then by `priority` order.
    Mirrors run_ranked_robin's ladder exactly, so the [Divergence from STAR]
    block can never name a different RCV-RR winner than the RR report does.
    That promise has been broken twice by the same shape of drift: once by
    ranking on raw wins while the report ranked on Copeland, and again on
    2026-08-19, when the report gained its 1st Degree rung and this function did
    not — a STAR page then printed "RCV-RR = Abby" beside its own RR mirror
    saying Brad. Change one, change both. Unlike a Condorcet winner it ALWAYS
    returns a name (a cycle is resolved by the degrees / priority), or None if
    unavailable.
    """
    if not candidates or not ballots:
        return None
    order = [c for c in (priority or candidates) if c in candidates]
    for c in candidates:
        if c not in order:
            order.append(c)
    matrix = calculate_preference_matrix(candidates, ballots)
    if not matrix:
        return None
    cope = {c: 0.0 for c in candidates}
    margin = {c: 0 for c in candidates}
    for i, a in enumerate(candidates):
        for b in candidates[i + 1:]:
            fa, ag, _ = matrix[a][b]
            margin[a] += fa - ag
            margin[b] += ag - fa
            if fa > ag:
                cope[a] += 1
            elif ag > fa:
                cope[b] += 1
            else:                       # a draw is half a win to BOTH sides
                cope[a] += 0.5
                cope[b] += 0.5
    top = max(cope.values())
    finalists = [c for c in candidates if cope[c] == top]
    first_degree = {c: sum(matrix[c][o][0] - matrix[c][o][1]
                           for o in finalists if o != c) for c in finalists}
    return min(finalists, key=lambda c: (-first_degree[c], -margin[c],
                                         order.index(c)))


def print_method_comparison(candidates, ballots, star_winner, priority,
                            src_path=None, ballots_text=None, title=None):
    """
    Use STAR as the baseline and only surface the comparison when it is
    interesting: if Choose-One (Plurality), RCV-IRV, Approval, or Condorcet elects
    a DIFFERENT winner than STAR, print the block; if every method agrees with
    STAR, print nothing.

    A method "differs" when:
      * Choose-One (Plurality): its winner != the STAR winner (each ballot's one
        vote goes to its top-scored candidate)
      * RCV-IRV  : its winner != the STAR winner
      * Approval : its (single) winner != the STAR winner
      * RCV-RR   : the Ranked Robin (Copeland) winner != the STAR winner
        (always defined; a cycle is resolved by margin / priority)
      * Condorcet: a Condorcet winner exists and != the STAR winner
        (no Condorcet winner / a cycle is not treated as a disagreement)

    RCV-RR and Condorcet usually name the same candidate (Copeland elects the
    Condorcet winner whenever one exists); they only part on a cycle. When both
    differ from STAR and agree with each other, they print as one combined line.
    """
    irv, tie_ballots, total = compute_irv_winner(candidates, ballots, priority)
    approval = approval_winner(candidates, ballots, priority)
    rr = copeland_winner(candidates, ballots, priority)
    condorcet = condorcet_winner(candidates, ballots)

    # Choose-One Plurality: each ballot's single vote goes to its top-scored
    # candidate (the same tally the [Vote-splitting check] uses). Ties broken by
    # candidate priority; an all-zero ballot is an undervote that counts for no one.
    fc_counts, _ = first_choice_counts(candidates, ballots, priority)
    _order = [c for c in (priority or candidates) if c in candidates]
    for _c in candidates:
        if _c not in _order:
            _order.append(_c)
    _prank = {c: i for i, c in enumerate(_order)}
    plurality = (min(candidates, key=lambda c: (-fc_counts[c], _prank[c]))
                 if any(v > 0 for v in fc_counts.values()) else None)

    plurality_diff = plurality is not None and plurality != star_winner
    irv_diff = irv is not None and irv != star_winner
    approval_diff = approval is not None and approval != star_winner
    rr_diff = rr is not None and rr != star_winner
    condorcet_diff = condorcet is not None and condorcet != star_winner

    if not (plurality_diff or irv_diff or approval_diff or rr_diff
            or condorcet_diff):
        return  # every method agrees with STAR — nothing to learn here

    # Show STAR (the baseline) plus ONLY the methods that disagree with it;
    # methods that agree are hidden to keep the block focused on the divergence.
    shown = [("STAR", star_winner if star_winner else "(tie)")]
    if plurality_diff:
        shown.append(("Choose-One (Plurality)", plurality))
    if irv_diff:
        shown.append(("RCV-IRV", irv))
    if approval_diff:
        shown.append(("Approval", approval))
    # RCV-RR (Ranked Robin / Copeland) and Condorcet coincide off-cycle; when
    # both differ from STAR and name the same candidate, collapse to one line.
    if rr_diff and condorcet_diff and rr == condorcet:
        shown.append(("RCV-RR (Condorcet)", rr))
    else:
        if rr_diff:
            shown.append(("RCV-RR", rr))
        if condorcet_diff:
            shown.append(("Condorcet", condorcet))
    width = max(len(label) for label, _ in shown)

    print("\n[Divergence from STAR]")
    for label, value in shown:
        tag = "   (differs from STAR)" if label != "STAR" else ""
        print(f"  {label.ljust(width)} = {value}{tag}")

    # Smart note: when RCV-IRV differs, say whether the score->rank tiebreak
    # could be responsible (an artifact) or not (a genuine method difference).
    def _note(text):
        # Wrap to a readable width with a hanging indent under "Note: ".
        print(textwrap.fill(text, width=76, initial_indent="  ",
                            subsequent_indent="        "))

    # A ballot that scores everybody 0 ranks NOBODY: score 0 = unranked, so the
    # converted ranking is empty. If that is EVERY ballot, no ranking exists for
    # IRV to count, and pyrankvote's winner is its own tiebreak among candidates
    # who all hold 0 votes. That is the strongest possible artifact — and it is
    # invisible to `tie_ballots`, which by design counts only ties between RANKED
    # (non-zero) candidates, so the profile scores 0 tied ballots and the old code
    # read that as "no ties anywhere" and printed the exact opposite of the truth.
    ranked_ballots = sum(
        1 for b in ballots if any(b.get(c, 0) > 0 for c in candidates)
    )

    if irv_diff:
        if not ranked_ballots:
            _note(
                f"Note: no ballot scored anybody above 0, so not one ballot "
                f"ranks anyone and RCV-IRV has nothing to count — its winner "
                f"came from its own tiebreak among candidates all holding 0 "
                f"votes. This divergence is noise, not a method difference."
            )
        elif tie_ballots:
            pct = (100 * tie_ballots / total) if total else 0
            _note(
                f"Note: {tie_ballots} of {total} ballots ({pct:.0f}%) had equal "
                f"non-zero scores, so their ranks were decided by candidate "
                f"priority order. The RCV-IRV result may be an artifact of "
                f"score-to-rank tie-breaking rather than a deep difference."
            )
        else:
            _note(
                f"Note: no ballots had tied scores, so RCV-IRV vs STAR here is a "
                f"genuine method difference, not a tie-breaking artifact."
            )
        # Where does Ranked Robin land? That's the tell for who's the outlier.
        # Skipped on a profile nobody ranked: a "center squeeze" needs somebody
        # to be squeezed, and agreement between two lot draws is not evidence.
        if not ranked_ballots:
            pass
        elif not rr_diff:
            _note(
                "Note: Ranked Robin (RCV-RR) agrees with STAR, so RCV-IRV is the "
                "lone outlier — the classic center-squeeze signature."
            )
        elif rr == irv:
            _note(
                "Note: Ranked Robin (RCV-RR) sides with RCV-IRV, so STAR is the "
                "outlier here — STAR need not elect the Condorcet candidate."
            )

    # Generate a round-by-round report for each diverging method and print a
    # link to it, so the rounds can be reviewed/pasted without re-running the
    # other engine. Only the methods that actually differ get a file (house
    # decision: links appear exactly where they're teachable).
    if src_path:
        link_lines = []

        def _emit(method_tag, label, text):
            if not text:
                return
            try:
                out = aux_tabulated_path(src_path, method_tag)
                if _skip_degraded_mirror(out):
                    return
                out.parent.mkdir(parents=True, exist_ok=True)
                out.write_text(strip_ansi(text), encoding="utf-8")
            except Exception:
                return
            short = method_mirror_link(out, src_path)
            link_lines.append(f"  {label}: {short}")

        if irv_diff:
            _emit("RCV-IRV", "RCV-IRV rounds",
                  build_irv_report(candidates, ballots, priority, title))
        if rr_diff and ballots_text is not None:
            try:
                rr_out = aux_tabulated_path(src_path, "RCV-RR")
                run_ranked_robin(ballots_text, file_path=src_path,
                                 lot_numbers=priority, silent=True, out_path=rr_out)
                short = method_mirror_link(rr_out, src_path)
                link_lines.append(f"  RCV-RR round-robin: {short}")
            except Exception:
                pass

        if link_lines:
            print("  Full round-by-round reports (generated for review):")
            for ln in link_lines:
                print(ln)


def first_choice_counts(candidates, ballots, priority):
    """
    Choose-One Plurality tally: each ballot gives one vote to its highest-scored
    candidate (ties broken by `priority` order). Ballots that score no one above
    0 (blank / abstention / all-zero) are undervotes and count for nobody.
    Returns (counts dict, n_undervotes).
    """
    order = [c for c in (priority or candidates) if c in candidates]
    for c in candidates:
        if c not in order:
            order.append(c)
    rank = {c: i for i, c in enumerate(order)}
    counts = {c: 0 for c in candidates}
    undervotes = 0
    for b in ballots:
        top_score = max((b.get(c, 0) for c in candidates), default=0)
        if top_score <= 0:
            undervotes += 1
            continue
        leaders = [c for c in candidates if b.get(c, 0) == top_score]
        leaders.sort(key=lambda c: rank[c])
        counts[leaders[0]] += 1
    return counts, undervotes


def print_vote_splitting(candidates, ballots, blocs, star_winner, priority):
    """
    Apply the vote-splitting / spoiler test to each declared bloc. A bloc B
    "split" the vote when, under Choose-One Plurality:
        (1) the plurality winner P is NOT in B,
        (2) the bloc's combined first choices exceed P's  (Σf(b) > f(P)),
        (3) [strong] the bloc is an outright majority      (Σf(b) > N/2).
    """
    if not blocs:
        return
    f, undervotes = first_choice_counts(candidates, ballots, priority)
    n = sum(f.values())  # voters who expressed a first choice
    if n == 0:
        return
    order = [c for c in (priority or candidates) if c in candidates]
    for c in candidates:
        if c not in order:
            order.append(c)
    rank = {c: i for i, c in enumerate(order)}
    ranked = sorted(candidates, key=lambda c: (-f[c], rank[c]))
    P = ranked[0]

    def _wrap(text):
        print(textwrap.fill(text, width=76, initial_indent="  ",
                            subsequent_indent="     "))

    print("\n[Vote-splitting check]")
    tally = ", ".join(f"{c} {f[c]}" for c in ranked)
    print(f"  Choose-One first choices: {tally}"
          + (f"  (+{undervotes} undervote{'s' if undervotes != 1 else ''})"
             if undervotes else ""))
    print(f"  Plurality winner: {P} ({f[P]}, {f[P] / n * 100:.1f}%)")

    for name, members in blocs.items():
        members = [m for m in members if m in candidates]
        if len(members) < 2:
            continue  # a "bloc" needs at least two candidates to split
        bloc_sum = sum(f[m] for m in members)
        inside = P in members
        splitting = (not inside) and (bloc_sum > f[P])
        majority = bloc_sum > n / 2
        loc = "INSIDE" if inside else "OUTSIDE"
        print(f"  Bloc '{name}' = {', '.join(members)}: combined "
              f"{bloc_sum} ({bloc_sum / n * 100:.1f}%); winner {P} is {loc} it.")
        if splitting:
            strength = ("an outright majority" if majority
                        else "more than the plurality winner")
            _wrap(f"=> VOTE SPLITTING: the '{name}' bloc is {strength} "
                  f"({bloc_sum} vs {P}'s {f[P]}) but split across "
                  f"{len(members)} candidates, so {P} won Choose-One. "
                  f"STAR elected {star_winner}.")
        elif inside:
            _wrap(f"=> No vote splitting: the bloc's own front-runner ({P}) "
                  f"also wins Choose-One overall.")
        else:
            _wrap(f"=> No vote splitting: even combined ({bloc_sum}), the "
                  f"'{name}' bloc does not outpoll {P} ({f[P]}).")


def markers_used(display_rows):
    """Return the marker characters that actually appear, in MARKER_MEANINGS order."""
    seen = set()
    for row in display_rows:
        for cell in row.split(","):
            cell = cell.strip()
            if cell in MARKER_MEANINGS:
                seen.add(cell)
    return [m for m in MARKER_MEANINGS if m in seen]


def classify_ballot(display_row, candidates=None):
    """
    Label "special" ballot rows for the echo, or return None for a normal
    fully-scored preference ballot. Buckets:
      * abstention — whole race : explicit race-abstention mark (e.g. "~,~,~")
      * abstention — left blank : every candidate blank, no score ("-,-,-")
      * spoiled ballot          : full row of a spoiled marker ("?" / "%")
      * cast, no support        : has scores, but none above 0 ("0,0,0", "-,-,-,0")
      * cast, equal support (no preference) : every candidate the same score > 0
      * partial — left blank    : some candidates scored, others left blank
                                  ("-,-,1,1"); the blanks count as 0
    """
    cells = [c.strip() for c in display_row.split(",")]
    digits = [c for c in cells if c.isdigit()]
    nonblank = [c for c in cells if c != ""]

    if not digits:  # no numeric score at all -> an abstention of some kind
        if nonblank and all(c == "~" for c in nonblank):
            return "abstention — whole race"
        if nonblank and all(c in ("?", "%") for c in nonblank) \
                and len(set(nonblank)) == 1:
            return "spoiled ballot"
        return "abstention — left blank"

    blank_idx = [i for i, c in enumerate(cells) if not c.isdigit()]

    def _blanks():
        if candidates and len(candidates) == len(cells):
            return ", ".join(candidates[i] for i in blank_idx)
        return "some candidates"

    if all(int(c) == 0 for c in digits):
        # Supports no one; still note any left-blank candidates so the label is
        # consistent with the partial case below.
        if blank_idx:
            return f"cast, no support — {_blanks()} left blank"
        return "cast, no support"

    # Every candidate scored the same value > 0 (no blanks): a real vote with
    # no preference between candidates.
    if len(digits) == len(cells) and len(set(digits)) == 1 and int(digits[0]) > 0:
        return "cast, equal support (no preference)"

    # Partial: some candidates scored above 0, others left blank (a marker, not
    # an explicit 0). The blanks count as 0.
    if blank_idx:
        return f"partial — {_blanks()} left blank (counts as 0)"

    return None


def _append_ballot_flags(body_rows):
    """
    body_rows: list of (row_text, label_or_None). Append an aligned trailing
    "# label" comment to flagged rows; leave normal rows untouched. The comments
    are '#' comments, so the echo still round-trips as valid input.
    """
    if not any(label for _, label in body_rows):
        return [t for t, _ in body_rows]
    pad = max(len(t) for t, _ in body_rows)
    out = []
    for t, label in body_rows:
        out.append(f"{t.ljust(pad)}   # {label}" if label else t)
    return out


def print_marker_legend(used, caption="(these all count as score 0)"):
    """
    Print a legend explaining only the markers present in the data.

    `caption` lets a 0/1 method say what a marker means *there* — on an Approval
    ballot "counts as score 0" is true but roundabout; "not approved" is the
    reader's word.
    """
    if not used:
        return
    print(f"\n[Legend] {caption}")
    for m in used:
        print(f"  {m}  {MARKER_MEANINGS[m]}")


def format_score_counts(candidates, ballots, max_score=5, display_rows=None):
    """Return the per-candidate score-distribution block as a string (or "" if
    there's nothing to show): how many ballots gave each score value, plus an Abs
    (abstained / left blank) bucket so a blank is not conflated with an explicit 0.

    Averages: when NO ballot abstained there is only one possible mean, printed as
    `Avg`. When some did, the two readings diverge and BOTH are printed (with a
    two-line note under the table), because a blank is counted one way by the
    tabulation and the other way by the average:
      * `Avg all`   = Total / every ballot cast — a blank scores 0 here, so this is
                      simply the Total the Scoring Round ranks on, per ballot.
      * `Avg rated` = Total / the ballots that actually scored the candidate (Abs
                      excluded) — how they poll among voters who had an opinion.
    Only the FIRST decides anything; the second explains a shape (unknown vs
    disliked) that the totals alone can't show.

    Uses display_rows (which preserve the original markers) when available, so a
    blank/'~' counts in Abs rather than as a 0."""
    if not candidates or not ballots:
        return ""

    counts = {c: defaultdict(int) for c in candidates}
    totals = {c: 0 for c in candidates}
    abstain = {c: 0 for c in candidates}

    if display_rows:
        for row in display_rows:
            cells = [x.strip() for x in row.split(",")]
            for i, c in enumerate(candidates):
                cell = cells[i] if i < len(cells) else ""
                if cell.isdigit():
                    s = int(cell)
                    counts[c][s] += 1
                    totals[c] += s
                else:  # blank or a marker -> abstained on this candidate
                    abstain[c] += 1
    else:  # fallback: numeric ballots only (cannot tell blank from explicit 0)
        for b in ballots:
            for c in candidates:
                s = b.get(c, 0)
                counts[c][s] += 1
                totals[c] += s

    scores = list(range(max_score, -1, -1))  # high to low, e.g. 5..0
    n = len(ballots)
    # Corner cell is labeled "Score" so the 5..0 header row is unmistakably the
    # star values (not candidate numbers / ranks); widen the name column to fit it.
    name_w = max([len("Candidate")] + [len(c) for c in candidates])
    show_abs = any(abstain[c] for c in candidates)

    # Size every column to its widest value so the headers line up with the data —
    # a count can be 3 digits (e.g. 114), which a fixed width-2 column misaligns.
    cell_w = max([len(str(s)) for s in scores]
                 + [len(str(counts[c][s])) for c in candidates for s in scores])
    abs_w = max([len("Abs")] + [len(str(abstain[c])) for c in candidates])
    total_w = max([len("Total")] + [len(str(totals[c])) for c in candidates])

    lines = ["[Score Distribution] (how many ballots gave each star rating)"]
    score_cells = "  ".join(f"{s:>{cell_w}}" for s in scores)
    # Group header: "Score" centered over just the star-value columns (not Abs).
    lines.append(f"{'':<{name_w}}  {'Score'.center(len(score_cells))}".rstrip())
    header = f"{'Candidate':<{name_w}}  {score_cells}"
    if show_abs:
        header += f"  {'Abs':>{abs_w}}"
    # Average from an EXACT rational (totals and counts are ints), then round
    # half-up to one decimal — not float division + {:.1f}, which uses
    # round-half-to-EVEN and would print an exact 1.25 as a surprising "1.2".
    # See STAR_reporting/score_distribution_and_averages.md.
    def _avg(total, denominator):
        if not denominator:
            return Decimal("0.0")
        return (Decimal(total) / Decimal(denominator)).quantize(
            Decimal("0.1"), rounding=ROUND_HALF_UP)

    # With no abstentions the two averages are arithmetically the same number, so
    # one `Avg` column says everything; the second appears only where a blank can
    # actually pull them apart. (Same conditional shape as the Abs column above.)
    if show_abs:
        lines.append(f"{header}  | {'Total':>{total_w}}  {'Avg all':>7}  {'Avg rated':>9}")
    else:
        lines.append(f"{header}  | {'Total':>{total_w}}  {'Avg':>4}")
    for c in candidates:
        cells = "  ".join(f"{counts[c][s]:>{cell_w}}" for s in scores)
        if show_abs:
            cells += f"  {abstain[c]:>{abs_w}}"
        row = f"{c:<{name_w}}  {cells}  | {totals[c]:>{total_w}}"
        if show_abs:
            row += (f"  {_avg(totals[c], n):>7}"
                    f"  {_avg(totals[c], n - abstain[c]):>9}")
        else:
            row += f"  {_avg(totals[c], n):>4}"
        lines.append(row)
    if show_abs:
        lines.append(
            "  Avg all   = Total / all ballots — a blank counts as 0, so this is the"
            " Total the Scoring Round ranks on, per ballot.")
        lines.append(
            "  Avg rated = Total / the ballots that scored this candidate (Abs excluded)"
            " — support among voters who had an opinion.")
    return "\n".join(lines)


def _star_comparison(cw, star_winner, finalists):
    """Annotate how the Condorcet winner relates to the STAR result."""
    if star_winner is None:
        return ""
    if cw == star_winner:
        return " — matches the STAR winner"
    note = f" — STAR elected {star_winner} instead"
    if finalists and cw not in finalists:
        note += f" ({cw} was eliminated in the scoring round)"
    return note


def analyze_condorcet(candidates, matrix, star_winner=None, finalists=None):
    """Classify the pairwise outcome instead of conflating ties with cycles.

    1. Strict winner: beats every other candidate head-to-head.
    2. Unique weak winner: unbeaten, but ties at least one matchup.
    3. Multiple unbeaten candidates: pairwise ties, no cycle among them.
    4. Genuine cycle: every candidate loses at least one matchup.
    """
    beats = {c: set() for c in candidates}
    losses = {c: 0 for c in candidates}
    for c1 in candidates:
        for c2 in candidates:
            if c1 == c2:
                continue
            for_c1, against_c1, _ = matrix[c1][c2]
            if for_c1 > against_c1:
                beats[c1].add(c2)
            elif against_c1 > for_c1:
                losses[c1] += 1

    n_others = len(candidates) - 1
    unbeaten = [c for c in candidates if losses[c] == 0]

    # Case 1: strict Condorcet winner
    for c in candidates:
        if len(beats[c]) == n_others:
            return f"Condorcet Winner: {c}" + _star_comparison(
                c, star_winner, finalists
            )

    # Case 2: unique weak winner (unbeaten, but ties some matchups)
    if len(unbeaten) == 1:
        return (
            f"No strict Condorcet winner; weak Condorcet winner: {unbeaten[0]}"
            + _star_comparison(unbeaten[0], star_winner, finalists)
        )

    # Case 3: multiple unbeaten candidates (indifference, not intransitivity)
    if len(unbeaten) > 1:
        return (
            f"No strict Condorcet winner; "
            f"unbeaten candidates: {', '.join(unbeaten)} (pairwise ties)"
        )

    # Case 4: everyone loses at least once -> a majority cycle must exist
    cycle = _find_beats_cycle(candidates, beats)
    if cycle:
        return f"No Condorcet winner (majority cycle: {' > '.join(cycle)})"
    return "No Condorcet winner (every candidate loses at least one matchup)"


def analyze_condorcet_loser(candidates, matrix):
    """Mirror of analyze_condorcet for the losing end of the pairwise table.

    Returns ([candidate], "strict") for a strict Condorcet loser (loses every
    head-to-head matchup), ([candidate], "weak") for a UNIQUE weak Condorcet
    loser (never wins a matchup, ties at least one), ([candidates], "joint")
    when SEVERAL candidates are winless (jointly weak Condorcet losers — the
    weak notion need not be unique; mirror of the winner side's "unbeaten
    candidates" case), or None when everyone wins at least one matchup.
    Everyone-winless (total pairwise indifference) also returns None — calling
    the whole field "losers" would mislead.
    """
    if len(candidates) < 2:
        return None
    wins = {c: 0 for c in candidates}
    losses = {c: 0 for c in candidates}
    for c1 in candidates:
        for c2 in candidates:
            if c1 == c2:
                continue
            for_c1, against_c1, _ = matrix[c1][c2]
            if for_c1 > against_c1:
                wins[c1] += 1
            elif against_c1 > for_c1:
                losses[c1] += 1

    n_others = len(candidates) - 1
    for c in candidates:
        if losses[c] == n_others:
            return ([c], "strict")
    winless = [c for c in candidates if wins[c] == 0]
    if len(winless) == 1:
        return (winless, "weak")
    if 1 < len(winless) < len(candidates):
        return (winless, "joint")
    return None


def _find_beats_cycle(candidates, beats):
    """DFS for a directed cycle in the 'beats' graph.
    Returns the cycle as a list like ['A', 'B', 'C', 'A'], or None."""
    WHITE, GRAY, BLACK = 0, 1, 2
    color = {c: WHITE for c in candidates}
    stack = []

    def dfs(c):
        color[c] = GRAY
        stack.append(c)
        for nxt in sorted(beats[c]):   # sorted: a set's order varies per run,
            if color[nxt] == GRAY:     # and the printed cycle must be stable
                i = stack.index(nxt)
                return stack[i:] + [nxt]
            if color[nxt] == WHITE:
                found = dfs(nxt)
                if found:
                    return found
        stack.pop()
        color[c] = BLACK
        return None

    for c in candidates:
        if color[c] == WHITE:
            found = dfs(c)
            if found:
                return found
    return None


def print_extended_analysis(ballots, winners):
    if not winners:
        return
    runoff_winner_name = list(winners)[0]
    scores = defaultdict(int)
    for b in ballots:
        for c, s in b.items():
            scores[c] += s
    max_score = max(scores.values()) if scores else 0
    top_scorers = [c for c, s in scores.items() if s == max_score]

    if runoff_winner_name not in top_scorers:
        # A Runoff Reversal HAPPENED (Runoff winner is NOT the score winner).
        # Header uses the house glossary term; the old label "Majority
        # Preference Enforcement Principle" overclaimed (STAR fails the formal
        # majority criterion — the true guarantee is conditional: majority
        # preference BETWEEN THE TWO FINALISTS).
        score_winners_str = ", ".join(top_scorers)
        print(
            "\n[Runoff Reversal]\n"
            f" - Score Round Winner(s) = ({score_winners_str})\n"
            f" - Runoff Round Winner   = ({runoff_winner_name})"
        )
        print(
            f"  Candidate {score_winners_str} earned the highest total score, but\n"
            f"  Candidate {runoff_winner_name} won the automatic runoff — not a malfunction,\n"
            "  STAR working as designed: the runoff elects the finalist preferred\n"
            "  by the majority (of voters with a preference).\n"
        )


# ---
# 3. EXECUTION LOGIC
# ---
def evaluate_quorum(participation, total_cast, eligible_voters, quorum_spec):
    """
    Decide whether an election meets quorum.

    participation : ballots counting toward quorum (abstentions INCLUDED, since a
                    cast abstention is still participation).
    total_cast    : total ballots cast (for messaging).
    eligible_voters: size of the electorate, or None if unknown.
    quorum_spec   : None  -> default = majority (>50%) of eligible voters
                    "50%" / 0.5 -> a fraction of eligible voters
                    integer >= 1 -> an absolute minimum number of ballots

    Returns (met, message):
      met = True / False / None  (None = cannot be assessed)
    """
    # Interpret the spec into either a fraction (of eligible) or absolute count.
    fraction = None
    absolute = None
    default_used = quorum_spec is None
    if quorum_spec is None:
        fraction = 0.5  # majority of eligible voters
    elif isinstance(quorum_spec, str) and quorum_spec.strip().endswith("%"):
        fraction = float(quorum_spec.strip().rstrip("%")) / 100.0
    else:
        v = float(quorum_spec)
        if 0 < v <= 1:
            fraction = v
        else:
            absolute = int(round(v))

    if absolute is not None:
        met = participation >= absolute
        msg = (f"Quorum: {participation} of {total_cast} ballots count toward "
               f"quorum; requires at least {absolute}. "
               f"{'MET' if met else 'NOT MET'}.")
        return met, msg

    # Fraction of eligible voters — needs the electorate size.
    if not eligible_voters:
        how = "default majority (>50%)" if default_used else f"{fraction:.0%}"
        return None, (
            f"Quorum not assessed: a turnout quorum ({how} of eligible voters) "
            f"needs an 'eligible_voters:' field, which is not set. "
            f"({total_cast} ballots cast.)"
        )
    required = math.floor(fraction * eligible_voters) + 1  # strictly more than
    met = participation >= required
    turnout = participation / eligible_voters * 100
    msg = (f"Quorum: {participation} of {eligible_voters} eligible voters "
           f"participated ({turnout:.0f}% turnout); requires more than "
           f"{fraction:.0%} (>= {required}). {'MET' if met else 'NOT MET'}.")
    return met, msg


def validate_star_rows(ballots_text, max_score=5):
    """
    Validate STAR score rows against the RAW source (so malformed rows error
    instead of being silently dropped by the parser). Only standard comma/tab
    grids are checked; the compact underscore format (e.g. "052_225") is left to
    the parser. A cell is valid if it is an int 0..max_score, blank, or a
    recognized marker. Returns (headers, problems) with (row_number, raw, reason).
    """
    lines = []
    for raw in ballots_text.strip().splitlines():
        line = raw if raw.strip().startswith("#,") else raw.split("#")[0]
        line = line.strip()
        if line:
            lines.append(line)
    if len(lines) < 2:
        return [], []

    headers = [h.strip() for h in re.split(r"[,\t]+", lines[0]) if h.strip()]
    if headers and headers[0] == "#":
        headers.pop(0)
    if headers and re.match(r"(?i)^count\s*:", headers[0]):
        headers[0] = headers[0].split(":", 1)[1].strip()
    count_col = bool(headers) and headers[0].lower() == "count"
    if count_col:
        headers.pop(0)

    problems = []
    for i, line in enumerate(lines[1:], 1):
        if "," not in line and "\t" not in line:
            continue  # compact underscore / single token -> leave to the parser
        parts = re.split(r"[,\t]", line)
        m = re.match(r"\s*(\d+)\s*[:xX×]\s*(.*)", parts[0])  # weight prefix
        if m:
            parts = [m.group(2)] + parts[1:]
        cells = [p.strip() for p in parts]
        if count_col and cells:
            cells = cells[1:]
        # A full row of one ballot-level marker (e.g. "~,~,~") is valid.
        nonblank = [c for c in cells if c != ""]
        if (nonblank and len(set(nonblank)) == 1
                and nonblank[0] in BALLOT_LEVEL_MARKERS):
            continue
        if len(cells) != len(headers):
            problems.append((i, line,
                             f"has {len(cells)} value(s), expected {len(headers)}"))
            continue
        bad = []
        for h, c in zip(headers, cells):
            if c == "" or c in MARKER_MEANINGS:
                continue
            try:
                v = int(c)
            except ValueError:
                bad.append(f"{h}={c}")
                continue
            if not (0 <= v <= max_score):
                bad.append(f"{h}={c}")
        if bad:
            problems.append((i, line, "invalid: " + ", ".join(bad)))
    return headers, problems


def run_election(
    csv_input,
    lot_numbers,
    # Display defaults = the house DEFAULT_OPTIONS dict (keep in sync). The CLI
    # passes every display kwarg explicitly; API callers get the house render.
    # NOTE: the CLI's seats/candidate-count matrix auto-gate lives in main, not
    # here — a multi-winner API caller should pass show_matrix=False itself.
    show_matrix=True,
    matrix_finalists_only=True,
    brief=True,
    seats=1,
    method=None,
    show_condorcet=False,
    show_score_counts=False,
    collapse_ballots=True,
    count_separator="×",
    title=None,
    description=None,
    show_irv=False,
    eligible_voters=None,
    quorum=None,
    blocs=None,
    show_description=False,
    show_runoff_percent=True,
    full_report=False,
    src_path=None,
    display_method_name=None,
):
    if method is None:
        method = starvote.star

    # Reject method/seats mismatches up front (before any tabulation), so the
    # intent must be corrected rather than silently guessed.
    # `display_method_name` overrides the label ONLY (the tabulation is unchanged) —
    # e.g. Choose-One/Plurality is computed via the STAR path but must not be
    # labelled "STAR" in the banner and winner line.
    method_name = display_method_name or getattr(method, "name", str(method))
    single_winner = method is starvote.star
    if single_winner and seats > 1:
        print(
            f"{COLOR_RED}Error:{COLOR_RESET} {method_name} elects a single winner,\n"
            f"  but got seats={seats}.\n"
            f"  Fix: set seats=1 (num_winners: 1),\n"
            f"       or choose a multi-winner method to elect {seats} winners:\n"
            f"       starvote.bloc, starvote.sss, starvote.rrv, starvote.allocated."
        )
        sys.exit(1)
    if not single_winner and seats == 1:
        print(
            f"{COLOR_RED}Error:{COLOR_RESET} {method_name} elects multiple winners,\n"
            f"  but got seats=1 (requires seats >= 2).\n"
            f"  Fix: set seats to the number of winners you want,\n"
            f"       or use method=starvote.star for a single winner."
        )
        sys.exit(1)

    # Optional scenario context from the YAML (election_title / scenario_description).
    # The title is a one-line banner; the (often multi-paragraph) description can
    # be suppressed with `show_description: false` for a clean demo / recording,
    # WITHOUT removing it from the file.
    if title or (description and show_description):
        if title:
            print(f"\n{COLOR_HEADER}=== {title} ==={COLOR_RESET}")
        if description and show_description:
            for line in str(description).splitlines():
                print(f"  {line}" if line.strip() else "")

    # Validate raw rows first, so invalid characters / out-of-range scores /
    # wrong column counts error instead of being silently dropped.
    _hdrs, _star_problems = validate_star_rows(csv_input, max_score=5)
    if _star_problems:
        # Lead with the message that matches the ACTUAL defect: a column-count
        # mismatch (candidates ≠ scores-per-ballot) is a different problem from
        # an out-of-range score, so don't headline "scores 0..5" for the former.
        _n = len(_hdrs)
        _has_col = any("value(s), expected" in _r for _, _, _r in _star_problems)
        _has_mark = any(_r.startswith("invalid:") for _, _, _r in _star_problems)
        if _has_col and not _has_mark:
            _headline = (
                f"Error: the number of scores per ballot doesn't match the number "
                f"of candidates. There are {_n} candidate(s) ({', '.join(_hdrs)}), "
                f"so each ballot row needs exactly {_n} comma-separated score(s)."
            )
        elif _has_col and _has_mark:
            _headline = (
                f"Error: some ballots have the wrong number of scores (expected "
                f"{_n}, one per candidate — {', '.join(_hdrs)}) and/or use scores "
                f"outside 0..5 (blank or a marker counts as 0)."
            )
        else:
            _headline = ("Error: STAR ballots use scores 0..5 "
                         "(blank or a marker counts as 0).")
        print(f"{COLOR_RED}{_headline}{COLOR_RESET}\n"
              f"  Offending ballot(s)  [{','.join(_hdrs)}]:")
        for _i, _line, _reason in _star_problems:
            print(f"    ballot {_i}: {_line}   ({_reason})")
        if _has_mark:
            print(f"  Accepted marks: 0..5, blank, or a marker "
                  f"({', '.join(MARKER_MEANINGS)}).")
        if _has_col:
            print("  Tip: use the SAME separator for the header and every row — commas\n"
                  "       (or tabs), e.g. 'A, B, C' then '5, 4, 0'. Mixing commas and\n"
                  "       spaces is the usual cause of a wrong value count.")
        sys.exit(1)

    # Parse once, return both headers and parsed ballots
    candidates, ballots, display_rows = parse_ballots_from_string(csv_input)

    if not ballots:
        # A '>' in the input means these are ranked ballots (e.g. "A>C>B"),
        # which STAR cannot tabulate — it needs scores. Point the user to the
        # RCV-IRV engine instead of a bare "no ballots" error.
        if ">" in (csv_input or ""):
            print(
                "Error: this file contains RANKED ballots (e.g. 'A>C>B'), which "
                "the STAR engine cannot tabulate — STAR needs score ballots.\n"
                "  Run it through the RCV-IRV engine instead:\n"
                "    python 06_Other/RCV_IRV/RCV_IRV_tabulation_engine/rcv_irv_tabulation.py "
                "<this_file>.yaml"
            )
        else:
            print("Error: No valid ballots found in input.\n       Separate columns with commas (recommended), tabs, or consistent spaces —\n       e.g. a header 'A, B, C' then rows like '5, 4, 0'. Other delimiters (like\n       '|' or ';') aren't supported, and every row needs the same number of\n       values as the header.")
        sys.exit(1)

    # Validate declared blocs against the ballot candidates, so a typo or a
    # candidate not on the ballot errors instead of being silently dropped.
    if blocs:
        problems = []
        for _name, _members in blocs.items():
            _members = list(_members or [])
            _unknown = [m for m in _members if m not in candidates]
            if _unknown:
                problems.append(f"  Bloc '{_name}': not on the ballot -> "
                                f"{', '.join(_unknown)}")
            elif len(_members) < 2:
                problems.append(f"  Bloc '{_name}': needs at least 2 candidates "
                                f"to split (got {len(_members)}).")
        if problems:
            print(f"{COLOR_RED}Error: invalid 'blocs:' definition.{COLOR_RESET}")
            print(f"  Ballot candidates: {', '.join(candidates)}")
            for p in problems:
                print(p)
            print("  Fix the names under 'blocs:' to match the ballot header "
                  "exactly (or remove the bloc).")
            sys.exit(1)

    # Quorum check (before declaring any winner). Only engaged when the file
    # opts in via eligible_voters and/or quorum; otherwise behaves as before.
    # Abstentions count toward quorum (a cast ballot is participation), so
    # participation = total ballots cast.
    if eligible_voters is not None or quorum is not None:
        _q_label = "single winner" if seats == 1 else f"{seats} winners"
        quorum_met, quorum_msg = evaluate_quorum(
            participation=len(ballots),
            total_cast=len(ballots),
            eligible_voters=eligible_voters,
            quorum_spec=quorum,
        )
        if quorum_met is False:
            print(f"\n{COLOR_HEADER}--- {method_name} Method ({_q_label}) "
                  f"---{COLOR_RESET}")
            print(f"{COLOR_RED} {quorum_msg}{COLOR_RESET}")
            print(f"{COLOR_RED} No winner declared — quorum not reached."
                  f"{COLOR_RESET}")
            return
        if quorum_met is None:
            print(f"{COLOR_DIM} {quorum_msg}{COLOR_RESET}")  # warning, continue
        else:
            print(f" {quorum_msg}")  # quorum MET

    # Which marker characters actually appear (for the legend).
    used_markers = markers_used(display_rows)

    # Generate matrix from the already-parsed data
    matrix = calculate_preference_matrix(candidates, ballots)

    # Same priority rule the tiebreaker uses (falls back to CSV column order)
    priority = lot_numbers or candidates
    order_map = {c: i for i, c in enumerate(priority)}
    finalists, finalist_tiebreak = resolve_finalists(ballots, order_map)

    # Initialize the new deterministic tiebreakers
    tiebreaker_obj = LotNumberTiebreaker(lot_numbers=lot_numbers, silent=False)
    tiebreaker_silent = LotNumberTiebreaker(lot_numbers=lot_numbers, silent=True)

    # Run silent election for analysis
    if winners_silent := starvote.election(
        method=starvote.star,
        ballots=ballots,
        seats=1,
        tiebreaker=tiebreaker_silent,
        verbosity=0,
        maximum_score=5,
    ):
        # (The normalized ballot CSV is printed by custom_print, right after
        # the engine's "Tabulating N ballots." line.)

        # seats=1: election() returns a single winner or a list
        star_winner = (
            winners_silent[0] if isinstance(winners_silent, list) else winners_silent
        )

        # These analyses are independent toggles. (The [Score Distribution]
        # block is rendered lower down, right after the "Tabulating N ballots."
        # echo and before the Scoring Round — see the custom_print handler.)
        if show_matrix:
            print_matrix(
                candidates,
                matrix,
                finalists,
                star_winner,
                finalists_only=matrix_finalists_only,
                tiebreak=finalist_tiebreak,
                seats=seats,
            )
        if show_condorcet:
            print_condorcet(candidates, matrix, star_winner, finalists,
                            ballots=ballots, priority=priority)

        # Always show the RCV-IRV / STAR / Approval comparison (Condorcet line
        # appears only when it differs from all three). priority == STAR's
        # tiebreak order, used for the score->rank conversion.
        print_method_comparison(candidates, ballots, star_winner, priority,
                                src_path=src_path, ballots_text=csv_input,
                                title=title)

        # Vote-splitting / spoiler check for any declared candidate blocs.
        print_vote_splitting(candidates, ballots, blocs, star_winner, priority)

        print_extended_analysis(ballots, winners_silent)

    # Header banner naming the actual method + winner count.
    winners_label = "single winner" if seats == 1 else f"{seats} winners"
    method_label = method_name if method_name.endswith("Voting") else f"{method_name} Voting"
    print(f"\n{COLOR_HEADER}--- {method_label} Method ({winners_label}) ---{COLOR_RESET}")

    # Intercept the engine's print() to fix grammar and relabel the
    # "No Preference" bucket, re-aligning score columns so the longer
    # label doesn't shove the " -- " separator out of column.
    EQUAL_LABEL = "Equal Support"  # EVC (Equal Vote Coalition) terminology
    EQUAL_NOTE = "(aka Equal Preference, No Preference)"  # appended inline after value
    row_re = re.compile(r"^(\s*)(\S.*?)\s+--\s+(.*)$")
    # Pad the label column to the widest of any candidate name or "Equal Support"
    # so the " -- " separators line up even for long names like "Chocolate Chip".
    label_width = max([len(EQUAL_LABEL)] + [len(c) for c in candidates])

    # Round grouping: draw a faint rule before each new round's Scoring Round
    # header (but not the first), so multi-round methods like Bloc STAR read as
    # distinct blocks without spending another header color.
    round_state = {"scoring_seen": False, "in_runoff": False, "runoff_rows": [],
                   "score_w": None}
    ROUND_RULE = f"{COLOR_DIM}{'─' * 50}{COLOR_RESET}"

    def format_runoff_percent(rows, full=False):
        """Runoff summary using the *decided-voters* denominator — the voters
        who expressed a preference between the two finalists (Equal Support is
        excluded). The line now *self-reconciles*: it states the decided count
        against the total ballots and names the Equal Support gap inline, so the
        reader never has to subtract two far-apart numbers to see where the
        denominator came from (BetterVoting's two percent columns leave that to
        infer). On screen it's one line; in the full `_tabulated` copy (full=True)
        a small "Runoff math" funnel makes the arithmetic explicit instead.
        Returns "" for anything that isn't a clean two-finalist runoff (e.g. an
        exact tie, which the tiebreaker chain explains instead)."""
        finalists = [(lbl, val) for lbl, val, _f in rows if lbl != EQUAL_LABEL]
        if len(finalists) != 2:
            return ""
        (w_lbl, w_val), (l_lbl, l_val) = sorted(finalists, key=lambda x: -x[1])
        decided = w_val + l_val
        if decided <= 0 or w_val == l_val:
            return ""
        equal = sum(val for lbl, val, _f in rows if lbl == EQUAL_LABEL)
        total = decided + equal
        w_pct = round(w_val / decided * 100)
        l_pct = round(l_val / decided * 100)
        majority = decided // 2 + 1  # votes needed for a strict majority
        es = f"{equal} Equal Support" if equal else "no Equal Support"
        if not full:
            # On-screen: two short lines — the denominator, then the head-to-head
            # split — so the summary never overflows a narrow terminal.
            return (
                f"   Voters with a preference: {decided} of {total} ({es}).\n"
                f"   {w_lbl} {w_val} ({w_pct}%) vs {l_lbl} {l_val} ({l_pct}%); "
                f"majority = {majority}."
            )
        # `_tabulated`: a funnel that visibly adds up (total − Equal Support =
        # decided), then the two finalists' shares of the decided voters.
        wd = len(str(total))
        return (
            "   Runoff math:\n"
            f"     {total:>{wd}}  ballots cast\n"
            f"   − {equal:>{wd}}  Equal Support (no preference between the two finalists)\n"
            f"     {'─' * wd}\n"
            f"     {decided:>{wd}}  voters with a preference  (majority = {majority})\n"
            f"           {w_lbl} {w_val} ({w_pct}%)  ·  {l_lbl} {l_val} ({l_pct}%)"
        )

    def colorize_runoff_value(label, rest):
        """In an Automatic Runoff Round, color the leading count to match the
        Preference Matrix legend: winner=For(green), other finalist=Against(red),
        Equal Support=Equal Preference(blue). A tie is left neutral."""
        mm = re.match(r"^(\S+)(.*)$", rest)
        if not mm:
            return rest
        val, tail = mm.groups()
        if label == EQUAL_LABEL:
            color = COLOR_BLUE
        elif "Tied" in rest:
            color = ""  # tie not yet resolved -> no winner/loser
        elif "First place" in rest:
            color = COLOR_GREEN
        else:
            color = COLOR_RED
        if not color:
            return rest
        return f"{color}{val}{COLOR_RESET}{tail}"

    def custom_print(*args, **kwargs):
        if args and isinstance(args[0], str):
            text = args[0]

            # The engine bakes the trailing newline into the string and calls
            # us with end='' — preserve it so rows don't run together.
            trailing = "\n" if text.endswith("\n") else ""
            if trailing:
                text = text[:-1]

            # 0. BRIEF mode: collapse the repetitive engine section headers.
            #    "[STAR Voting]"               -> dropped entirely
            #    "[STAR Voting: Scoring Round]" -> "Scoring Round"
            #    (also works for "[Bloc STAR: Round 1: ...]")
            stripped = text.strip()

            # Relocate the multiwinner "Want to fill N seats." line: suppress it
            # here and fold the seat count into the "Tabulating N ballots." line
            # below, so the two "size of this election" facts sit together at the
            # top instead of orphaning this line just before Round 1.
            if (seats and seats > 1 and stripped.startswith("Want to fill ")
                    and stripped.endswith("seats.")):
                return

            # Round separator: before each main "Scoring Round" header after the
            # first, emit a faint rule to visually group each round.
            if stripped.startswith("[") and stripped.endswith("]"):
                inner_h = stripped[1:-1].rstrip()
                # Only the *main* runoff header enables coloring; tiebreaker
                # sub-headers (which list raw scores) reset it.
                round_state["in_runoff"] = inner_h.endswith("Automatic Runoff Round")
                round_state["score_w"] = None  # re-measure value width per section
                if round_state["in_runoff"]:
                    round_state["runoff_rows"] = []  # fresh tally per runoff round
                if inner_h.endswith("Scoring Round"):
                    if round_state["scoring_seen"]:
                        print(ROUND_RULE)
                    round_state["scoring_seen"] = True

            if brief and stripped.startswith("[") and stripped.endswith("]"):
                inner = stripped[1:-1]
                if ":" in inner:
                    label = inner.split(":", 1)[1].strip()
                    # Restate the method on the final Winner(s) line, since the
                    # top banner has usually scrolled off by then.
                    if label in ("Winner", "Winners"):
                        # "Winner" stays green; the restated method matches the
                        # purple top banner.
                        suffix = f" — {method_label} Method ({winners_label})"
                        text = (
                            f"{COLOR_WINNER}{label}{COLOR_RESET}"
                            f"{COLOR_HEADER}{suffix}{COLOR_RESET}"
                        )
                    else:
                        text = f"{header_color(label)}{label}{COLOR_RESET}"
                    args = (text + trailing,) + args[1:]
                    print(*args, **kwargs)
                else:
                    # Bare top-level method header -> suppress.
                    pass
                return
            if stripped.startswith("[") and stripped.endswith("]"):
                # Non-brief: keep the full "[...]" header but colorize it.
                inner_nb = stripped[1:-1]
                if inner_nb.rsplit(":", 1)[-1].strip() in ("Winner", "Winners"):
                    stripped = (
                        f"{stripped[:-1]} — {method_label} Method ({winners_label})]"
                    )
                text = f"{header_color(stripped)}{stripped}{COLOR_RESET}"
                args = (text + trailing,) + args[1:]
                print(*args, **kwargs)
                return

            # 1. Fix the singular/plural grammar.
            text = text.replace("Tabulating 1 ballots.", "Tabulating 1 ballot.")

            # 1a. Multiwinner: fold the seat count into the setup line (the base
            #     engine's standalone "Want to fill N seats." is suppressed above).
            #     Only the initial tabulation, not per-round "remaining ballots".
            if (seats and seats > 1 and text.lstrip().startswith("Tabulating ")
                    and "remaining" not in text):
                text = re.sub(r"(ballots?)\.", rf"\1 to fill {seats} seats.",
                              text, count=1)

            # 1b. After the "Tabulating N ballot(s)." line, list the
            #     normalized ballots as Standard CSV.
            if text.lstrip().startswith("Tabulating ") and "ballot" in text:
                # Note true abstentions: ballots that recorded NO numeric score
                # for any candidate — i.e. entirely blank / abstention markers
                # (e.g. "~,~,~" or "-,-,-"). An explicit all-zeros ballot
                # ("0,0,0") is a cast ballot that supports no one, NOT an
                # abstention, so it is not counted here.
                _abs = sum(
                    1 for r in display_rows
                    if not any(cell.strip().isdigit() for cell in r.split(","))
                )
                if _abs:
                    _n = len(ballots)
                    if _abs == 1:
                        text += f" Note: 1 of {_n} ballots is marked as an abstention."
                    else:
                        text += (f" Note: {_abs} of {_n} ballots are marked as "
                                 f"abstentions.")

                # Echo keeps original markers (display_rows), faithful to source,
                # followed by a legend for any markers used. Each column is padded
                # to its widest cell so it lines up — still valid, parseable CSV.
                ncols = len(candidates)
                has_dupes = len(set(display_rows)) < len(display_rows)
                if collapse_ballots and has_dupes:
                    # Collapse identical ballots into "count: scores" (matches the
                    # weight syntax, so it round-trips), most common first.
                    counts, first_idx = {}, {}
                    for i, r in enumerate(display_rows):
                        if r not in counts:
                            counts[r], first_idx[r] = 0, i
                        counts[r] += 1
                    unique = sorted(counts, key=lambda r: (-counts[r], first_idx[r]))
                    rows = [r.split(",") for r in unique]
                    widths = [
                        max(len(candidates[i]), max(len(rc[i]) for rc in rows))
                        for i in range(ncols)
                    ]
                    count_w = max(len("Count"), max(len(str(counts[r])) for r in unique))
                    # count_separator (":", "x"/"X", or "×") all round-trip.
                    sep = f" {count_separator} "
                    csv_rows = [
                        "Count".rjust(count_w)
                        + sep
                        + ",".join(candidates[i].rjust(widths[i]) for i in range(ncols))
                    ]
                    body_rows = []
                    for r in unique:
                        rc = r.split(",")
                        body = ",".join(rc[i].rjust(widths[i]) for i in range(ncols))
                        body_rows.append(
                            (f"{str(counts[r]).rjust(count_w)}{sep}{body}",
                             classify_ballot(r, candidates))
                        )
                    csv_rows += [t for t, _ in body_rows]
                else:
                    grid = [r.split(",") for r in display_rows]
                    header = list(candidates)
                    widths = [max(len(row[i]) for row in ([header] + grid))
                              for i in range(ncols)]
                    csv_rows = [
                        ",".join(c.rjust(widths[i]) for i, c in enumerate(header))
                    ]
                    body_rows = [
                        (",".join(c.rjust(widths[i]) for i, c in enumerate(row)),
                         classify_ballot(display_rows[j], candidates))
                        for j, row in enumerate(grid)
                    ]
                    csv_rows += [t for t, _ in body_rows]
                # One-line clarification of the blank-vs-explicit-zero distinction
                # (only when a blank actually appears): a blank ballot is an
                # abstention; an all-zeros ballot is cast but supports no one.
                if "-" in used_markers:
                    csv_rows.append(
                        "  ('-' = left blank / abstained; '0' = scored zero — "
                        "both count as 0 stars.)"
                    )
                text = text + "\n" + "\n".join(csv_rows)

                # 1c. Optional [Score Distribution] block, between the ballot
                #     echo and the Scoring Round.
                if show_score_counts:
                    _sc = format_score_counts(
                        candidates, ballots, max_score=5, display_rows=display_rows
                    )
                    if _sc:
                        text = text + "\n\n" + _sc

            # 2. Relabel the no-preference bucket.
            relabeled = "No Preference" in text
            text = text.replace("No Preference", EQUAL_LABEL)

            # 3. Re-align score rows ("   <label> -- <value>"), skipping
            #    section headers like "[STAR Voting: ...]".
            m = row_re.match(text)
            if m and not text.lstrip().startswith("["):
                indent, label, rest = m.groups()
                # Right-justify the leading score so values line up within a round.
                # The first row is "First place" (the largest), so its width sizes
                # the column. (House term is just "Equal Support".)
                _pad = ""
                _vm = re.match(r"(-?\d+)(.*)$", rest, re.S)
                if _vm:
                    _num = _vm.group(1)
                    if round_state["score_w"] is None:
                        round_state["score_w"] = len(_num)
                    _pad = " " * max(0, round_state["score_w"] - len(_num))
                    if round_state["in_runoff"]:
                        # leading integer captured for the percentage summary
                        round_state["runoff_rows"].append(
                            (label.strip(), int(_num), "First place" in rest)
                        )
                if round_state["in_runoff"]:
                    rest = colorize_runoff_value(label.strip(), rest)
                text = f"{indent}{label:<{label_width}} -- {_pad}{rest}"

            # Optional runoff percentage summary, appended right after the
            # round's "<winner> wins." line (decided-voters denominator). Always
            # in the full _tabulated copy; on screen only when the option is set.
            elif (show_runoff_percent and round_state["in_runoff"]
                  and re.match(r"^\s*\S.*\swins\.\s*$", text)):
                extra = format_runoff_percent(round_state["runoff_rows"], full_report)
                if extra:
                    text = f"{text}\n{extra}"

            args = (text + trailing,) + args[1:]
        print(*args, **kwargs)

    winners = starvote.election(
        method=method,
        ballots=ballots,
        seats=seats,
        tiebreaker=tiebreaker_obj,
        verbosity=1,
        maximum_score=5,
        print=custom_print,  # Inject the custom print function here
    )

    return winners


if __name__ == "__main__":
    # Code is available at: "https://github.com/larryhastings/starvote"

    # TIEBREAKER SETTING
    # Provide a list like ["B", "A", "C"] for production ties.
    # Leave empty [] to auto-generate based on CSV columns for quick testing.
    LOT_NUMBERS = []

    # DISPLAY DEFAULTS — pulled from the single DEFAULT_OPTIONS dict near the
    # top of the file (the house style; edit THERE, not here). A YAML file's
    # `options:` block can still override any of them per run, and `--full`
    # puts the everything-on mirror render on screen instead.
    SHOW_MATRIX = DEFAULT_OPTIONS["show_matrix"]
    MATRIX_FINALISTS_ONLY = DEFAULT_OPTIONS["matrix_finalists_only"]
    SHOW_CONDORCET = DEFAULT_OPTIONS["show_condorcet"]
    # NOTE: The [RCV-IRV, STAR, and Approval comparison] block is ALWAYS
    # printed; show_irv no longer gates it and is kept only so existing YAML
    # `options:` blocks that set it still parse without error.
    SHOW_IRV = DEFAULT_OPTIONS["show_irv"]
    SHOW_DESCRIPTION = DEFAULT_OPTIONS["show_description"]
    SHOW_SCORE_COUNTS = DEFAULT_OPTIONS["show_score_counts"]
    SHOW_RUNOFF_PERCENT = DEFAULT_OPTIONS["show_runoff_percent"]
    BRIEF = DEFAULT_OPTIONS["brief"]
    COLLAPSE_BALLOTS = DEFAULT_OPTIONS["collapse_ballots"]
    COUNT_SEPARATOR = DEFAULT_OPTIONS["count_separator"]

    # METHOD + SEATS.
    #   starvote.star  -> single-winner STAR (use SEATS = 1)
    #   starvote.bloc  -> Bloc STAR, multi-winner (use SEATS >= 2)
    # A mismatch (star with SEATS>1, or bloc with SEATS=1) is rejected with an
    # error and exits, so you must correct the combination.
    METHOD = starvote.star
    SEATS = 1

    # BALLOTS (the data).
    #
    # Demo workflow: pass a YAML/CSV file on the command line and it is used
    # instead of the inline csv_input below. In PyCharm, make a Run
    # Configuration whose "Parameters" is the macro  $FilePath$  — then whatever
    # election file is open in the editor is the one that gets tabulated. Open
    # the next file, hit Run again.
    #
    #   python starvote_larry_hastings.py 01_STAR/02_Examples/cases/09_c4_b100_tennessee-capital.yaml
    #
    # A .yaml/.yml file also supplies its own num_winners -> SEATS,
    # voting_method -> METHOD, and an optional `options:` block (see below).
    #
    # Add  --save  to write the result back into the YAML as an
    # `expected_results:` block (winners + plain-text report):
    #   python starvote_larry_hastings.py 01_STAR/02_Examples/cases/09_c4_b100_tennessee-capital.yaml --save
    args = [a for a in sys.argv[1:]]
    SAVE_RESULTS = "--save" in args
    # --full: put the everything-on render (the same one the _tabulated mirror
    # always gets) on screen too, ignoring the defaults and the file's options.
    FULL_RENDER = "--full" in args
    # --json: emit the machine-readable result contract and nothing else — no
    # report, no `_tabulated` mirror. This is the surface a second engine (in
    # any language) diffs against; see result_json.py and
    # 07_Concepts/tabulation_engines/result_schema.md.
    JSON_MODE = "--json" in args
    # --emit-election-json: the INPUT half of the same contract — the normalized
    # election (candidates, ballots, rules, tiebreak floor) rather than the
    # count. Same purity rule as --json: JSON on stdout, nothing else. It exists
    # so the bespoke `ballots:` DSL keeps one implementation, in Python, and
    # every other engine reads JSON; see election_json.py and
    # 07_Concepts/tabulation_engines/input_schema.md.
    EMIT_ELECTION = "--emit-election-json" in args
    positional = [a for a in args if not a.startswith("-")]
    BALLOTS_FILE = positional[0] if positional else None

    # Guard: refuse a generated *_tabulated.txt artifact (or anything carrying its
    # banner). These embed the full report — including the Preference Matrix whose
    # rows contain ">" — which would otherwise misroute to the RCV-IRV engine and
    # crash with a confusing YAML traceback. Point the user at the SOURCE FILE.
    if BALLOTS_FILE:
        try:
            _head = Path(BALLOTS_FILE).read_text(encoding="utf-8").splitlines()[:8]
        except OSError:
            _head = []
        _nonblank = [ln for ln in _head if ln.strip()]
        _is_tabulated = bool(_nonblank) and _nonblank[0].strip("= ") == "" \
            and any(ln.startswith(("SOURCE FILE:", "TABULATED FILE:")) for ln in _head)
        if _is_tabulated:
            _src = next((ln.split(":", 1)[1].strip()
                         for ln in _head if ln.startswith("SOURCE FILE:")), None)
            print(
                f"Error: '{Path(BALLOTS_FILE).name}' is a generated _tabulated.txt "
                "report, not a source election file.\n"
                "       Run the source YAML instead"
                + (f" (SOURCE FILE: {_src})." if _src else ".")
                + "\n       _tabulated files are regenerated by re-running their YAML."
            )
            sys.exit(1)

    if EMIT_ELECTION:
        if not BALLOTS_FILE:
            print("Error: --emit-election-json needs an election file "
                  "(e.g. starvote_larry_hastings.py case.yaml --emit-election-json).")
            sys.exit(1)
        import election_json
        print(election_json.dumps(BALLOTS_FILE))
        sys.exit(0)

    if JSON_MODE:
        if not BALLOTS_FILE:
            print("Error: --json needs an election file "
                  "(e.g. starvote_larry_hastings.py case.yaml --json).")
            sys.exit(1)
        import result_json
        print(result_json.dumps(BALLOTS_FILE))
        sys.exit(0)

    csv_input = """
Memphis,Nashville,Chattanooga,Knoxville
3, 3, 4, 2
2, 5, 4, 3
2, 3, 5, 4
5, 4, 3, 2
"""

    if BALLOTS_FILE:
        election = load_election(BALLOTS_FILE)
        csv_input = election["ballots"]

        # Helpful note when the optional key components are omitted (they have
        # safe defaults, so this is informational, not an error).
        _defaulted = []
        if not election.get("method_name"):
            _defaulted.append("voting_method: STAR")
        if election.get("seats") is None:
            _defaulted.append("num_winners: 1")
        if _defaulted:
            print(f"{COLOR_DIM}Note: {' and '.join(_defaulted)} not set in the file "
                  f"— using defaults.{COLOR_RESET}")

        # On-the-fly engine dispatch based on the declared voting_method (or the
        # ballot style), so one command routes STAR / RCV-IRV / Approval files.
        import difflib

        # One alias table, defined once near METHOD_BY_NAME — see
        # classify_method(). Approval is matched fuzzily (tolerant of typos like
        # "Arroval"); a name carrying "multi"/"single" must agree with
        # num_winners (checked below). Ranked Robin (= RCV-RR = Copeland =
        # Consensus Voting) counts the SAME ranked ballot as RCV-IRV by
        # head-to-head wins, so it is dispatched before the IRV branch below.
        # Choose-One (Plurality) is an honest label for 0/1 single-mark ballots.
        _cls = classify_method(election.get("method_name"), csv_input)
        _mname = _cls["declared_lower"]
        _norm = _cls["normalized"]
        _is_rcv = _cls["is_rcv"]
        _is_approval = _cls["is_approval"]
        _is_rr = _cls["is_rr"]
        _is_plurality = _cls["is_plurality"]

        # An EXPLICIT voting_method must be one we recognize. Silently falling
        # back to STAR turned typos ("STARR", "Aproval") into wrong counts.
        if _mname and not _cls["known"]:
            _valid = ["STAR", "Approval", "RankedRobin", "RCV_IRV", "STV",
                      "Plurality", "Bloc STAR", "sss", "rrv", "allocated"]
            _close = difflib.get_close_matches(
                _norm, [v.lower() for v in _valid], n=1, cutoff=0.6)
            _hint = ""
            if _close:
                _hint = ("  Did you mean '"
                         + next(v for v in _valid if v.lower() == _close[0])
                         + "'?\n")
            print(
                f"{COLOR_RED}Error: unknown voting_method "
                f"'{election.get('method_name')}'.{COLOR_RESET}\n" + _hint +
                f"  Valid values: {' | '.join(_valid)}"
            )
            sys.exit(1)

        # Ranked ballots ("A>C>B") can only be RCV-IRV, regardless of the label.
        # Check for ">" only in the ballot data, NOT in trailing "# comments"
        # (which may legitimately contain "->" arrows).
        _ballot_body = "\n".join(
            ln.split("#")[0] for ln in (csv_input or "").splitlines()
        )
        _ranked_ballots = ">" in _ballot_body

        # --- ballot-shape sanity (catches common hand-authoring mistakes) ----
        _body_rows = [r.strip() for r in _ballot_body.splitlines() if r.strip()]
        if _ranked_ballots:
            # Mixed styles: ranked rows alongside comma-separated score rows
            # would otherwise route to RCV-IRV and invent phantom "candidates".
            _mixed = [r for r in _body_rows if ">" not in r and "," in r]
            if _mixed:
                print(
                    f"{COLOR_RED}Error: mixed ballot styles — this file has ranked "
                    f"rows ('A>B>C') AND comma-separated rows.{COLOR_RESET}\n"
                    "  Offending row(s):"
                )
                for r in _mixed[:5]:
                    print(f"    {r}")
                print("  Use ONE style: either every row ranked (RCV-IRV / Ranked "
                      "Robin),\n  or a score grid (header row of names, then 0..5 "
                      "scores) under a score method.")
                sys.exit(1)
            _cand_pool = set()
            for r in _body_rows:
                r = re.sub(r"^\d+\s*[:xX×]\s*", "", r)
                for grp in r.split(">"):
                    for nm in grp.split("="):
                        if nm.strip():
                            _cand_pool.add(nm.strip())
        else:
            _hdr = re.sub(r"(?i)^count\s*[:xX×]\s*", "", _body_rows[0]) if _body_rows else ""
            _hdr_names = [n.strip() for n in _hdr.split(",") if n.strip()]
            _cand_pool = set(_hdr_names)
            _dupes = sorted({n for n in _hdr_names if _hdr_names.count(n) > 1})
            if _dupes:
                print(
                    f"{COLOR_RED}Error: duplicate candidate name(s) in the ballot "
                    f"header: {', '.join(_dupes)}.{COLOR_RESET}\n"
                    f"  Header: {_body_rows[0]}\n"
                    "  Every column needs a unique candidate name."
                )
                sys.exit(1)
            # Only for genuinely multi-winner methods — a single-winner method
            # with num_winners > 1 gets its own, more specific mismatch error.
            _seats_eff = election["seats"] or 1
            _multi_method = election["method"] is not None and \
                election["method"] is not starvote.star
            if _multi_method and _seats_eff > 1 and _seats_eff >= len(_hdr_names):
                print(
                    f"{COLOR_RED}Error: cannot fill {_seats_eff} seats from "
                    f"{len(_hdr_names)} candidate(s).{COLOR_RESET}\n"
                    "  num_winners must be smaller than the number of candidates."
                )
                sys.exit(1)
        _lot = election.get("lot_numbers") or []
        _lot_unknown = [n for n in _lot if n not in _cand_pool]
        if _lot and _lot_unknown:
            print(
                f"{COLOR_RED}Error: lot_numbers name(s) not on the ballot: "
                f"{', '.join(_lot_unknown)}.{COLOR_RESET}\n"
                f"  Ballot candidates: {', '.join(sorted(_cand_pool))}\n"
                "  A typo here would silently corrupt the official tie-break "
                "order, so it is an error."
            )
            sys.exit(1)

        # A file that EXPLICITLY declares a score method (STAR, Approval, Bloc /
        # Proportional STAR, …) but supplies RANKED ballots ("A>C>B") is self-
        # contradictory: score methods need 0–5 scores; ranked ballots are an
        # RCV-IRV input. Flag the mismatch instead of silently switching engines.
        # (Ranked Robin / RCV-RR is itself a ranked method, so it's exempt.)
        # (No voting_method declared + ranked ballots still auto-routes to RCV-IRV.)
        if _ranked_ballots and _mname and not _is_rcv and not _is_rr:
            print(
                f"Error: voting_method '{election.get('method_name')}' is a "
                "score-ballot method, but the ballots are ranked (e.g. 'A>C>B').\n"
                "       Score methods (STAR, Approval, Bloc / Proportional STAR) "
                "need 0–5 scores; ranked ballots are tabulated by RCV-IRV.\n"
                "       Fix the mismatch: set 'voting_method: RCV_IRV', or rewrite "
                "the ballots as scores (header + rows, e.g. 'A,B,C' then '5,4,0')."
            )
            sys.exit(1)

        # Ranked Robin / RCV-RR comes BEFORE the RCV-IRV branch: a file labeled
        # Ranked Robin has ranked ballots too, but must be counted round-robin.
        if _is_rr:
            _rr_opts = dict(election.get("options") or {})
            if FULL_RENDER:
                # --full: the mirror's render on screen (matrix + Smith block).
                _rr_opts.update(show_matrix=True, show_smith_set=True,
                                collapse_ballots=True)
            run_ranked_robin(csv_input, BALLOTS_FILE,
                             lot_numbers=election.get("lot_numbers"),
                             options=_rr_opts,
                             num_winners=(election.get("seats") or 1))
            sys.exit(0)

        # Choose-One / Plurality gets its own report at BOTH seat counts:
        # multi-winner = SNTV / Bloc Plurality (top-N first choices); single-winner
        # = count the marks, most marks wins. (Single-winner used to fall through
        # to the STAR path — arithmetically equivalent, but it printed a Scoring
        # Round and an Automatic Runoff that choose-one simply does not have.)
        if _is_plurality:
            if (election.get("seats") or 1) > 1:
                run_plurality_multi(csv_input, BALLOTS_FILE,
                                    lot_numbers=election.get("lot_numbers"),
                                    num_winners=election.get("seats") or 1)
            else:
                run_plurality_single(csv_input, BALLOTS_FILE,
                                     lot_numbers=election.get("lot_numbers"))
            sys.exit(0)

        if _is_rcv or _ranked_ballots:
            if _IRV_AVAILABLE:
                import rcv_irv_tabulation
                # Capture the report so it both echoes AND writes the standard
                # '<folder>_tabulated' mirror (house rule: every tabulated YAML
                # gets a mirror; this path was the last one missing it).
                import contextlib as _ctx
                import io as _io
                _buf = _io.StringIO()
                _transfers = None
                try:
                    with _ctx.redirect_stdout(_buf):
                        # extras=False: the transfer/inactive block is RETURNED
                        # for the always-full mirror, not printed to screen.
                        _transfers = rcv_irv_tabulation.run(BALLOTS_FILE)
                except SystemExit:
                    sys.stdout.write(_buf.getvalue())  # don't swallow errors
                    raise
                _out = _buf.getvalue()
                sys.stdout.write(_out)
                # Two analyses ride the MIRROR only, so the on-screen echo stays
                # minimal (house rule) while the `_tabulated` copy renders
                # maximum info. `--full` puts both on screen.
                #   1. Where the votes went + how many stopped counting: the two
                #      numbers pyrankvote's round tables never print.
                #   2. The Smith set. RCV-IRV is not Smith-efficient, so this is a
                #      genuine pass/fail: did the eliminations walk out of the set
                #      that collectively beats everyone else? The winner is read
                #      back from the vendored engine's own report rather than
                #      recomputed, so the two can never contradict each other on a
                #      tie-break.
                _extras = []
                if _transfers:
                    _extras.append(_transfers)
                try:
                    _cands, _bals, _, _ = ballots_for_pairwise(csv_input)
                    _wnames = []
                    _lines = _out.splitlines()
                    for _i, _ln in enumerate(_lines):
                        if _ln.startswith("Winner(s) —"):
                            for _nxt in _lines[_i + 1:]:
                                if not _nxt.strip():
                                    break
                                _wnames.append(_nxt.strip())
                            break
                    _blk = format_smith_set(
                        _cands, calculate_preference_matrix(_cands, _bals),
                        winner=_wnames[0] if len(_wnames) == 1 else None,
                        method_label="RCV-IRV", smith_efficient=False)
                    if _blk:
                        _extras.append("\n".join(_blk))
                except Exception:
                    pass
                _mirror = _out
                if _extras:
                    _mirror = (_out.rstrip("\n") + "\n\n"
                               + "\n\n".join(_extras) + "\n")
                    if FULL_RENDER:
                        sys.stdout.write("\n" + "\n\n".join(_extras) + "\n")
                try:
                    write_composed_tabulated(BALLOTS_FILE, _mirror)
                except Exception:
                    pass
                sys.exit(0)
            print("Error: this file needs the RCV-IRV engine, but it could not "
                  "be imported (RCV_IRV_tabulation_engine missing?).")
            sys.exit(1)

        if _is_approval:
            _seats = election["seats"] if election["seats"] is not None else 1
            _raw = election.get("method_name")
            # Plain "Approval" means SINGLE winner. Multi-winner must be opted
            # into explicitly (Approval_Multi_Winner / block / *_mw).
            _wants_multi = ("multi" in _norm or "block" in _norm
                            or _norm.endswith("_mw"))

            # The method name must not contradict num_winners.
            if _wants_multi and _seats < 2:
                print(f"{COLOR_RED}Error: voting_method '{_raw}' is multi-winner, "
                      f"but num_winners is {_seats}.{COLOR_RESET}\n"
                      f"  Set num_winners >= 2, or use voting_method: Approval "
                      f"(single winner).")
                sys.exit(1)
            if not _wants_multi and _seats > 1:
                print(f"{COLOR_RED}Error: voting_method '{_raw}' is single-winner, "
                      f"but num_winners is {_seats}.{COLOR_RESET}\n"
                      f"  For multiple seats, use voting_method: "
                      f"Approval_Multi_Winner; otherwise set num_winners: 1.")
                sys.exit(1)

            if _norm not in {"approval", "approve", "av", "approval_voting",
                             "approval_single_winner", "approval_multi_winner"}:
                print(f"(Interpreting voting_method '{_raw}' as Approval.)")
            # Capture the report so it both echoes on screen AND writes the
            # standard '<folder>_tabulated' mirror (same composed format as
            # the STAR path: provenance header + original file + results).
            import contextlib as _ctx
            import io as _io
            _file_opts = dict(election.get("options") or {})
            if FULL_RENDER:
                # --full: the mirror's render on screen (co-approval matrix on).
                _file_opts.update(show_matrix=True, collapse_ballots=True)
            # On-screen render honors the file's own options (co-approval matrix
            # only if it sets show_matrix — house "less is more" default).
            _buf = _io.StringIO()
            try:
                with _ctx.redirect_stdout(_buf):
                    tabulate_approval(csv_input, seats=_seats,
                                      priority=election.get("lot_numbers"),
                                      options=_file_opts)
            except SystemExit:
                sys.stdout.write(_buf.getvalue())  # don't swallow error text
                raise
            sys.stdout.write(_buf.getvalue())
            # The '_tabulated' mirror ALWAYS renders full detail: force the
            # co-approval matrix on regardless of the file's options.
            _full_opts = dict(_file_opts)
            _full_opts["show_matrix"] = True
            _full_opts["collapse_ballots"] = True
            _mbuf = _io.StringIO()
            try:
                with _ctx.redirect_stdout(_mbuf):
                    tabulate_approval(csv_input, seats=_seats,
                                      priority=election.get("lot_numbers"),
                                      options=_full_opts)
            except SystemExit:
                _mbuf.write("")  # a validation error already surfaced on screen
            try:
                write_composed_tabulated(BALLOTS_FILE, _mbuf.getvalue())
            except Exception:
                pass
            sys.exit(0)

        if election["seats"] is not None:
            SEATS = election["seats"]
        if election["method"] is not None:
            METHOD = election["method"]

        # Auto-adjusted defaults (before the file's own options, which still
        # win): the preference matrix is a SINGLE-WINNER concept — a Bloc/PR
        # report with a "Top 2 Finalist" grid is misleading — and with only
        # two candidates it merely echoes the runoff, so both cases default
        # the matrix off. A file can still force it with `show_matrix: true`,
        # and the always-full mirror / `--full` render always carries it —
        # for seats > 1 those render the grid as plain head-to-head data with
        # no finalist markers (see print_matrix).
        if SEATS > 1 or len(_hdr_names) == 2:
            SHOW_MATRIX = False
            MATRIX_FINALISTS_ONLY = False

        # A YAML `options:` block can override the display flags. Example:
        #   options:
        #     show_matrix: true
        #     show_score_counts: true
        #     brief: false
        #     count_separator: ":"
        def _as_bool(v):
            # Accept booleans plus common truthy spellings, including the short
            # "t" / "y". Anything else (including "f", "false", "n") is False.
            return (
                v
                if isinstance(v, bool)
                else str(v).strip().lower()
                in (
                    "1",
                    "true",
                    "t",
                    "yes",
                    "y",
                    "on",
                )
            )

        opts = election["options"]
        if "show_matrix" in opts:
            SHOW_MATRIX = _as_bool(opts["show_matrix"])
        if "matrix_finalists_only" in opts:
            MATRIX_FINALISTS_ONLY = _as_bool(opts["matrix_finalists_only"])
        if "show_condorcet" in opts:
            SHOW_CONDORCET = _as_bool(opts["show_condorcet"])
        if "show_irv" in opts:
            SHOW_IRV = _as_bool(opts["show_irv"])
        if "show_description" in opts:
            SHOW_DESCRIPTION = _as_bool(opts["show_description"])
        if "show_score_counts" in opts:
            SHOW_SCORE_COUNTS = _as_bool(opts["show_score_counts"])
        if "show_runoff_percent" in opts:
            SHOW_RUNOFF_PERCENT = _as_bool(opts["show_runoff_percent"])
        if "brief" in opts:
            BRIEF = _as_bool(opts["brief"])
        if "collapse_ballots" in opts:
            COLLAPSE_BALLOTS = _as_bool(opts["collapse_ballots"])
        if "count_separator" in opts:
            COUNT_SEPARATOR = str(opts["count_separator"])

        # Honor an official tie-breaking lot order declared in the file (e.g.
        # carried over from BetterVoting's `perm`). Falls back to the empty
        # default — CSV column order — when the file doesn't supply one.
        if election.get("lot_numbers"):
            LOT_NUMBERS = election["lot_numbers"]

    run_kwargs = dict(
        show_matrix=SHOW_MATRIX,
        matrix_finalists_only=MATRIX_FINALISTS_ONLY,
        brief=BRIEF,
        seats=SEATS,
        method=METHOD,
        show_condorcet=SHOW_CONDORCET,
        show_score_counts=SHOW_SCORE_COUNTS,
        show_runoff_percent=SHOW_RUNOFF_PERCENT,
        collapse_ballots=COLLAPSE_BALLOTS,
        count_separator=COUNT_SEPARATOR,
        show_irv=SHOW_IRV,
        title=(election["title"] if BALLOTS_FILE else None),
        description=(election["description"] if BALLOTS_FILE else None),
        eligible_voters=(election["eligible_voters"] if BALLOTS_FILE else None),
        quorum=(election["quorum"] if BALLOTS_FILE else None),
        blocs=(election["blocs"] if BALLOTS_FILE else None),
        show_description=SHOW_DESCRIPTION,
        # Label Choose-One/Plurality honestly even though it runs via the STAR path.
        display_method_name=("Choose-One / Plurality"
                             if locals().get("_is_plurality") else None),
    )

    if BALLOTS_FILE:
        # Keep the file's trailing '# file:' comment in sync with its name
        # (only writes if missing/stale).
        ensure_filename_comment(BALLOTS_FILE)

        # Capture the output so we can both display it and write a plain-text
        # '_tabulated' copy (and, with --save, embed results into the YAML).
        import contextlib
        import io

        def _capture(kwargs):
            b = io.StringIO()
            try:
                with contextlib.redirect_stdout(b):
                    w = run_election(csv_input, LOT_NUMBERS,
                                     src_path=BALLOTS_FILE, **kwargs)
            except SystemExit:
                # run_election bailed out (e.g. a method/seats mismatch error).
                # Flush what it printed so the message isn't swallowed, then
                # propagate the exit code.
                sys.stdout.write(b.getvalue())
                raise
            return w, b.getvalue()

        # On-screen render: the global defaults, adjusted by the file's own
        # options — or the everything-on mirror render when --full was passed.
        if FULL_RENDER:
            run_kwargs.update(FULL_RENDER_OVERRIDES)
        winners, out = _capture(run_kwargs)
        sys.stdout.write(out)  # display on screen

        # The saved '_tabulated' file ALWAYS uses the full, most explanatory
        # render (every analysis on, maximum verbosity) regardless of the file's
        # own options — only the on-screen echo above honors those options.
        full_kwargs = dict(run_kwargs)
        full_kwargs.update(FULL_RENDER_OVERRIDES)
        _, file_out = _capture(full_kwargs)

        # The '_tabulated' file is the ORIGINAL election file copied as-is,
        # followed by the tabulation results (see write_composed_tabulated).
        write_composed_tabulated(BALLOTS_FILE, file_out)

        if SAVE_RESULTS:
            names = winners if isinstance(winners, (list, tuple)) else [winners]
            save_results_to_file(BALLOTS_FILE, [str(w) for w in names], out)
            print(f"\n{COLOR_HEADER}[saved results to {BALLOTS_FILE}]{COLOR_RESET}")
    else:
        run_election(csv_input, LOT_NUMBERS, src_path=BALLOTS_FILE, **run_kwargs)
