commonground-score 0.1.0__tar.gz

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,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: commonground-score
3
+ Version: 0.1.0
4
+ Summary: Pure-Python scoring utilities for Common Ground deliberation evaluations.
5
+ Author: Common Ground contributors
6
+ License-Expression: Apache-2.0
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
13
+ Requires-Python: >=3.12, <3.13
14
+ Project-URL: Repository, https://github.com/<OWNER>/commonground-envs
15
+ Description-Content-Type: text/markdown
16
+
17
+ # commonground-score
18
+
19
+ Pure-Python scoring utilities for Common Ground deliberation evaluations.
20
+
21
+ ## API
22
+
23
+ `commonground_score` exposes:
24
+
25
+ - `prop_test(successes, trials)`
26
+ - `two_prop_test(s_in, s_out, p_in, p_out)`
27
+ - `comment_stats(votes)`
28
+ - `vote_entropy(votes)`
29
+ - `cluster_separation(votes)`
30
+ - `rating_to_vote(value)`
31
+ - `vote_accuracy(predictions, held_out)`
32
+ - `brier_score(predictions, held_out)`
33
+
34
+ `vote_entropy` normalizes agree/disagree/pass entropy to `[0, 1]`.
35
+ `cluster_separation` returns the fraction of faction-vote pairs taking different
36
+ stances. Together they provide the deterministic panel-disagreement math used
37
+ by `commonground-elicit`; missing votes are excluded from both calculations.
38
+
39
+ `brier_score` accepts bare point predictions (`1`, `-1`, or `0`) and
40
+ probability mappings keyed by `agree`, `disagree`, and `pass` or their numeric
41
+ equivalents. Valid non-negative finite mappings are normalized before scoring.
42
+ Invalid or non-normalizable mappings score as the uniform distribution
43
+ (`1/3`, `1/3`, `1/3`) to represent no information.
44
+
45
+ `rating_to_vote(value)` implements the canonical 0-10 rating conversion used for
46
+ dataset parity:
47
+
48
+ ```text
49
+ signed = (2 * (value - 5)) / 10
50
+ ```
51
+
52
+ Values outside the inclusive `0`-`10` range, plus non-finite values, map to
53
+ neutral/pass (`0`). In-range values below `5` return a negative score, values
54
+ above `5` return a positive score, and exactly `5` returns `0`.
55
+
56
+ ## Parity Fixtures
57
+
58
+ The test suite includes a parity fixture loader for
59
+ `tests/fixtures/parity_*.json`. The harness requires fixtures for
60
+ `prop_test`, `two_prop_test`, `comment_stats`, and `rating_to_vote`. Fixture
61
+ files should use:
62
+
63
+ ```json
64
+ {"function":"prop_test","cases":[{"args":[2,4],"expected":0.4472135954999579}]}
65
+ ```
66
+
67
+ JSON cannot encode NaN, so NaN arguments are represented as the exact string
68
+ `"NaN"` and decoded by the harness before invocation.
@@ -0,0 +1,52 @@
1
+ # commonground-score
2
+
3
+ Pure-Python scoring utilities for Common Ground deliberation evaluations.
4
+
5
+ ## API
6
+
7
+ `commonground_score` exposes:
8
+
9
+ - `prop_test(successes, trials)`
10
+ - `two_prop_test(s_in, s_out, p_in, p_out)`
11
+ - `comment_stats(votes)`
12
+ - `vote_entropy(votes)`
13
+ - `cluster_separation(votes)`
14
+ - `rating_to_vote(value)`
15
+ - `vote_accuracy(predictions, held_out)`
16
+ - `brier_score(predictions, held_out)`
17
+
18
+ `vote_entropy` normalizes agree/disagree/pass entropy to `[0, 1]`.
19
+ `cluster_separation` returns the fraction of faction-vote pairs taking different
20
+ stances. Together they provide the deterministic panel-disagreement math used
21
+ by `commonground-elicit`; missing votes are excluded from both calculations.
22
+
23
+ `brier_score` accepts bare point predictions (`1`, `-1`, or `0`) and
24
+ probability mappings keyed by `agree`, `disagree`, and `pass` or their numeric
25
+ equivalents. Valid non-negative finite mappings are normalized before scoring.
26
+ Invalid or non-normalizable mappings score as the uniform distribution
27
+ (`1/3`, `1/3`, `1/3`) to represent no information.
28
+
29
+ `rating_to_vote(value)` implements the canonical 0-10 rating conversion used for
30
+ dataset parity:
31
+
32
+ ```text
33
+ signed = (2 * (value - 5)) / 10
34
+ ```
35
+
36
+ Values outside the inclusive `0`-`10` range, plus non-finite values, map to
37
+ neutral/pass (`0`). In-range values below `5` return a negative score, values
38
+ above `5` return a positive score, and exactly `5` returns `0`.
39
+
40
+ ## Parity Fixtures
41
+
42
+ The test suite includes a parity fixture loader for
43
+ `tests/fixtures/parity_*.json`. The harness requires fixtures for
44
+ `prop_test`, `two_prop_test`, `comment_stats`, and `rating_to_vote`. Fixture
45
+ files should use:
46
+
47
+ ```json
48
+ {"function":"prop_test","cases":[{"args":[2,4],"expected":0.4472135954999579}]}
49
+ ```
50
+
51
+ JSON cannot encode NaN, so NaN arguments are represented as the exact string
52
+ `"NaN"` and decoded by the harness before invocation.
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "commonground-score"
3
+ version = "0.1.0"
4
+ description = "Pure-Python scoring utilities for Common Ground deliberation evaluations."
5
+ authors = [{ name = "Common Ground contributors" }]
6
+ license = "Apache-2.0"
7
+ readme = "README.md"
8
+ requires-python = ">=3.12,<3.13"
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "Intended Audience :: Science/Research",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
16
+ ]
17
+ dependencies = []
18
+
19
+ [project.urls]
20
+ Repository = "https://github.com/<OWNER>/commonground-envs"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.8.0,<0.9.0"]
24
+ build-backend = "uv_build"
@@ -0,0 +1,25 @@
1
+ """Scoring utilities for Common Ground deliberation evaluations."""
2
+
3
+ from commonground_score.scoring import (
4
+ brier_score,
5
+ cluster_separation,
6
+ comment_stats,
7
+ prop_test,
8
+ rating_to_vote,
9
+ two_prop_test,
10
+ vote_accuracy,
11
+ vote_entropy,
12
+ )
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ __all__ = [
17
+ "brier_score",
18
+ "cluster_separation",
19
+ "comment_stats",
20
+ "prop_test",
21
+ "rating_to_vote",
22
+ "two_prop_test",
23
+ "vote_accuracy",
24
+ "vote_entropy",
25
+ ]
@@ -0,0 +1,193 @@
1
+ """Polis-compatible vote statistics and reward helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from math import isfinite, log, sqrt
7
+ from typing import Any, cast
8
+
9
+ Vote = int | None
10
+ PointPrediction = int
11
+ ProbPrediction = Mapping[object, object]
12
+ Prediction = PointPrediction | ProbPrediction
13
+
14
+ _LABEL_TO_VOTE = {"agree": 1, "disagree": -1, "pass": 0}
15
+ _VOTE_TO_LABEL = {vote: label for label, vote in _LABEL_TO_VOTE.items()}
16
+ _LABELS = ("agree", "disagree", "pass")
17
+ RATING_MIN = 0
18
+ RATING_MAX = 10
19
+ VALID_VOTES = (-1, 0, 1)
20
+
21
+
22
+ def prop_test(successes: int, trials: int) -> float:
23
+ """Return the one-proportion test statistic used by Polis math."""
24
+
25
+ return 2 * sqrt(trials + 1) * ((successes + 1) / (trials + 1) - 0.5)
26
+
27
+
28
+ def two_prop_test(s_in: int, s_out: int, p_in: int, p_out: int) -> float:
29
+ """Return the smoothed two-proportion test statistic used by Polis math."""
30
+
31
+ s_in += 1
32
+ s_out += 1
33
+ p_in += 1
34
+ p_out += 1
35
+ pi1 = s_in / p_in
36
+ pi2 = s_out / p_out
37
+ pi_hat = (s_in + s_out) / (p_in + p_out)
38
+ if pi_hat == 1:
39
+ return 0
40
+ return (pi1 - pi2) / sqrt(pi_hat * (1 - pi_hat) * ((1 / p_in) + (1 / p_out)))
41
+
42
+
43
+ def comment_stats(votes: list[Vote]) -> dict[str, float | int]:
44
+ """Summarize agree/disagree/pass counts and smoothed proportions for a statement."""
45
+
46
+ agree = sum(vote == 1 for vote in votes)
47
+ disagree = sum(vote == -1 for vote in votes)
48
+ passed = sum(vote == 0 for vote in votes)
49
+ seen = sum(vote is not None for vote in votes)
50
+ return {
51
+ "agree": agree,
52
+ "disagree": disagree,
53
+ "pass": passed,
54
+ "seen": seen,
55
+ "pa": (agree + 1) / (seen + 2),
56
+ "pd": (disagree + 1) / (seen + 2),
57
+ "pat": prop_test(agree, seen),
58
+ "pdt": prop_test(disagree, seen),
59
+ }
60
+
61
+
62
+ def vote_entropy(votes: list[Vote]) -> float:
63
+ """Return normalized agree/disagree/pass entropy over seen valid votes."""
64
+
65
+ seen = [vote for vote in votes if vote in VALID_VOTES]
66
+ if not seen:
67
+ return 0.0
68
+ entropy = 0.0
69
+ for vote in VALID_VOTES:
70
+ count = seen.count(vote)
71
+ if count:
72
+ probability = count / len(seen)
73
+ entropy -= probability * log(probability)
74
+ return entropy / log(len(VALID_VOTES))
75
+
76
+
77
+ def cluster_separation(votes: list[Vote]) -> float:
78
+ """Return the fraction of seen faction pairs taking different stances."""
79
+
80
+ seen = [vote for vote in votes if vote in VALID_VOTES]
81
+ pair_count = len(seen) * (len(seen) - 1) // 2
82
+ if pair_count == 0:
83
+ return 0.0
84
+ separated_pairs = sum(
85
+ left_vote != right_vote
86
+ for left_index, left_vote in enumerate(seen)
87
+ for right_vote in seen[left_index + 1 :]
88
+ )
89
+ return separated_pairs / pair_count
90
+
91
+
92
+ def rating_to_vote(value: float) -> float:
93
+ """Map a 0-10 rating to the canonical signed vote score.
94
+
95
+ Non-finite values and values outside ``[0, 10]`` return ``0`` (no stance).
96
+ In-range values return ``(2 * (value - 5)) / 10``: positive values lean
97
+ agree, negative values lean disagree, and exactly ``5`` is neutral/pass.
98
+ """
99
+
100
+ if not isfinite(value) or value < RATING_MIN or value > RATING_MAX:
101
+ return 0
102
+ return (2 * (value - 5)) / 10
103
+
104
+
105
+ def vote_accuracy(predictions: Mapping[str, int], held_out: Mapping[str, int]) -> float:
106
+ """Return the exact-match fraction over held-out cells.
107
+
108
+ Missing predictions are counted as wrong.
109
+ """
110
+
111
+ if not held_out:
112
+ return 0.0
113
+ correct = sum(
114
+ predictions.get(cell_id) == vote for cell_id, vote in held_out.items()
115
+ )
116
+ return correct / len(held_out)
117
+
118
+
119
+ def brier_score(
120
+ predictions: Mapping[str, Prediction],
121
+ held_out: Mapping[str, int],
122
+ ) -> float:
123
+ """Return mean multiclass Brier score for agree/disagree/pass predictions.
124
+
125
+ Probabilistic predictions are dictionaries keyed by ``agree``, ``disagree``,
126
+ and ``pass``. Valid non-negative finite mappings are normalized to sum to
127
+ one. Invalid or non-normalizable mappings score as the uniform
128
+ distribution, representing no information. Point predictions using ``1``,
129
+ ``-1``, or ``0`` are converted to one-hot probabilities. Missing
130
+ predictions use an all-zero vector.
131
+ """
132
+
133
+ if not held_out:
134
+ return 0.0
135
+ total = 0.0
136
+ for cell_id, actual_vote in held_out.items():
137
+ pred_probs = _prediction_probs(predictions.get(cell_id))
138
+ actual_label = _VOTE_TO_LABEL[actual_vote]
139
+ total += sum(
140
+ (pred_probs[label] - float(label == actual_label)) ** 2 for label in _LABELS
141
+ )
142
+ return total / len(held_out)
143
+
144
+
145
+ def _prediction_probs(prediction: Prediction | None) -> dict[str, float]:
146
+ if isinstance(prediction, Mapping):
147
+ return _mapping_prediction_probs(prediction)
148
+ if prediction in _VOTE_TO_LABEL:
149
+ label = _VOTE_TO_LABEL[prediction]
150
+ return {candidate: float(candidate == label) for candidate in _LABELS}
151
+ return {label: 0.0 for label in _LABELS}
152
+
153
+
154
+ def _mapping_prediction_probs(prediction: Mapping[object, object]) -> dict[str, float]:
155
+ scores: dict[str, float] = {}
156
+ invalid = False
157
+ for key, value in prediction.items():
158
+ label = _coerce_class_label(key)
159
+ if label is None:
160
+ continue
161
+ try:
162
+ score = float(cast(Any, value))
163
+ except (TypeError, ValueError):
164
+ invalid = True
165
+ continue
166
+ if not isfinite(score):
167
+ invalid = True
168
+ continue
169
+ scores[label] = score
170
+ if score < 0:
171
+ invalid = True
172
+
173
+ total = sum(scores.get(label, 0.0) for label in _LABELS)
174
+ if scores and not invalid and total > 0:
175
+ return {label: scores.get(label, 0.0) / total for label in _LABELS}
176
+ return _uniform_probs()
177
+
178
+
179
+ def _uniform_probs() -> dict[str, float]:
180
+ return {label: 1 / len(_LABELS) for label in _LABELS}
181
+
182
+
183
+ def _coerce_class_label(key: object) -> str | None:
184
+ if isinstance(key, bool):
185
+ return None
186
+ if isinstance(key, int) and key in _VOTE_TO_LABEL:
187
+ return _VOTE_TO_LABEL[key]
188
+ if isinstance(key, str):
189
+ if key in _LABEL_TO_VOTE:
190
+ return key
191
+ if key in {"-1", "0", "1"}:
192
+ return _VOTE_TO_LABEL[int(key)]
193
+ return None