sortition-algorithms 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,288 @@
1
+ import csv
2
+ import typing
3
+ from collections import defaultdict
4
+ from collections.abc import Iterable
5
+ from copy import deepcopy
6
+
7
+ from attrs import define
8
+
9
+ from sortition_algorithms import errors
10
+ from sortition_algorithms.features import FeatureCollection
11
+ from sortition_algorithms.people import People
12
+ from sortition_algorithms.settings import Settings
13
+ from sortition_algorithms.utils import random_provider
14
+
15
+
16
+ @define(kw_only=True, slots=True)
17
+ class MaxRatioResult:
18
+ """Result from finding the category with maximum selection ratio."""
19
+
20
+ feature_name: str
21
+ feature_value: str
22
+ random_person_index: int
23
+
24
+
25
+ class PeopleFeatures:
26
+ """
27
+ This class manipulates people and features together, making a deepcopy on init.
28
+ """
29
+
30
+ # TODO: consider naming: maybe SelectionState
31
+
32
+ def __init__(
33
+ self,
34
+ people: People,
35
+ features: FeatureCollection,
36
+ check_same_address: bool = False,
37
+ check_same_address_columns: list[str] | None = None,
38
+ ) -> None:
39
+ self.people = deepcopy(people)
40
+ self.features = deepcopy(features)
41
+ self.check_same_address = check_same_address
42
+ self.check_same_address_columns = check_same_address_columns or []
43
+
44
+ def update_features_remaining(self, person_key: str) -> None:
45
+ # this will blow up if the person does not exist
46
+ person = self.people.get_person_dict(person_key)
47
+ for feature_name in self.features.feature_names:
48
+ self.features.add_remaining(feature_name, person[feature_name])
49
+
50
+ def update_all_features_remaining(self) -> None:
51
+ for person_key in self.people:
52
+ self.update_features_remaining(person_key)
53
+
54
+ def delete_all_with_feature_value(self, feature_name: str, feature_value: str) -> tuple[int, int]:
55
+ """
56
+ When a feature/value is "full" we delete everyone else in it.
57
+ "Full" means that the number selected equals the "max" amount - that
58
+ is detected elsewhere and then this method is called.
59
+ Returns count of those deleted, and count of those left
60
+ """
61
+ # when a category is full we want to delete everyone in it
62
+ people_to_delete: list[str] = []
63
+ for pkey, person in self.people.items():
64
+ if person[feature_name] == feature_value:
65
+ people_to_delete.append(pkey)
66
+ for feature in self.features.feature_names:
67
+ try:
68
+ self.features.remove_remaining(feature, person[feature])
69
+ except errors.SelectionError as e:
70
+ msg = (
71
+ f"SELECTION IMPOSSIBLE: FAIL in delete_all_in_feature_value() "
72
+ f"as after previous deletion no one/not enough left in {feature} "
73
+ f"{person[feature]}. Tried to delete: {len(people_to_delete)}"
74
+ )
75
+ raise errors.SelectionError(msg) from e
76
+
77
+ self.people.remove_many(people_to_delete)
78
+ # return the number of people deleted and the number of people left
79
+ return len(people_to_delete), self.people.count
80
+
81
+ def prune_for_feature_max_0(self) -> list[str]:
82
+ """
83
+ Check if any feature_value.max is set to zero. if so delete everyone with that feature value
84
+ NOT DONE: could then check if anyone is left.
85
+ """
86
+ # TODO: when do we want to do this?
87
+ msg: list[str] = []
88
+ msg.append(f"Number of people: {self.people.count}.")
89
+ total_num_deleted = 0
90
+ for (
91
+ feature_name,
92
+ feature_value,
93
+ fv_counts,
94
+ ) in self.features.feature_values_counts():
95
+ if fv_counts.max == 0: # we don't want any of these people
96
+ # pass the message in as deleting them might throw an exception
97
+ msg.append(f"Feature/value {feature_name}/{feature_value} full - deleting people...")
98
+ num_deleted, num_left = self.delete_all_with_feature_value(feature_name, feature_value)
99
+ # if no exception was thrown above add this bit to the end of the previous message
100
+ msg[-1] += f" Deleted {num_deleted}, {num_left} left."
101
+ total_num_deleted += num_deleted
102
+ # if the total number of people deleted is lots then we're probably doing a replacement selection, which means
103
+ # the 'remaining' file will be useless - remind the user of this!
104
+ if total_num_deleted >= self.people.count / 2:
105
+ msg.append(
106
+ ">>> WARNING <<< That deleted MANY PEOPLE - are you doing a "
107
+ "replacement? If so your REMAINING FILE WILL BE USELESS!!!"
108
+ )
109
+ return msg
110
+
111
+ def select_person(self, person_key: str) -> list[str]:
112
+ """
113
+ Selecting a person means:
114
+ - remove the person from our copy of People
115
+ - update the `selected` and `remaining` counts of the FeatureCollection
116
+ - if check_same_address is True, also remove household members (without adding to selected)
117
+
118
+ Returns:
119
+ List of additional people removed due to same address (empty if check_same_address is False)
120
+ """
121
+ # First, find household members if address checking is enabled (before removing the person)
122
+ household_members_removed = []
123
+ if self.check_same_address and self.check_same_address_columns:
124
+ household_members_removed = list(self.people.matching_address(person_key, self.check_same_address_columns))
125
+
126
+ # Handle the main person selection
127
+ person = self.people.get_person_dict(person_key)
128
+ for feature in self.features.feature_names:
129
+ self.features.remove_remaining(feature, person[feature])
130
+ self.features.add_selected(feature, person[feature])
131
+ self.people.remove(person_key)
132
+
133
+ # Then remove household members if any were found
134
+ for household_member_key in household_members_removed:
135
+ household_member = self.people.get_person_dict(household_member_key)
136
+ for feature in self.features.feature_names:
137
+ self.features.remove_remaining(feature, household_member[feature])
138
+ # Note: we don't call add_selected() for household members
139
+ self.people.remove(household_member_key)
140
+
141
+ return household_members_removed
142
+
143
+ def find_max_ratio_category(self) -> MaxRatioResult:
144
+ """
145
+ Find the feature/value combination with the highest selection ratio.
146
+
147
+ The ratio is calculated as: (min - selected) / remaining
148
+ This represents how urgently we need people from this category.
149
+ Higher ratio = more urgent need (fewer people available relative to what we still need).
150
+
151
+ Returns:
152
+ MaxRatioResult containing the feature name, value, and a random person index
153
+
154
+ Raises:
155
+ SelectionError: If insufficient people remain to meet minimum requirements
156
+ """
157
+ max_ratio = -100.0
158
+ result_feature_name = ""
159
+ result_feature_value = ""
160
+ random_person_index = -1
161
+
162
+ for (
163
+ feature_name,
164
+ feature_value,
165
+ fv_counts,
166
+ ) in self.features.feature_values_counts():
167
+ # Check if we have insufficient people to meet minimum requirements
168
+ people_still_needed = fv_counts.min - fv_counts.selected
169
+ if fv_counts.selected < fv_counts.min and fv_counts.remaining < people_still_needed:
170
+ msg = (
171
+ f"SELECTION IMPOSSIBLE: Not enough people remaining in {feature_name}/{feature_value}. "
172
+ f"Need {people_still_needed} more, but only {fv_counts.remaining} remaining."
173
+ )
174
+ raise errors.SelectionError(msg)
175
+
176
+ # Skip categories with no remaining people or max = 0
177
+ if fv_counts.remaining == 0 or fv_counts.max == 0:
178
+ continue
179
+
180
+ # Calculate the priority ratio
181
+ ratio = people_still_needed / float(fv_counts.remaining)
182
+
183
+ # Track the highest ratio category
184
+ if ratio > max_ratio:
185
+ max_ratio = ratio
186
+ result_feature_name = feature_name
187
+ result_feature_value = feature_value
188
+ # from 1 to remaining
189
+ random_person_index = random_provider().randbelow(fv_counts.remaining) + 1
190
+
191
+ # If no valid category found, all categories must be at their max or have max=0
192
+ if not result_feature_name:
193
+ msg = "No valid categories found - all may be at maximum or have max=0"
194
+ raise errors.SelectionError(msg)
195
+
196
+ return MaxRatioResult(
197
+ feature_name=result_feature_name,
198
+ feature_value=result_feature_value,
199
+ random_person_index=random_person_index,
200
+ )
201
+
202
+ def handle_category_full_deletions(self, selected_person_data: dict[str, str]) -> list[str]:
203
+ """
204
+ Check if any categories are now full after a selection and delete remaining people.
205
+
206
+ When a person is selected, some categories may reach their maximum quota.
207
+ This method identifies such categories and removes all remaining people from them.
208
+
209
+ Args:
210
+ selected_person_data: Dictionary of the selected person's feature values
211
+
212
+ Returns:
213
+ List of output messages about categories that became full and people deleted
214
+
215
+ Raises:
216
+ SelectionError: If deletions would violate minimum constraints
217
+ """
218
+ output_messages = []
219
+
220
+ for (
221
+ feature_name,
222
+ feature_value,
223
+ fv_counts,
224
+ ) in self.features.feature_values_counts():
225
+ if feature_value == selected_person_data[feature_name] and fv_counts.selected == fv_counts.max:
226
+ num_deleted, num_left = self.delete_all_with_feature_value(feature_name, feature_value)
227
+ if num_deleted > 0:
228
+ output_messages.append(
229
+ f"Category {feature_name}/{feature_value} full - deleted {num_deleted} people, {num_left} left."
230
+ )
231
+
232
+ return output_messages
233
+
234
+
235
+ def simple_add_selected(person_keys: Iterable[str], people: People, features: FeatureCollection) -> None:
236
+ """
237
+ Just add the person to the selected counts for the feature values for that person.
238
+ Don't do the more complex handling of the full PeopleFeatures.add_selected()
239
+ """
240
+ for person_key in person_keys:
241
+ person = people.get_person_dict(person_key)
242
+ for feature_name in features.feature_names:
243
+ features.add_selected(feature_name, person[feature_name])
244
+
245
+
246
+ class WeightedSample:
247
+ def __init__(self, features: FeatureCollection) -> None:
248
+ """
249
+ This produces a set of lists of feature values for each feature. Each value
250
+ is in the list `fv_counts.max` times - so a random choice with represent the max.
251
+
252
+ So if we had feature "ethnicity", value "white" w max 4, "asian" w max 3 and
253
+ "black" with max 2 we'd get:
254
+
255
+ ["white", "white", "white", "white", "asian", "asian", "asian", "black", "black"]
256
+
257
+ Then making random choices from that list produces a weighted sample.
258
+ """
259
+ self.weighted: dict[str, list[str]] = defaultdict(list)
260
+ for feature_name, value, fv_counts in features.feature_values_counts():
261
+ self.weighted[feature_name] += [value] * fv_counts.max
262
+
263
+ def value_for(self, feature_name: str) -> str:
264
+ # S311 is random numbers for crypto - but this is just for a sample file
265
+ return random_provider().choice(self.weighted[feature_name])
266
+
267
+
268
+ def create_readable_sample_file(
269
+ features: FeatureCollection,
270
+ people_file: typing.TextIO,
271
+ number_people_example_file: int,
272
+ settings: Settings,
273
+ ) -> None:
274
+ example_people_writer = csv.writer(
275
+ people_file,
276
+ delimiter=",",
277
+ quotechar='"',
278
+ quoting=csv.QUOTE_MINIMAL,
279
+ )
280
+ example_people_writer.writerow([settings.id_column, *settings.columns_to_keep, *features.feature_names])
281
+ weighted = WeightedSample(features)
282
+ for x in range(number_people_example_file):
283
+ row = [
284
+ f"p{x}",
285
+ *[f"{col} {x}" for col in settings.columns_to_keep],
286
+ *[weighted.value_for(f) for f in features.feature_names],
287
+ ]
288
+ example_people_writer.writerow(row)
@@ -0,0 +1,110 @@
1
+ import tomllib
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from attrs import define, field, validators
6
+ from cattrs import structure
7
+
8
+ SELECTION_ALGORITHMS = ("legacy", "maximin", "nash")
9
+
10
+ DEFAULT_SETTINGS = """
11
+ # #####################################################################
12
+ #
13
+ # IF YOU EDIT THIS FILE YOU NEED TO RESTART THE APPLICATION
14
+ #
15
+ # #####################################################################
16
+
17
+ # this is written in TOML - https://github.com/toml-lang/toml
18
+
19
+ # this is the name of the (unique) field for each person
20
+ id_column = "nationbuilder_id"
21
+
22
+ # if check_same_address is true, then no 2 people from the same address will be selected
23
+ # the comparison checks if the TWO fields listed here are the same for any person
24
+ check_same_address = true
25
+ check_same_address_columns = [
26
+ "primary_address1",
27
+ "zip_royal_mail"
28
+ ]
29
+
30
+ max_attempts = 100
31
+ columns_to_keep = [
32
+ "first_name",
33
+ "last_name",
34
+ "mobile_number",
35
+ "email",
36
+ "primary_address1",
37
+ "primary_address2",
38
+ "primary_city",
39
+ "zip_royal_mail",
40
+ "tag_list",
41
+ "age",
42
+ "gender"
43
+ ]
44
+
45
+ # selection_algorithm can either be "legacy", "maximin", "leximin", or "nash"
46
+ selection_algorithm = "maximin"
47
+
48
+ # random number seed - if this is NOT zero then it is used to set the random number generator seed
49
+ random_number_seed = 0
50
+ """
51
+
52
+
53
+ def check_columns_for_same_address(instance: "Settings", attribute: Any, value: Any) -> None:
54
+ if not isinstance(value, list):
55
+ raise TypeError("check_same_address_columns must be a LIST of strings")
56
+ if len(value) not in (0, 2):
57
+ raise ValueError("check_same_address_columns must be a list of ZERO OR TWO strings")
58
+ if not all(isinstance(element, str) for element in value):
59
+ raise TypeError("check_same_address_columns must be a list of STRINGS")
60
+ if len(value) == 0 and instance.check_same_address:
61
+ raise ValueError(
62
+ "check_same_address is TRUE but there are no columns listed to check! FIX THIS and RESTART this program!"
63
+ )
64
+
65
+
66
+ @define
67
+ class Settings:
68
+ id_column: str = field(validator=validators.instance_of(str))
69
+ columns_to_keep: list[str] = field()
70
+ check_same_address: bool = field(validator=validators.instance_of(bool))
71
+ check_same_address_columns: list[str] = field(validator=check_columns_for_same_address)
72
+ max_attempts: int = field(validator=validators.instance_of(int))
73
+ selection_algorithm: str = field()
74
+ random_number_seed: int = field(validator=validators.instance_of(int))
75
+
76
+ @columns_to_keep.validator
77
+ def check_columns_to_keep(self, attribute: Any, value: Any) -> None:
78
+ if not isinstance(value, list):
79
+ raise TypeError("columns_to_keep must be a LIST of strings")
80
+ if not all(isinstance(element, str) for element in value):
81
+ raise TypeError("columns_to_keep must be a list of STRINGS")
82
+
83
+ @selection_algorithm.validator
84
+ def check_selection_algorithm(self, attribute: Any, value: str) -> None:
85
+ if value not in SELECTION_ALGORITHMS:
86
+ raise ValueError(f"selection_algorithm {value} is not one of: {', '.join(SELECTION_ALGORITHMS)}")
87
+
88
+ @classmethod
89
+ def load_from_file(
90
+ cls,
91
+ *,
92
+ settings_file_path: Path,
93
+ ) -> tuple["Settings", str]:
94
+ messages: list[str] = []
95
+ if not settings_file_path.is_file():
96
+ with open(settings_file_path, "w", encoding="utf-8") as settings_file:
97
+ settings_file.write(DEFAULT_SETTINGS)
98
+ messages.append(
99
+ f"Wrote default settings to '{settings_file_path.absolute()}' "
100
+ "- if editing is required, restart this app."
101
+ )
102
+ with open(settings_file_path, "rb") as settings_file:
103
+ settings = tomllib.load(settings_file)
104
+ # you can't check an address if there is no info about which columns to check...
105
+ if settings["check_same_address"] is False:
106
+ messages.append(
107
+ "<b>WARNING</b>: Settings file is such that we do NOT check if respondents have same address."
108
+ )
109
+ settings["check_same_address_columns"] = []
110
+ return structure(settings, cls), "\n".join(messages)
@@ -0,0 +1,107 @@
1
+ import random
2
+ import secrets
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Mapping
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from _typeshed import SupportsLenAndGetItem
9
+
10
+
11
+ def print_ret(message: str) -> str:
12
+ """Print and return a message for output collection."""
13
+ # TODO: should we replace this with logging or similar?
14
+ print(message)
15
+ return message
16
+
17
+
18
+ def strip_str_int(value: str | int | float) -> str:
19
+ return str(value).strip()
20
+
21
+
22
+ class StrippedDict:
23
+ """
24
+ Wraps a dict, and whenever we get a value from it, we convert to str and
25
+ strip() whitespace
26
+ """
27
+
28
+ def __init__(self, raw_dict: Mapping[str, str] | Mapping[str, str | int]) -> None:
29
+ self.raw_dict = raw_dict
30
+
31
+ def __getitem__(self, key: str) -> str:
32
+ return strip_str_int(self.raw_dict[key])
33
+
34
+
35
+ class RandomProvider(ABC):
36
+ """
37
+ This is something of a hack. Mostly we want to use the `secrets` module.
38
+ But for repeatable testing we might want to set the random.seed sometimes.
39
+
40
+ So we have a global `_random_provider` which can be switched between an
41
+ instance of this class that uses the `secrets` module and an instance that
42
+ uses `random` with a seed. The switch is done by the `set_random_provider()`
43
+ function.
44
+
45
+ Then every time we want some randomness, we call `random_provider()` to get
46
+ the current version of the global.
47
+ """
48
+
49
+ @classmethod
50
+ @abstractmethod
51
+ def uniform(cls, lower: float, upper: float) -> float: ...
52
+
53
+ @classmethod
54
+ @abstractmethod
55
+ def randbelow(cls, upper: int) -> int: ...
56
+
57
+ @classmethod
58
+ @abstractmethod
59
+ def choice(cls, seq: "SupportsLenAndGetItem[str]") -> str: ...
60
+
61
+
62
+ class GenRandom(RandomProvider):
63
+ def __init__(self, seed: int) -> None:
64
+ random.seed(seed)
65
+
66
+ @classmethod
67
+ def uniform(cls, lower: float, upper: float) -> float:
68
+ return random.uniform(lower, upper) # noqa: S311
69
+
70
+ @classmethod
71
+ def randbelow(cls, upper: int) -> int:
72
+ return random.randrange(upper) # noqa: S311
73
+
74
+ @classmethod
75
+ def choice(cls, seq: "SupportsLenAndGetItem[str]") -> str:
76
+ return random.choice(seq) # noqa: S311
77
+
78
+
79
+ class GenSecrets(RandomProvider):
80
+ @classmethod
81
+ def uniform(cls, lower: float, upper: float) -> float:
82
+ assert upper > lower
83
+ diff = upper - lower
84
+ rand_int = secrets.randbelow(1_000_000)
85
+ return lower + (rand_int * diff / 1_000_000)
86
+
87
+ @classmethod
88
+ def randbelow(cls, upper: int) -> int:
89
+ return secrets.randbelow(upper)
90
+
91
+ @classmethod
92
+ def choice(cls, seq: "SupportsLenAndGetItem[str]") -> str:
93
+ return secrets.choice(seq)
94
+
95
+
96
+ _random_provider: RandomProvider = GenSecrets()
97
+
98
+
99
+ def set_random_provider(seed: int | None = None) -> None:
100
+ global _random_provider
101
+ if seed:
102
+ _random_provider = GenRandom(seed)
103
+ _random_provider = GenSecrets()
104
+
105
+
106
+ def random_provider() -> RandomProvider:
107
+ return _random_provider