diff --git a/.gitignore b/.gitignore index cd12f255..8d7b32de 100644 --- a/.gitignore +++ b/.gitignore @@ -159,7 +159,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ .DS_Store docs/.DS_Store .DS_Store @@ -171,4 +171,4 @@ docs/.DS_Store .DS_Store .DS_Store docs/.DS_Store -docs/.DS_Store +docs/.DS_Store \ No newline at end of file diff --git a/strategic_voting/strategic_voting_algorithms.py b/strategic_voting/strategic_voting_algorithms.py new file mode 100644 index 00000000..51c2f4d0 --- /dev/null +++ b/strategic_voting/strategic_voting_algorithms.py @@ -0,0 +1,664 @@ +""" +Implementation of algorithms from: + "Strategic Voting in the Context of Negotiating Teams", + Leora Schmerler & Noam Hazon (2021) – https://arxiv.org/abs/2107.14097 + +Programmer: Elyasaf Kopel +Last revised: 18 May 2025 + +The module provides two functions: + + algorithm1_single_voter ─ C-MaNego (single manipulator) + algorithm2_coalitional ─ CC-MaNego (coalition of k manipulators) + +Both decide whether a preferred outcome `p` can be made the unique +sub-game perfect equilibrium (SPE) of a VAOV negotiation game. + +Changes compared with the paper +------------------------------- +* `check_validation` encapsulate the common “sanity checks.” +* `_rc_result` models the Rational-Compromise (Bucklin-style) outcome + and returns ``None`` whenever the first intersection is not singleton; + this is enough because a manipulator must guarantee *uniqueness*. + +""" +from __future__ import annotations +import logging, math +from typing import Callable, List, Optional, Sequence, Set, Tuple, Union + +try: + # Only present if the user has pref_voting installed + from pref_voting.profiles import Profile as Profile +except ImportError: # tests or minimal env + class Profile: # fake placeholder so isinstance() works + pass + +# ───────────────────────────── logging ─────────────────────────────────── +# 4 levels we actually care about in this module: +# INFO – high-level algorithm steps +# DEBUG – detailed steps +# WARNING – guards +# ERROR – unexpected errors +logger = logging.getLogger(__name__) +logging.disable(logging.CRITICAL) # disable all logging by default + +def _setup_logging(detailed: bool = False) -> None: + """ + Attach a single console handler to this module’s logger. + + Parameters + ---------- + detailed : bool, optional + If True, use a verbose timestamped format + ("%Y-%m-%d %H:%M:%S ..."); otherwise, use level-only format. + + Returns + ------- + None + """ + fmt = ( + "%(asctime)s %(levelname)-8s %(name)s:%(lineno)d %(message)s" + if detailed + else "%(levelname)s: %(message)s" + ) + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter(fmt)) + handler.setLevel(logging.INFO) # tweak as you wish + logger.setLevel(logging.DEBUG) + logger.addHandler(handler) + logger.propagate = False + +# ──────────────────────────────────────────────────────────────── +def _explode_profile(profile: Union[Profile, Sequence[Sequence[str]]]) -> List[List[str]]: + """ + Normalize a Profile or raw ballots into an explicit list of ballots. + + Parameters + ---------- + profile : Profile or Sequence of Sequence of str + - If a Profile, each ranking is replicated by its count. + - If a raw sequence, it is assumed to already be a list of ballots. + + Returns + ------- + List[List[str]] + Expanded list of ballots. + + • If already a List[List[str]] → return it unchanged (cheap pointer copy). + • If a Profile → replicates each unique ranking according to its count vector; + if `profile.counts` is missing or None, assume 1 voter per ranking. + """ + if isinstance(profile, Profile): + # try to grab counts; default to [1, 1, …] if missing or None + raw_counts = getattr(profile, 'counts', None) + counts = raw_counts if raw_counts is not None else [1] * len(profile.rankings) + + return [ + ballot + for ballot, n in zip(profile.rankings, counts) + for _ in range(n) + ] + + # if it's already a sequence of ballots + return [list(b) for b in profile] + +# ──────────────────── 0. built-in social-welfare rules ─────────────────── +def borda(profile: List[List[str]]) -> List[str]: + """ + Compute the Borda count ranking for a list of ballots. + + Parameters + ---------- + profile : List of List of str + Each inner list is a ballot ranking candidates. + + Returns + ------- + List[str] + Candidates sorted from highest to lowest Borda score. + + >>> borda([["C", "A", "B"], ["B", "C", "A"], ["C", "A", "B"]]) + ['C', 'A', 'B'] + """ + if not profile: + logger.warning("Empty profile in Borda count") + return [] + + m = len(profile[0]) + scores = {c: 0 for c in profile[0]} + + for ballot in profile: + for pos, cand in enumerate(ballot): + scores[cand] += m - pos - 1 + + ranking = sorted(scores, key=lambda c: (-scores[c], c)) + + # ▲ log the full score vector in a stable, readable order + logger.info( + "[Borda] scores → %s", + [(c, scores[c]) for c in ranking] + ) + + return ranking + + +def make_x_approval(x: int) -> Callable[[List[List[str]]], List[str]]: + """ + Factory: x-approval rule (Plurality is x=1). + Create an x-approval social welfare function. + + Parameters + ---------- + x : int + Number of top positions on each ballot that earn one point. + + Returns + ------- + Callable[[List[List[str]]], List[str]] + A function that maps ballots to a full ranking. + + Raises + ------ + ValueError + If x is not a positive integer. + + >>> x_approval = make_x_approval(2) + >>> x_approval([["C", "A", "B"], ["B", "C", "A"], ["C", "A", "B"]]) + ['C', 'A', 'B'] + """ + if x <= 0: + logger.error("x must be a positive integer") + raise ValueError("x must be a positive integer") + + def rule(profile: List[List[str]]) -> List[str]: + if not profile: + logger.warning("Empty profile in x-approval count") + return [] + scores = {c: 0 for c in profile[0]} # initialize scores for all candidates to 0 + for ballot in profile: + for cand in ballot[:x]: + scores[cand] += 1 # increment score for each of the top x candidates + return sorted(scores, key=lambda c: (-scores[c], c)) + + rule.__name__ = f"x_approval_{x}" + return rule + +# ───────────────────── 1. shared helper utilities ──────────────────────── +def _pos(candidate: str, ranking: Sequence[str]) -> int: + """ + Compute paper‐style position of `candidate` in `ranking`. + + "the number of outcomes that o (candidate) is preferred over them in pi (ranking)." + + Parameters + ---------- + candidate + label of the candidate to look up. + ranking + a full ranking list with most‐to‐least preferred. + + Returns + ------- + int + position value (higher ⇒ better). + + >>> _pos("A", ["C", "B", "A"]) + 0 + """ + idx = ranking.index(candidate) + result = len(ranking) - 1 - idx + + logger.debug("[_pos] candidate=%r, index_in_ranking=%d, returned_position=%d",candidate, idx, result) + return result + +def _top_i(ranking: Sequence[str], i: int) -> List[str]: + """ + Return the first i candidates from a ranking. + + Parameters + ---------- + ranking : Sequence of str + Full ranking, most-to-least preferred. + i : int + Number of top candidates to select. + + Returns + ------- + List[str] + The top i candidates. + + >>> _top_i(["C", "B", "A"], 2) + ['C', 'B'] + """ + return list(ranking)[:i] + +# ──────────────────── Rational-Compromise helper ──────────────────────── +def _rc_result(pt: Sequence[str], po: Sequence[str]) -> Optional[str]: + """ + Compute the Rational-Compromise winner between two orderings. + VAOV-style intersection of top-i candidates. + Parameters + ---------- + pt : Sequence of str + Social welfare function ranking (team). + po : Sequence of str + Opponent’s ranking. + + Returns + ------- + Optional[str] + The singleton intersection winner, or None if none found. + + Prints a concise trace of the top-j intersections as it searches for the + first *singleton* intersection. Returns the winner (string) or None. + >>> _rc_result(["C", "A", "B"], ["B", "C", "A"]) + 'C' + """ + m = len(pt) + for j in range(1, m + 1): + inter = set(_top_i(pt, j)) & set(_top_i(po, j)) # intersection of top-j + logger.debug("[RC] depth j=%d, pt_top=%s, po_top=%s, intersection=%s",j, _top_i(pt, j), _top_i(po, j), sorted(inter)) + if inter: + if len(inter) == 1: + winner = next(iter(inter)) # the set has exactly one element, get it + logger.info("[RC] singleton intersection at j=%d ⇒ returning %r", j, winner) + return winner + else: + logger.info("[RC] intersection at j=%d is not singleton (%d items: %s) ⇒ returning None",j, len(inter), sorted(inter)) + return None + + logger.debug("[RC] no intersection found at any depth ⇒ returning None") + return None + +def _compute_Hi( + preferred: str, + i: int, + pt: Sequence[str], + opponent_order: Sequence[str], +) -> List[str]: + """ + Build a depth-i candidate set Hᵢ. + + Parameters + ---------- + preferred : str + The candidate we aim to promote. + i : int + Depth parameter (1 ≤ i ≤ m). + pt : Sequence of str + Honest team’s SWF ranking. + opponent_order : Sequence of str + Opponent’s full ranking. + + Returns + ------- + List[str] + A list of up to i candidates: starts with `preferred`, then the next + best in `pt` not in opponent’s top-i. + + Hᵢ = {preferred} ∪ (i-1 best in pt not in Aᵢ(po)), keeping pt order. + Aᵢ(po) = top-i candidates in po (opponent) order. + >>> team_profile = [ + ... ["p", "c", "a", "b"], + ... ["p", "b", "a", "c"], + ... ["b", "p", "a", "c"], + ... ["b", "a", "c", "p"], + ... ] + >>> pt = borda(team_profile) + >>> _compute_Hi("p", 2, pt, ["b", "p", "a", "c"]) + ['p', 'a'] + """ + Ai_po: Set[str] = set(_top_i(opponent_order, i)) + logger.debug("[_compute_Hi] i=%d, opponent_top_i=%s", i, sorted(Ai_po)) + H: List[str] = [preferred] + logger.debug("[_compute_Hi] initially H = %s", H) + + for c in pt: + if len(H) == i: # already have i candidates + break + + if c != preferred and c not in Ai_po: # not in opponent's top-i + H.append(c) + logger.debug("[_compute_Hi] adding %r to H (size now %d)", c, len(H)) + + logger.debug("[_compute_Hi] final H_i = %s for preferred=%r and i=%d", H, preferred, i) + return H + +def check_validation(opp: List[str], preferred: str, m: int) -> bool: + """ + Guard: ensure opponent ranks `preferred` high enough for manipulation, if not, manipulation is impossible. + + Parameters + ---------- + opp : List of str + Opponent’s ranking. + preferred : str + Candidate to check. + m : int + Total number of candidates. + + Returns + ------- + bool + True if `preferred` is ranked at or above ceil(m/2); otherwise False. + + >>> check_validation(["A", "B", "C"], "A", 3) + True + >>> check_validation(["C", "B", "A"], "A", 2) + False + """ + if not opp: + logger.debug("[check_validation] opponent profile is empty ⇒ returning False") + return False + + # Compute the paper‐style “position” (higher ⇒ better for the opponent) + pos_val = _pos(preferred, opp) + threshold = math.ceil(m / 2) + + logger.debug("[check_validation] preferred=%r, pos_val=%d, threshold=%d, m=%d",preferred, pos_val, threshold, m) + + if pos_val < threshold: + logger.warning("Preferred candidate ranked too low by opponent – manipulation impossible.") + return False + + return True + +# ─────────────── Algorithm 1 – single-voter manipulation ──────────────── +def algorithm1_single_voter( + F: Callable[[List[List[str]]], List[str]], # social-welfare function + team_profile : Union[List[List[str]], Profile], # honest team ballots + opponent_order: Union[List[str],List[int]], # opponent ranking + preferred : Union[str,int], # preferred candidate +) -> Tuple[bool, Optional[List[str]]]: + """ + Single-voter manipulation: find a ballot that makes `preferred` the unique RC winner. + + Parameters + ---------- + F: Callable[[List[List[str]]], List[str]] + Social welfare function. + team_profile : List of List of str or Profile + Honest team ballots or Profile object. + opponent_order : List of int or List of str + Opponent’s ranking. + preferred : str or int + Candidate to manipulate for. + + Returns + ------- + Tuple[bool, Optional[List[str]]] + (success, manipulative_ballot) or (False, None). + + >>> algorithm1_single_voter(borda,[["b", "a", "p"]], ["b", "a", "p"], "p") + (False, None) + >>> algorithm1_single_voter(borda,[["p", "c", "a", "b"],["p", "b", "a", "c"],["b", "p", "a", "c"],["b", "a", "c", "p"],], ["b", "p", "a", "c"], "p") + (True, ['a', 'p', 'c', 'b']) + """ + if not opponent_order or not team_profile: + logger.warning("[Alg-1] empty team profile or opponent ranking – exit") + return False, None + + team_profile = _explode_profile(team_profile) + + logger.info("\n[Alg-1] =========================================================") + logger.info("[Alg-1] opponent order : %s", opponent_order) + logger.info("[Alg-1] preferred : %r", preferred) + logger.info("[Alg-1] team profile (%d voters): %s", len(team_profile), team_profile) + + m = len(opponent_order) + if not check_validation(opponent_order, preferred, m): + logger.warning("[Alg-1] guard failed – manipulation impossible") + return False, None + + # SWF order of the honest team + pt = F(team_profile) + logger.info("[Alg-1] SWF order (pt) : %s", pt) + + # iterate i = 1 … ⌈m/2⌉ + for i in range(1, math.ceil(m / 2) + 1): + logger.info("\n[Alg-1] ----- depth i = %d -----", i) + + Hi = _compute_Hi(preferred, i, pt, opponent_order) + logger.info("[Alg-1] H_i = %s (size %d vs required %d)", Hi, len(Hi), i) + if len(Hi) < i: + logger.info("[Alg-1] › not enough candidates – skip depth") + continue + + # build manipulator ballot: high block then low block (both reversed) + hi_block = list(reversed([c for c in pt if c in Hi])) + lo_block = list(reversed([c for c in pt if c not in Hi])) + pa = hi_block + lo_block + + logger.info("[Alg-1] hi_block = %s", hi_block) + logger.info("[Alg-1] lo_block = %s", lo_block) + logger.info("[Alg-1] ballot (pa) = %s", pa) + + # test if ‘preferred’ becomes the unique RC winner + rc = _rc_result(F(team_profile + [pa]), opponent_order) + if rc == preferred: + logger.info("[Alg-1] ✅ success – manipulation ballot found") + return True, pa + + logger.info("[Alg-1] ✘ depth failed – trying next i") + + logger.info("[Alg-1] ❌ no successful manipulation found") + return False, None + +# ───────── Algorithm 2 – coalition of k manipulators (CC-MaNego) ───────── +def algorithm2_coalitional( + F: Callable[[List[List[str]]], List[str]], + team_profile : Union[List[List[str]], Profile], + opponent_order: Union[List[str],List[int]], + preferred : Union[str,int], + k : int, +) -> Tuple[bool, Optional[List[List[str]]]]: + """ + Coalition manipulation of size k: find ballots that make `preferred` the unique RC winner. + + Parameters + ---------- + F: Callable[[List[List[str]]], List[str]] + Social welfare function. + team_profile : List of List of str or Profile + Honest team ballots or Profile object. + opponent_order : List of int or List of str + Opponent’s ranking. + preferred : str or int + Candidate to manipulate for. + k : int + Number of manipulators. + + Returns + ------- + Tuple[bool, Optional[List[List[str]]]] + (success, list_of_ballots) or (False, None). + + >>> algorithm2_coalitional(borda, [], ["a", "b", "c", "p"], "p", k=2) + (False, None) + >>> algorithm2_coalitional(borda, [["p", "d", "a", "b", "c", "e"],["a", "p", "b", "c", "d", "e"],["b", "c", "a", "p", "d", "e"],], ["a", "p", "b", "c", "d", "e"], "p", k=2) + (True, [['p', 'e', 'd', 'c', 'b', 'a'], ['p', 'e', 'd', 'c', 'b', 'a']]) + + # Example from the paper + >>> algorithm2_coalitional(borda, [["c", "a", "p", "b"],["a", "b", "c", "p"],["a", "b", "c", "p"],["a", "b", "p", "c"],], ["p", "b", "a", "c"], "p", k=2) + (True, [['p', 'a', 'c', 'b'], ['p', 'a', 'c', 'b']]) + """ + if k <= 0: + logger.warning("[Alg-2] k=0 manipulators – impossible by definition") + return False, None + if not opponent_order: + logger.warning("[Alg-2] empty opponent ranking – exit") + return False, None + + team_profile = _explode_profile(team_profile) + m = len(opponent_order) + + if not check_validation(opponent_order, preferred, m): + logger.warning("[Alg-2] opponent ranks 'p' too low ⇒ manipulation impossible") + return False, None + + first_round = True + while True: + logger.info("\n[Alg-2] =========================================================") + logger.info(f"[Alg-2] opponent order : {opponent_order}") + logger.info(f"[Alg-2] preferred : '{preferred}'") + logger.info(f"[Alg-2] team profile ({len(team_profile)} voters): {team_profile}") + logger.info(f"[Alg-2] coalition size k = {k}") + + # ── SWF order of the honest team ─────────────────────────────────── + pt = F(team_profile) + logger.info(f"[Alg-2] SWF order before coalition: {pt}") + + # ── iterate depths i = 1 … ⌈m/2⌉ ────────────────────────────────── + for i in range(1, math.ceil(m / 2) + 1): + logger.info(f"\n[Alg-2] ===== depth i = {i} =====") + Hi = _compute_Hi(preferred, i, pt, opponent_order) + logger.info(f"[Alg-2] H_i = {Hi} (size {len(Hi)} vs required {i})") + if len(Hi) < i: + logger.info("[Alg-2] › not enough candidates – skip depth") + continue + + pm: List[List[str]] = [] + + # ── construct ballots l = 1 … k with the *same* Hᵢ ──────────── + for l in range(1, k + 1): + cur_pt = F(team_profile + pm) + logger.info(f"[Alg-2] manipulator #{l} sees SWF: {cur_pt}") + + # top block (Hᵢ) – place the least-preferred first (rev order) + hi_block = list(reversed([c for c in cur_pt if c in Hi])) + # bottom block (O\Hᵢ) – place the most-preferred first + lo_block = list(reversed([c for c in cur_pt if c not in Hi])) + + pa = hi_block + lo_block + pm.append(pa) + + logger.info(f"[Alg-2] manipulator #{l} ballot = {pa}") + + if _rc_result(F(team_profile + pm), opponent_order) == preferred: + # ---------- second-round success notice (Borda only) ---------- + if not first_round: # we are in the k+1 retry + logger.warning( + "[Alg-2] ℹ️ k failed but k+1 succeeded – cannot be sure whether the original k was truly insufficient.") + logger.info("[Alg-2] ✅ success – coalition found") + return True, pm + + logger.info("[Alg-2] ✘ depth failed – trying next i") + + # =========== B O R D A k → k+1 fallback ==================== + if first_round and F.__name__ == "borda": + logger.info("[Alg-2] k=%d failed under Borda – retrying with k+1.", k) + first_round = False # second (and last) pass + k += 1 + continue # rerun the while-loop once + # =========== end of Borda fallback ============================ + + # reached only if first round failed and either: + # – it wasn’t Borda, or + # – Borda fallback also failed + if not first_round: # we **did** try k+1 and still failed + logger.info("[Alg-2] ❌ k+1 also failed – logged for reference.") + else: # non-Borda rule failed once + logger.info("[Alg-2] ❌ no successful coalition manipulation found") + + return False, None + + +# ───────────────────────────── demo harness ────────────────────────────── +def main() -> None: + """ + Demo harness for C-MaNego (single manipulator) and CC-MaNego (coalition). + + This function sets up logging, runs two example profiles + through `algorithm1_single_voter` and `algorithm2_coalitional`, + and prints the outcomes via the module logger. + """ + _setup_logging(detailed=False) + # ###################################### + # 1. Algorithm 1 – single manipulator # + # ###################################### + logger.info("\n===== DEMO 1-A: C-MaNego (single voter) =====") + + team_profile_1 = [ + ["p", "c", "a", "b"], + ["p", "b", "a", "c"], + ["b", "p", "a", "c"], + ["b", "a", "c", "p"], + ] + opponent_order_1 = ["b", "p", "a", "c"] + preferred_1 = "p" + + ok_1, ballot_1 = algorithm1_single_voter( + borda, + team_profile_1, + opponent_order_1, + preferred_1, + ) + logger.info(f"Outcome : {ok_1}") + if ok_1: + logger.info(f"Manipulative ballot : {ballot_1}") + else: + logger.info("Manipulation impossible under this profile.") +""" # ------------------------------------------------------------------ + # 1-B. SAME EXAMPLE, Profile BUILT *AS IN* pref_voting DOCS (integers) + # ------------------------------------------------------------------ + logger.info("\n===== DEMO 1-B: C-MaNego with canonical integer Profile =====") + + ballots = [ + (0, 1, 2, 3), # 0>1>2>3 (p>c>a>b) + (0, 3, 2, 1), # 0>3>2>1 (p>b>a>c) + (3, 0, 2, 1), # 3>0>2>1 (b>p>a>c) + (3, 2, 1, 0), # 3>2>1>0 (b>a>c>p) + ] + counts = [1, 1, 1, 1] # one voter per ranking + + profile = Profile(ballots, rcounts=counts) + + opponent_order = [3, 0, 2, 1] # b p a c (IDs) + preferred = 0 # p + + ok, ballot = algorithm1_single_voter( + borda, + profile, + opponent_order, + preferred, + ) + + logger.info(f"Outcome : {ok}") + logger.info(f"Manipulative ballot : {ballot}" if ok + else "Manipulation impossible under this profile.") + # ###################################################### + # 2. Algorithm 2 – coalition of k manipulators (k = 2) # + # ###################################################### + logger.info("\n===== DEMO 2: CC-MaNego (coalition, k = 2) =====") + + team_profile_2 = [ + ["p", "d", "a", "b", "c", "e"], + ["a", "p", "b", "c", "d", "e"], + ["b", "c", "a", "p", "d", "e"], + ] + opponent_order_2 = ["a", "p", "b", "c", "d", "e"] + preferred_2 = "p" + k = 2 + + logger.info(f"Honest team profile : {team_profile_2}") + logger.info(f"Opponent ranking : {opponent_order_2}") + logger.info(f"Preferred candidate : '{preferred_2}', coalition size k = {k}") + + ok_2, ballots_2 = algorithm2_coalitional( + make_x_approval(2), + team_profile_2, + opponent_order_2, + preferred_2, + k, + ) + logger.info(f"Outcome : {ok_2}") + if ok_2: + for idx, b in enumerate(ballots_2, 1): + logger.info(f"Manipulator #{idx} ballot : {b}") + else: + logger.info("Coalitional manipulation impossible under this profile.")""" + + +# ------------------------------------------------------------------------- +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/strategic_voting/test_algorithms.py b/strategic_voting/test_algorithms.py new file mode 100644 index 00000000..3d139288 --- /dev/null +++ b/strategic_voting/test_algorithms.py @@ -0,0 +1,224 @@ +""" +Pytest unit tests for the Strategic-Voting algorithms. + +The suite is split into two sections: +1. Algorithm 1 – single-voter manipulation. +2. Algorithm 2 – coalitional manipulation. + +Tests that rely on functionality not yet implemented are marked +with @pytest.mark.xfail. +""" +from __future__ import annotations + +import math +import random + +import pytest +from strategic_voting_algorithms import ( + algorithm1_single_voter, + algorithm2_coalitional, + borda, + make_x_approval, +) +try: + from pref_voting.profiles import Profile # real class +except ImportError: + Profile = None # tests on a lean env + +# --------------------------------------------------------------------- +# Shared constants – paper’s “4 honest + 1 manipulator” example +TEAM_PROFILE_EXAMPLE = [ + ["p", "c", "a", "b"], # p>c>a>b + ["p", "b", "a", "c"], # p>b>a>c + ["b", "p", "a", "c"], # b>p>a>c + ["b", "a", "c", "p"], # b>a>c>p +] +OPPONENT_ORDER_EXAMPLE = ["b", "p", "a", "c"] # b>p>a>c +# --------------------------------------------------------------------- +# Algorithm 1 +# --------------------------------------------------------------------- + + +def test_alg1_borda_example(): + """Single-voter manipulation in the paper’s running example.""" + ok, vote = algorithm1_single_voter( + borda, TEAM_PROFILE_EXAMPLE, OPPONENT_ORDER_EXAMPLE, preferred="p" + ) + assert ok + assert vote == ["a", "p", "c", "b"] + + +def test_alg1_empty_input(): + ok, vote = algorithm1_single_voter(borda, [], [], "p") + assert not ok and vote is None + + +def test_alg1_threshold_guard(): + """pos(p) < ⌈m/2⌉ ⇒ manipulation impossible.""" + team_profile = [["b", "a", "p"]] # m = 3 + opponent_order = ["b", "a", "p"] # pos(p)=0 < ⌈3/2⌉=2 + ok, vote = algorithm1_single_voter(borda, team_profile, opponent_order, "p") + assert not ok and vote is None + + +def test_alg1_simple_case(): + """Paper’s 5-candidate example (4 honest + 1 manipulator).""" + ok, vote = algorithm1_single_voter( + borda, TEAM_PROFILE_EXAMPLE, OPPONENT_ORDER_EXAMPLE, "p" + ) + assert ok + assert vote == ["a", "p", "c", "b"] + + +def test_alg1_random_threshold_guard(): + """Randomized guard: pos(p) < ⌈m/2⌉ must fail.""" + for _ in range(20): + m = random.randint(3, 10) + candidates = [chr(ord("a") + i) for i in range(m)] + preferred = random.choice(candidates) + + pos = random.randint(math.ceil(m / 2), m - 1) + others = [c for c in candidates if c != preferred] + random.shuffle(others) + opponent_order = others.copy() + opponent_order.insert(pos, preferred) + + team_profile = [ + random.sample(candidates, k=m) for __ in range(random.randint(0, 5)) + ] + ok, _ = algorithm1_single_voter(borda, team_profile, opponent_order, preferred) + assert not ok + + +def test_alg1_random_output_shape(): + """If algorithm 1 succeeds, its ballot must be a permutation.""" + for _ in range(20): + m = random.randint(3, 8) + candidates = [chr(ord("a") + i) for i in range(m)] + preferred = random.choice(candidates) + opponent_order = random.sample(candidates, k=m) + team_profile = [ + random.sample(candidates, k=m) for __ in range(random.randint(0, 4)) + ] + + ok, vote = algorithm1_single_voter(borda, team_profile, opponent_order, preferred) + if ok: + assert sorted(vote) == sorted(candidates) + +@pytest.mark.skipif(Profile is None, reason="pref_voting not installed") +def test_alg1_accepts_pref_profile(): + # 4-candidate mapping: p=0, c=1, a=2, b=3 + ballots = [ + [0, 1, 2, 3], # p c a b + [0, 3, 2, 1], # p b a c + [3, 0, 2, 1], # b p a c + [3, 2, 1, 0], # b a c p + ] + counts = [1, 1, 1, 1] + + prof = Profile(ballots, counts) + + opponent_order = [3, 0, 2, 1] # b p a c (same code) + preferred = 0 # p + + ok, _ = algorithm1_single_voter(borda, prof, opponent_order, preferred) + assert ok + + +# --------------------------------------------------------------------- +# Algorithm 2 +# --------------------------------------------------------------------- + +def test_alg2_zero_manipulators(): + ok, prof = algorithm2_coalitional(borda, [], [], "p", k=0) + assert not ok and prof is None + + +def test_alg2_threshold_guard(): + team_profile: list[list[str]] = [] + opponent_order = ["a", "b", "c", "p"] + ok, votes = algorithm2_coalitional(borda, team_profile, opponent_order, "p", k=2) + assert not ok and votes is None + + +def test_alg2_simple_case_1(): + """CC-MaNego example with k=2 manipulators.""" + team_profile = [ + ["p", "d", "a", "b", "c", "e"], + ["a", "p", "b", "c", "d", "e"], + ["b", "c", "a", "p", "d", "e"], + ] + opponent_order = ["a", "p", "b", "c", "d", "e"] + ok, votes = algorithm2_coalitional(make_x_approval(2), team_profile, opponent_order, "p", k=2) + assert ok + assert votes == [["p", "e", "d", "c", "b", "a"], ["p", "e", "d", "c", "b", "a"]] + + +def test_alg2_simple_case_2(): + """X-approval (X=1) example with k=2 manipulators.""" + team_profile = [ + ["a", "p", "b", "c"], + ["b", "a", "c", "p"], + ] + opponent_order = ["p", "c", "b", "a"] + ok, votes = algorithm2_coalitional(make_x_approval(1), team_profile, opponent_order, "p", k=2) + assert ok + assert votes == [["p", "c", "b", "a"], ["p", "c", "b", "a"]] + + +def test_alg2_random_threshold_guard(): + for _ in range(20): + m = random.randint(3, 10) + candidates = [chr(ord("a") + i) for i in range(m)] + preferred = random.choice(candidates) + + pos = random.randint(math.ceil(m / 2), m - 1) + others = [c for c in candidates if c != preferred] + random.shuffle(others) + opponent_order = others.copy() + opponent_order.insert(pos, preferred) + + k = random.randint(1, min(4, len(candidates))) + team_profile = [ + random.sample(candidates, k=m) for __ in range(random.randint(0, 5)) + ] + + ok, votes = algorithm2_coalitional( + borda, team_profile, opponent_order, preferred, k + ) + assert not ok and votes is None + + +def test_alg2_random_output_shape(): + for _ in range(20): + m = random.randint(3, 7) + candidates = [chr(ord("A") + i) for i in range(m)] + preferred = random.choice(candidates) + opponent_order = random.sample(candidates, k=m) + k = random.randint(1, 3) + team_profile = [ + random.sample(candidates, k=m) for __ in range(random.randint(0, 4)) + ] + + ok, votes = algorithm2_coalitional( + borda, team_profile, opponent_order, preferred, k + ) + if ok: + assert isinstance(votes, list) and len(votes) == k + for v in votes: + assert sorted(v) == sorted(candidates) + + +def test_alg2_all_top_preferred(): + m = random.randint(3, 8) + others = list("abcdefgh")[: m - 1] + + team_profile = [ + ["p"] + random.sample(others, k=len(others)) + for _ in range(random.randint(1, 5)) + ] + opponent_order = ["p"] + random.sample(others, k=len(others)) + k = random.randint(2, 3) + ok, votes = algorithm2_coalitional(borda, team_profile, opponent_order, "p", k) + assert ok + assert votes is not None