"""Tallying: quorum, thresholds, and the official count. All arithmetic uses exact rational numbers (:class:`fractions.Fraction`) — no floating point anywhere near a ratification decision. A 2/3 supermajority means exactly ``yes / (yes + no) >= 2/3``, not ``>= 0.6666…``. Rules implemented here (Articles 4 and 5 as code): * **Quorum** counts every valid ballot, including abstentions: showing up to abstain is participation. ``valid / eligible >= quorum``. * **Threshold** is computed over yes+no only (abstentions don't break ties in either direction). A threshold of exactly 1/2 means *strict* majority (``yes > no``); any higher threshold means ``yes/(yes+no) >= threshold``. * **Re-votes**: only each voter's latest valid ballot counts; earlier ones are recorded as superseded, with full detail in the result. * Every invalid ballot is listed with its reason — the tally is an audit document, not just a number. """ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime from fractions import Fraction from typing import Any, Dict, List, Optional, Tuple from .ballots import ( Ballot, CHOICE_ABSTAIN, CHOICE_NO, CHOICE_YES, verify_ballot, ) from .canonical import iso, utcnow from .eligibility import Citizen, eligible_voters from .errors import TallyError from .proposal import Proposal def _frac_str(f: Fraction) -> str: return f"{f.numerator}/{f.denominator}" def parse_fraction(value: Any, what: str = "fraction") -> Fraction: """Parse '2/3', '0.5', 0.5, or Fraction into an exact Fraction in [0, 1].""" try: if isinstance(value, Fraction): f = value elif isinstance(value, str) and "/" in value: num, den = value.split("/", 1) f = Fraction(int(num.strip()), int(den.strip())) else: f = Fraction(str(value)) except (ValueError, ZeroDivisionError) as exc: raise TallyError(f"cannot parse {what} from {value!r}: {exc}") from exc if not (0 <= f <= 1): raise TallyError(f"{what} {value!r} must be between 0 and 1") return f @dataclass class TallyResult: proposal_id: str change_hash: str tallied_at: str eligible: int ballots_filed: int valid: int superseded: int yes: int no: int abstain: int quorum_required: Fraction participation: Fraction quorum_met: bool threshold_required: Fraction approval: Optional[Fraction] # None when yes + no == 0 threshold_met: bool passed: bool invalid_ballots: List[Dict[str, str]] = field(default_factory=list) counted_ballots: List[Dict[str, str]] = field(default_factory=list) def to_dict(self) -> Dict[str, Any]: """Serializable form: exact fractions as strings, floats for humans.""" return { "proposal_id": self.proposal_id, "change_hash": self.change_hash, "tallied_at": self.tallied_at, "eligible": self.eligible, "ballots_filed": self.ballots_filed, "valid": self.valid, "superseded": self.superseded, "yes": self.yes, "no": self.no, "abstain": self.abstain, "quorum_required": _frac_str(self.quorum_required), "participation": _frac_str(self.participation), "participation_pct": round(float(self.participation) * 100, 2), "quorum_met": self.quorum_met, "threshold_required": _frac_str(self.threshold_required), "approval": _frac_str(self.approval) if self.approval is not None else None, "approval_pct": ( round(float(self.approval) * 100, 2) if self.approval is not None else None ), "threshold_met": self.threshold_met, "passed": self.passed, "invalid_ballots": self.invalid_ballots, "counted_ballots": self.counted_ballots, } def tally( proposal: Proposal, ballots: List[Ballot], registry: Dict[str, Citizen], quorum: Fraction, threshold: Fraction, now: Optional[datetime] = None, ) -> TallyResult: """Compute the official tally for a proposal. Verification, deduplication, quorum, and threshold in one deterministic pass. The same inputs always produce the same result — the tally can be independently recomputed by anyone from the public proposal directory, ballot files, and registry. """ quorum = parse_fraction(quorum, "quorum") threshold = parse_fraction(threshold, "threshold") if threshold < Fraction(1, 2): raise TallyError( f"threshold {_frac_str(threshold)} is below 1/2 — a minority cannot " f"ratify an amendment under any classification" ) opens, _ = proposal.voting_window() electorate = eligible_voters(registry, opens) eligible = len(electorate) invalid: List[Dict[str, str]] = [] valid_by_voter: Dict[str, Ballot] = {} superseded = 0 for ballot in ballots: ok, reason = verify_ballot(ballot, proposal, registry) if not ok: invalid.append({ "voter": ballot.voter, "cast_at": ballot.cast_at, "reason": reason, }) continue existing = valid_by_voter.get(ballot.voter) if existing is None: valid_by_voter[ballot.voter] = ballot else: # Latest valid ballot wins; ties on cast_at resolve to the # lexicographically larger signature for determinism. if (ballot.cast_at, ballot.signature) > (existing.cast_at, existing.signature): valid_by_voter[ballot.voter] = ballot superseded += 1 yes = sum(1 for b in valid_by_voter.values() if b.choice == CHOICE_YES) no = sum(1 for b in valid_by_voter.values() if b.choice == CHOICE_NO) abstain = sum(1 for b in valid_by_voter.values() if b.choice == CHOICE_ABSTAIN) valid = len(valid_by_voter) participation = Fraction(valid, eligible) if eligible > 0 else Fraction(0) quorum_met = eligible > 0 and participation >= quorum decided = yes + no approval: Optional[Fraction] = Fraction(yes, decided) if decided > 0 else None if decided == 0: threshold_met = False elif threshold == Fraction(1, 2): threshold_met = yes > no # strict majority else: threshold_met = approval >= threshold passed = quorum_met and threshold_met counted = [ {"voter": b.voter, "choice": b.choice, "cast_at": b.cast_at} for b in sorted(valid_by_voter.values(), key=lambda b: b.voter) ] return TallyResult( proposal_id=proposal.id, change_hash=proposal.change_hash, tallied_at=iso(now or utcnow()), eligible=eligible, ballots_filed=len(ballots), valid=valid, superseded=superseded, yes=yes, no=no, abstain=abstain, quorum_required=quorum, participation=participation, quorum_met=quorum_met, threshold_required=threshold, approval=approval, threshold_met=threshold_met, passed=passed, invalid_ballots=invalid, counted_ballots=counted, )