diff --git a/schulze_condorcet/schulze_condorcet.py b/schulze_condorcet/schulze_condorcet.py index 53ba864..9feb5c9 100644 --- a/schulze_condorcet/schulze_condorcet.py +++ b/schulze_condorcet/schulze_condorcet.py @@ -4,10 +4,11 @@ Collection, Container, List, Mapping, Tuple, Sequence ) +from schulze_condorcet.util import as_vote_string, as_vote_tuples from schulze_condorcet.strength import winning_votes from schulze_condorcet.types import ( Candidate, DetailedResultLevel, LinkStrength, PairwisePreference, SchulzeResult, - VoteList, StrengthCallback, VoteString + StrengthCallback, VoteString ) @@ -43,27 +44,6 @@ def _schulze_winners(d: Mapping[Tuple[Candidate, Candidate], int], return winners -def _split_vote(vote: VoteString) -> VoteList: - """Split a vote string into its candidates.""" - return ( - tuple( - tuple( - Candidate(candidate) for candidate in level.split('=') - ) for level in vote.split('>') - ) - ) - - -def _split_votes(votes: Collection[VoteString]) -> List[VoteList]: - """Split a list of vote strings into their candidates.""" - return [_split_vote(vote) for vote in votes] - - -def _recombine_vote(vote: VoteList) -> VoteString: - """Construct a vote string from its candidates, opposite of _split_vote.""" - return VoteString(">".join("=".join(level) for level in vote)) - - def _check_consistency(votes: Collection[VoteString], candidates: Sequence[Candidate]) -> None: """Check that the given vote strings are consistent with the provided candidates. @@ -73,7 +53,7 @@ def _check_consistency(votes: Collection[VoteString], candidates: Sequence[Candi if any(">" in candidate or "=" in candidate for candidate in candidates): raise ValueError(_("A candidate contains a forbidden character.")) candidates_set = set(candidates) - for vote in _split_votes(votes): + for vote in as_vote_tuples(votes): vote_candidates = [c for c in itertools.chain.from_iterable(vote)] vote_candidates_set = set(vote_candidates) if candidates_set != vote_candidates_set: @@ -102,7 +82,7 @@ def _pairwise_preference( ) -> PairwisePreference: """Calculate the pairwise preference of all candidates from all given votes.""" counts = {(x, y): 0 for x in candidates for y in candidates} - for vote in _split_votes(votes): + for vote in as_vote_tuples(votes): for x in candidates: for y in candidates: if _subindex(vote, x) < _subindex(vote, y): @@ -183,7 +163,7 @@ def schulze_evaluate( _, result = _schulze_evaluate_routine(votes, candidates, strength) # Construct a vote string reflecting the overall preference - return _recombine_vote(result) + return as_vote_string(result) def schulze_evaluate_detailed( diff --git a/schulze_condorcet/types.py b/schulze_condorcet/types.py index e3cc6a3..fcf4800 100644 --- a/schulze_condorcet/types.py +++ b/schulze_condorcet/types.py @@ -1,5 +1,5 @@ from typing import ( - Dict, List, NewType, Protocol, Tuple, TypedDict, Sequence + Dict, List, NewType, Protocol, Tuple, TypedDict ) @@ -11,7 +11,9 @@ Candidate = NewType('Candidate', str) # A single vote, split into separate levels accordingly to (descending) preference. # All candidates at the same level (in the same inner tuple) have equal preference. -VoteList = Sequence[Sequence[Candidate]] +VoteTuple = Tuple[Tuple[Candidate, ...], ...] +# We accept VoteLists instead of VoteTuples for convenience. +VoteList = List[List[Candidate]] # How many voters prefer the first candidate over the second candidate. PairwisePreference = Dict[Tuple[Candidate, Candidate], int] # The link strength between two candidates. diff --git a/schulze_condorcet/util.py b/schulze_condorcet/util.py new file mode 100644 index 0000000..7775427 --- /dev/null +++ b/schulze_condorcet/util.py @@ -0,0 +1,61 @@ +"""Offer convenient functions to convert votes and candidates into proper types. + +This especially includes marking strings as Candidate or VoteString, and converting the +string-based representations of votes (like 'a>b=c=d>e') into the level-based +representations of votes (like [[a], [b, c, d], [e]]) and vice versa. +""" + +from typing import Collection, List, Sequence, Union + +from schulze_condorcet.types import Candidate, VoteList, VoteString, VoteTuple + + +def as_candidate(value: str) -> Candidate: + """Mark a string as candidate.""" + return Candidate(value) + + +def as_candidates(values: Sequence[str]) -> List[Candidate]: + """Mark a row of strings as candidates. + + We respect the order of the candidates, as this is also respected during evaluation + of the votes using the schulze method. + """ + return [as_candidate(value) for value in values] + + +def as_vote_string( + value: Union[VoteList, VoteTuple] +) -> VoteString: + """Convert a level-based representation of the vote into its string representation.""" + if isinstance(value, (list, tuple)): + return VoteString(">".join("=".join(level) for level in value)) + else: + raise NotImplementedError(value) + + +def as_vote_strings( + values: Union[Collection[VoteList], Collection[VoteTuple]] +) -> List[VoteString]: + """Convert each value into a string representation of the vote.""" + return [as_vote_string(value) for value in values] + + +def as_vote_tuple( + value: Union[str, VoteString] +) -> VoteTuple: + """Convert a string representation of a vote into its level-based representation.""" + return ( + tuple( + tuple( + Candidate(candidate) for candidate in level.split('=') + ) for level in value.split('>') + ) + ) + + +def as_vote_tuples( + values: Union[Collection[str], Collection[VoteString]] +) -> List[VoteTuple]: + """Convert string representations of votes into their level-based representations.""" + return [as_vote_tuple(value) for value in values] diff --git a/tests/test_schulze.py b/tests/test_schulze.py index 0a82622..f341770 100644 --- a/tests/test_schulze.py +++ b/tests/test_schulze.py @@ -4,6 +4,7 @@ import unittest from schulze_condorcet import schulze_evaluate, schulze_evaluate_detailed +import schulze_condorcet.util as util from schulze_condorcet.strength import margin, winning_votes from schulze_condorcet.types import Candidate, DetailedResultLevel as DRL, VoteString @@ -517,6 +518,35 @@ def test_result_order(self) -> None: condensed = schulze_evaluate(reference_votes, candidates) self.assertEqual("1=0>2", condensed) + def test_util(self) -> None: + candidates = ["1", "2", "3"] + # This does only static type conversion + self.assertEqual(util.as_candidates(candidates), candidates) + + # build the same votes, represented as string, list and tuple + vote_str_1 = "1" + vote_str_2 = "1=2>3" + vote_list_1 = [[Candidate("1")]] + vote_list_2 = [[Candidate("1"), Candidate("2")], [Candidate("3")]] + vote_tuple_1 = ((Candidate("1"),),) + vote_tuple_2 = ((Candidate("1"), Candidate("2")), (Candidate("3"),)) + vote_str_list = [vote_str_1, vote_str_2] + vote_list_list = [vote_list_1, vote_list_2] + vote_tuple_list = [vote_tuple_1, vote_tuple_2] + + self.assertEqual(vote_str_list, util.as_vote_strings(vote_list_list)) + self.assertEqual(vote_str_list, util.as_vote_strings(vote_tuple_list)) + + # mixing different representations of votes is confusing and therefore not + # recommended and forbidden by static type checking. + # However, we ensure the outcome is right nonetheless. + self.assertEqual( + 2*vote_str_list, + util.as_vote_strings([*vote_list_list, *vote_tuple_list]) # type:ignore + ) + + self.assertEqual(vote_tuple_list, util.as_vote_tuples(vote_str_list)) + if __name__ == '__main__': unittest.main()