althing 1.7.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.
- althing/__init__.py +46 -0
- althing/__main__.py +9 -0
- althing/__version__.py +1 -0
- althing/_compat_env.py +27 -0
- althing/_runners.py +953 -0
- althing/agent_assets/__init__.py +3 -0
- althing/agent_assets/commands/althing-poll.md +75 -0
- althing/agent_assets/skills/concept-test/SKILL.md +72 -0
- althing/agent_assets/skills/focus-group/SKILL.md +77 -0
- althing/agent_assets/skills/name-test/SKILL.md +62 -0
- althing/agent_assets/skills/pricing-probe/SKILL.md +75 -0
- althing/agent_assets/skills/survey-prescreen/SKILL.md +76 -0
- althing/aggregation.py +240 -0
- althing/analysis/__init__.py +46 -0
- althing/analysis/distribution.py +323 -0
- althing/analysis/inspect.py +586 -0
- althing/analysis/subgroup.py +168 -0
- althing/analysis/subgroup_cli.py +598 -0
- althing/analyze.py +839 -0
- althing/attachments/__init__.py +77 -0
- althing/attachments/filter.py +67 -0
- althing/attachments/models.py +50 -0
- althing/attachments/pdf.py +537 -0
- althing/attachments/store.py +111 -0
- althing/calibration.py +239 -0
- althing/checkpoint.py +763 -0
- althing/cli/__init__.py +1 -0
- althing/cli/commands.py +8080 -0
- althing/cli/mcp_install.py +487 -0
- althing/cli/output.py +106 -0
- althing/cli/parser.py +1706 -0
- althing/cli/progress.py +93 -0
- althing/cli/repl.py +122 -0
- althing/cli/slash.py +197 -0
- althing/conditions.py +261 -0
- althing/convergence.py +1091 -0
- althing/cost.py +950 -0
- althing/cost_summary.py +452 -0
- althing/credentials.py +311 -0
- althing/data/__init__.py +8 -0
- althing/data/synthbench-snapshot.json +120 -0
- althing/diff.py +331 -0
- althing/domains.py +284 -0
- althing/ensemble.py +922 -0
- althing/fetch/__init__.py +38 -0
- althing/fetch/cache.py +360 -0
- althing/fetch/ladder.py +437 -0
- althing/fetch/lower.py +199 -0
- althing/fetch/perimeter.py +494 -0
- althing/instrument.py +751 -0
- althing/integrations/__init__.py +21 -0
- althing/integrations/composio.py +345 -0
- althing/llm/__init__.py +32 -0
- althing/llm/aliases.py +138 -0
- althing/llm/capabilities.py +86 -0
- althing/llm/client.py +267 -0
- althing/llm/errors.py +162 -0
- althing/llm/fast_default.py +71 -0
- althing/llm/models.py +340 -0
- althing/llm/providers/__init__.py +18 -0
- althing/llm/providers/_anthropic_format.py +281 -0
- althing/llm/providers/_openai_format.py +381 -0
- althing/llm/providers/anthropic.py +172 -0
- althing/llm/providers/base.py +60 -0
- althing/llm/providers/gemini.py +107 -0
- althing/llm/providers/openai_compat.py +102 -0
- althing/llm/providers/openrouter.py +357 -0
- althing/llm/providers/xai.py +98 -0
- althing/llm/retry.py +113 -0
- althing/logging_config.py +80 -0
- althing/main.py +308 -0
- althing/mcp/__init__.py +7 -0
- althing/mcp/compat.py +110 -0
- althing/mcp/data.py +700 -0
- althing/mcp/sampling.py +380 -0
- althing/mcp/server.py +3371 -0
- althing/metadata.py +214 -0
- althing/metadata_migrations.py +70 -0
- althing/orchestrator.py +2119 -0
- althing/pack_diff.py +271 -0
- althing/packs/__init__.py +3 -0
- althing/packs/ai-eval-buyers.yaml +247 -0
- althing/packs/broad-professionals.yaml +251 -0
- althing/packs/developer.yaml +271 -0
- althing/packs/enterprise-ai-buyers.yaml +245 -0
- althing/packs/enterprise-buyer.yaml +252 -0
- althing/packs/general-consumer.yaml +271 -0
- althing/packs/healthcare-patient.yaml +271 -0
- althing/packs/instruments/__init__.py +1 -0
- althing/packs/instruments/churn-diagnosis.yaml +73 -0
- althing/packs/instruments/feature-prioritization.yaml +75 -0
- althing/packs/instruments/general-survey.yaml +46 -0
- althing/packs/instruments/happiness-probe.yaml +41 -0
- althing/packs/instruments/landing-page-comprehension.yaml +75 -0
- althing/packs/instruments/market-research.yaml +50 -0
- althing/packs/instruments/name-test.yaml +74 -0
- althing/packs/instruments/pricing-discovery.yaml +80 -0
- althing/packs/instruments/product-feedback.yaml +46 -0
- althing/packs/job-seekers.yaml +188 -0
- althing/packs/market-research-critics.yaml +217 -0
- althing/packs/product-research.yaml +248 -0
- althing/packs/recruiters-talent.yaml +264 -0
- althing/packs/skeptical-executives.yaml +227 -0
- althing/packs/startup-founder.yaml +242 -0
- althing/packs/students.yaml +352 -0
- althing/persistence.py +510 -0
- althing/perturbation.py +333 -0
- althing/plugins/__init__.py +34 -0
- althing/plugins/hooks.py +159 -0
- althing/plugins/lint.py +352 -0
- althing/plugins/manager.py +176 -0
- althing/plugins/manifest.py +100 -0
- althing/plugins/registry.py +66 -0
- althing/poll_summary.py +1195 -0
- althing/preflight.py +155 -0
- althing/profiles.py +264 -0
- althing/prompts.py +311 -0
- althing/py.typed +0 -0
- althing/question_budget.py +140 -0
- althing/registry/__init__.py +178 -0
- althing/registry/cache.py +223 -0
- althing/registry/fetch.py +104 -0
- althing/registry/github.py +212 -0
- althing/reporting/__init__.py +17 -0
- althing/reporting/loader.py +69 -0
- althing/reporting/markdown.py +450 -0
- althing/response_coercion.py +161 -0
- althing/routing.py +132 -0
- althing/runtime.py +552 -0
- althing/schemas/__init__.py +53 -0
- althing/schemas/v1.0.0.json +161 -0
- althing/sdk.py +1458 -0
- althing/stats.py +1522 -0
- althing/structured/__init__.py +5 -0
- althing/structured/models.py +149 -0
- althing/structured/output.py +376 -0
- althing/structured/retry.py +170 -0
- althing/structured/schemas.py +127 -0
- althing/structured/validate.py +303 -0
- althing/structured/verdict.py +146 -0
- althing/synthbench.py +768 -0
- althing/synthbench_submit.py +582 -0
- althing/synthesis.py +1529 -0
- althing/synthesis_recovery.py +467 -0
- althing/templates.py +179 -0
- althing/text_width.py +126 -0
- althing-1.7.0.dist-info/METADATA +1473 -0
- althing-1.7.0.dist-info/RECORD +156 -0
- althing-1.7.0.dist-info/WHEEL +5 -0
- althing-1.7.0.dist-info/entry_points.txt +3 -0
- althing-1.7.0.dist-info/licenses/LICENSE +21 -0
- althing-1.7.0.dist-info/top_level.txt +3 -0
- synth_panel/__init__.py +60 -0
- synth_panel/__main__.py +17 -0
- synthpanel/__init__.py +60 -0
- synthpanel/__main__.py +17 -0
althing/aggregation.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Post-run variant aggregation and robustness scoring (sp-5on.16).
|
|
2
|
+
|
|
3
|
+
Groups panelist results by the ``_variant_of`` metadata field (set by the
|
|
4
|
+
perturbation module, sp-5on.14) and computes two-level nested robustness
|
|
5
|
+
scores: intra-persona agreement across variants (inner) and inter-persona
|
|
6
|
+
agreement across the panel (outer).
|
|
7
|
+
|
|
8
|
+
Formula: R(F) = (1/N) * sum_i[(1/K_i) * sum_j 1{variant_ij agrees with F}]
|
|
9
|
+
|
|
10
|
+
Thresholds: R >= 0.8 robust, 0.6-0.8 moderate, 0.4-0.6 sensitive, < 0.4 fragile.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from althing.orchestrator import PanelistResult
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Data models
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class VariantGroup:
|
|
28
|
+
"""Results for a source persona and all its variants."""
|
|
29
|
+
|
|
30
|
+
source_name: str
|
|
31
|
+
original: PanelistResult | None
|
|
32
|
+
variants: list[PanelistResult] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def k(self) -> int:
|
|
36
|
+
"""Number of variants."""
|
|
37
|
+
return len(self.variants)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class FindingRobustness:
|
|
42
|
+
"""Robustness of responses to a single question across all personas."""
|
|
43
|
+
|
|
44
|
+
question_index: int
|
|
45
|
+
score: float
|
|
46
|
+
classification: str
|
|
47
|
+
per_persona: dict[str, float]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class RobustnessReport:
|
|
52
|
+
"""Complete two-level robustness report."""
|
|
53
|
+
|
|
54
|
+
findings: list[FindingRobustness]
|
|
55
|
+
aggregate_robustness: float
|
|
56
|
+
fragile_findings: list[FindingRobustness]
|
|
57
|
+
n_personas: int
|
|
58
|
+
per_persona_robustness: dict[str, float]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Helpers
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _default_extractor(response: dict[str, Any]) -> str:
|
|
67
|
+
"""Extract a comparable string value from a response dict."""
|
|
68
|
+
val = response.get("response", "")
|
|
69
|
+
return str(val)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _classify_robustness(r: float) -> str:
|
|
73
|
+
"""Classify a robustness score into a named tier."""
|
|
74
|
+
if r >= 0.8:
|
|
75
|
+
return "robust"
|
|
76
|
+
elif r >= 0.6:
|
|
77
|
+
return "moderate"
|
|
78
|
+
elif r >= 0.4:
|
|
79
|
+
return "sensitive"
|
|
80
|
+
return "fragile"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Grouping
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def aggregate_variants(
|
|
89
|
+
results: list[PanelistResult],
|
|
90
|
+
personas: list[dict[str, Any]],
|
|
91
|
+
) -> list[VariantGroup]:
|
|
92
|
+
"""Group panelist results by ``_variant_of`` metadata.
|
|
93
|
+
|
|
94
|
+
Matches results to personas by name. Personas whose dict contains a
|
|
95
|
+
``_variant_of`` key are variants; all others are originals.
|
|
96
|
+
|
|
97
|
+
Returns one :class:`VariantGroup` per source persona that has at least
|
|
98
|
+
one variant. Groups are sorted by source name for deterministic order.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
results: Panelist results from a panel run that included both
|
|
102
|
+
original personas and their perturbation variants.
|
|
103
|
+
personas: The persona dicts used in the panel run. Variant
|
|
104
|
+
personas must carry the ``_variant_of`` field set by the
|
|
105
|
+
perturbation module.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
List of :class:`VariantGroup`, one per source persona with variants.
|
|
109
|
+
"""
|
|
110
|
+
persona_by_name: dict[str, dict[str, Any]] = {p["name"]: p for p in personas if p.get("name")}
|
|
111
|
+
|
|
112
|
+
originals: dict[str, PanelistResult] = {}
|
|
113
|
+
variant_map: dict[str, list[PanelistResult]] = {}
|
|
114
|
+
|
|
115
|
+
for r in results:
|
|
116
|
+
persona = persona_by_name.get(r.persona_name)
|
|
117
|
+
if persona and "_variant_of" in persona:
|
|
118
|
+
source = persona["_variant_of"]
|
|
119
|
+
variant_map.setdefault(source, []).append(r)
|
|
120
|
+
else:
|
|
121
|
+
originals[r.persona_name] = r
|
|
122
|
+
|
|
123
|
+
groups: list[VariantGroup] = []
|
|
124
|
+
for source_name in sorted(set(originals) | set(variant_map)):
|
|
125
|
+
variants = variant_map.get(source_name, [])
|
|
126
|
+
if not variants:
|
|
127
|
+
continue
|
|
128
|
+
groups.append(
|
|
129
|
+
VariantGroup(
|
|
130
|
+
source_name=source_name,
|
|
131
|
+
original=originals.get(source_name),
|
|
132
|
+
variants=variants,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
return groups
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# Scoring
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def robustness_report(
|
|
144
|
+
groups: list[VariantGroup],
|
|
145
|
+
*,
|
|
146
|
+
response_extractor: Callable[[dict[str, Any]], str] | None = None,
|
|
147
|
+
) -> RobustnessReport:
|
|
148
|
+
"""Compute two-level nested robustness scores from variant groups.
|
|
149
|
+
|
|
150
|
+
For each question, the original persona's response is the reference.
|
|
151
|
+
Each variant's response is compared for agreement (exact match on the
|
|
152
|
+
extracted string value).
|
|
153
|
+
|
|
154
|
+
**Inner level** (intra-persona): for persona *i* with *K_i* variants,
|
|
155
|
+
the agreement fraction is ``agreements / K_i``.
|
|
156
|
+
|
|
157
|
+
**Outer level** (inter-persona): for question *q*,
|
|
158
|
+
``R(q) = (1/N) * sum_i(agreement_fraction_i)``.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
groups: Variant groups from :func:`aggregate_variants`.
|
|
162
|
+
response_extractor: Extracts a comparable string from a response
|
|
163
|
+
dict. Defaults to ``str(response["response"])``.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
:class:`RobustnessReport` with per-finding and per-persona scores.
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
ValueError: If *groups* is empty or no group has both an original
|
|
170
|
+
result and at least one variant.
|
|
171
|
+
"""
|
|
172
|
+
if not groups:
|
|
173
|
+
raise ValueError("groups must not be empty")
|
|
174
|
+
|
|
175
|
+
extract = response_extractor or _default_extractor
|
|
176
|
+
|
|
177
|
+
scorable = [g for g in groups if g.original is not None and g.k > 0]
|
|
178
|
+
if not scorable:
|
|
179
|
+
raise ValueError("no groups with both an original and variants")
|
|
180
|
+
|
|
181
|
+
n_questions = max(len(g.original.responses) for g in scorable)
|
|
182
|
+
|
|
183
|
+
findings: list[FindingRobustness] = []
|
|
184
|
+
per_persona_all: dict[str, list[float]] = {}
|
|
185
|
+
|
|
186
|
+
for qi in range(n_questions):
|
|
187
|
+
per_persona: dict[str, float] = {}
|
|
188
|
+
|
|
189
|
+
for group in scorable:
|
|
190
|
+
if qi >= len(group.original.responses):
|
|
191
|
+
continue
|
|
192
|
+
orig_resp = group.original.responses[qi]
|
|
193
|
+
# No reference answer when the original itself was conditionally skipped.
|
|
194
|
+
if isinstance(orig_resp, dict) and orig_resp.get("skipped_by_condition"):
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
ref = extract(orig_resp)
|
|
198
|
+
|
|
199
|
+
k = 0
|
|
200
|
+
agreements = 0
|
|
201
|
+
for v in group.variants:
|
|
202
|
+
if qi >= len(v.responses):
|
|
203
|
+
continue
|
|
204
|
+
v_resp = v.responses[qi]
|
|
205
|
+
# Don't count a variant that skipped this question by condition.
|
|
206
|
+
if isinstance(v_resp, dict) and v_resp.get("skipped_by_condition"):
|
|
207
|
+
continue
|
|
208
|
+
k += 1
|
|
209
|
+
if extract(v_resp) == ref:
|
|
210
|
+
agreements += 1
|
|
211
|
+
|
|
212
|
+
score = agreements / k if k > 0 else 0.0
|
|
213
|
+
per_persona[group.source_name] = score
|
|
214
|
+
per_persona_all.setdefault(group.source_name, []).append(score)
|
|
215
|
+
|
|
216
|
+
if per_persona:
|
|
217
|
+
finding_score = sum(per_persona.values()) / len(per_persona)
|
|
218
|
+
findings.append(
|
|
219
|
+
FindingRobustness(
|
|
220
|
+
question_index=qi,
|
|
221
|
+
score=finding_score,
|
|
222
|
+
classification=_classify_robustness(finding_score),
|
|
223
|
+
per_persona=per_persona,
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
aggregate = sum(f.score for f in findings) / len(findings) if findings else 0.0
|
|
228
|
+
fragile = [f for f in findings if f.score < 0.6]
|
|
229
|
+
|
|
230
|
+
persona_robustness: dict[str, float] = {}
|
|
231
|
+
for name, scores in per_persona_all.items():
|
|
232
|
+
persona_robustness[name] = sum(scores) / len(scores) if scores else 0.0
|
|
233
|
+
|
|
234
|
+
return RobustnessReport(
|
|
235
|
+
findings=findings,
|
|
236
|
+
aggregate_robustness=aggregate,
|
|
237
|
+
fragile_findings=fragile,
|
|
238
|
+
n_personas=len(scorable),
|
|
239
|
+
per_persona_robustness=persona_robustness,
|
|
240
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Panel-result analysis utilities.
|
|
2
|
+
|
|
3
|
+
Two layers live here:
|
|
4
|
+
|
|
5
|
+
* Inspection helpers (:mod:`althing.analysis.inspect`) — schema
|
|
6
|
+
walkers and no-LLM result summaries.
|
|
7
|
+
* Structured-response aggregation (sp-2hpi scaffolding) —
|
|
8
|
+
:mod:`althing.analysis.distribution` computes per-question
|
|
9
|
+
distributions from ``response_schema``-typed responses, and
|
|
10
|
+
:mod:`althing.analysis.subgroup` splits those distributions by
|
|
11
|
+
persona field.
|
|
12
|
+
|
|
13
|
+
Heavy statistical analysis that's entangled with the legacy
|
|
14
|
+
narrative-synthesis pipeline continues to live in
|
|
15
|
+
:mod:`althing.analyze`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from althing.analysis.distribution import (
|
|
19
|
+
InvalidResponseSchemaError,
|
|
20
|
+
coerce_enum_value,
|
|
21
|
+
coerce_scale_value,
|
|
22
|
+
coerce_tagged_themes,
|
|
23
|
+
distribution_for_question,
|
|
24
|
+
)
|
|
25
|
+
from althing.analysis.inspect import (
|
|
26
|
+
InspectReport,
|
|
27
|
+
build_inspect_report,
|
|
28
|
+
format_inspect_text,
|
|
29
|
+
)
|
|
30
|
+
from althing.analysis.subgroup import (
|
|
31
|
+
UnknownPersonaFieldError,
|
|
32
|
+
subgroup_breakdown,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"InspectReport",
|
|
37
|
+
"InvalidResponseSchemaError",
|
|
38
|
+
"UnknownPersonaFieldError",
|
|
39
|
+
"build_inspect_report",
|
|
40
|
+
"coerce_enum_value",
|
|
41
|
+
"coerce_scale_value",
|
|
42
|
+
"coerce_tagged_themes",
|
|
43
|
+
"distribution_for_question",
|
|
44
|
+
"format_inspect_text",
|
|
45
|
+
"subgroup_breakdown",
|
|
46
|
+
]
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Deterministic distributions over structured panelist responses (sp-2hpi).
|
|
2
|
+
|
|
3
|
+
Given a question's ``response_schema`` and the per-panelist raw responses,
|
|
4
|
+
this module computes a distribution summary without any LLM call:
|
|
5
|
+
|
|
6
|
+
* ``scale`` — frequency table, mean, median, stdev, min, max
|
|
7
|
+
* ``enum`` — frequency table over the declared options (zero-filled)
|
|
8
|
+
* ``tagged_themes`` — per-tag frequency over the declared taxonomy
|
|
9
|
+
(zero-filled), with a bucket for off-taxonomy ``other`` themes
|
|
10
|
+
* ``text`` — only ``n`` and ``n_nonempty`` (text carries no
|
|
11
|
+
distribution on its own; extraction lives upstream)
|
|
12
|
+
|
|
13
|
+
The output is intentionally plain dicts/lists so the result can be
|
|
14
|
+
serialized directly into the panel result's ``analysis`` section.
|
|
15
|
+
This module is the scaffolding the narrative-over-stats and
|
|
16
|
+
``--analysis-mode`` layers will ride on top of in follow-up work.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
from collections import Counter
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"InvalidResponseSchemaError",
|
|
27
|
+
"coerce_enum_value",
|
|
28
|
+
"coerce_scale_value",
|
|
29
|
+
"coerce_tagged_themes",
|
|
30
|
+
"distribution_for_question",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_RECOGNIZED_TYPES: frozenset[str] = frozenset({"text", "scale", "enum", "tagged_themes"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InvalidResponseSchemaError(ValueError):
|
|
38
|
+
"""Raised when a response_schema lacks a recognized ``type``.
|
|
39
|
+
|
|
40
|
+
Matches the contract in :mod:`althing.instrument`: a ``response_schema``
|
|
41
|
+
dict without a recognized ``type`` is treated as a legacy inline JSON
|
|
42
|
+
Schema by the parser. This module only computes distributions for the
|
|
43
|
+
*semantic* types, so callers must pre-filter.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def distribution_for_question(
|
|
48
|
+
responses: list[Any],
|
|
49
|
+
response_schema: dict[str, Any],
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
"""Compute a distribution summary for one question's responses.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
responses: Raw per-panelist response values. Shape depends on
|
|
55
|
+
``response_schema['type']``; unparseable entries are counted
|
|
56
|
+
as ``n_invalid``, not dropped silently.
|
|
57
|
+
response_schema: Question-level schema as validated by
|
|
58
|
+
:func:`althing.instrument._validate_response_schema`.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
A plain dict with at least ``type``, ``n`` (total responses),
|
|
62
|
+
``n_valid``, and ``n_invalid``. Shape beyond that is type-specific
|
|
63
|
+
— see module docstring.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
InvalidResponseSchemaError: If ``response_schema`` is missing a
|
|
67
|
+
recognized ``type``.
|
|
68
|
+
"""
|
|
69
|
+
t = response_schema.get("type") if isinstance(response_schema, dict) else None
|
|
70
|
+
if t not in _RECOGNIZED_TYPES:
|
|
71
|
+
raise InvalidResponseSchemaError(f"response_schema must have type in {sorted(_RECOGNIZED_TYPES)}, got {t!r}")
|
|
72
|
+
|
|
73
|
+
n = len(responses)
|
|
74
|
+
if t == "scale":
|
|
75
|
+
return _scale_distribution(responses, response_schema, n)
|
|
76
|
+
if t == "enum":
|
|
77
|
+
return _enum_distribution(responses, response_schema, n)
|
|
78
|
+
if t == "tagged_themes":
|
|
79
|
+
return _tagged_themes_distribution(responses, response_schema, n)
|
|
80
|
+
return _text_distribution(responses, n)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Scale
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def coerce_scale_value(value: Any, lo: int, hi: int) -> int | None:
|
|
89
|
+
"""Return a clamped integer scale value, or ``None`` if uncoerceable.
|
|
90
|
+
|
|
91
|
+
Accepts ints, floats (rounded half-to-even per ``round()``),
|
|
92
|
+
and numeric strings. Values outside [lo, hi] return ``None`` —
|
|
93
|
+
the caller decides whether to clamp or count as invalid.
|
|
94
|
+
"""
|
|
95
|
+
if isinstance(value, bool):
|
|
96
|
+
return None
|
|
97
|
+
if isinstance(value, int):
|
|
98
|
+
iv = value
|
|
99
|
+
elif isinstance(value, float):
|
|
100
|
+
if math.isnan(value) or math.isinf(value):
|
|
101
|
+
return None
|
|
102
|
+
iv = round(value)
|
|
103
|
+
elif isinstance(value, str):
|
|
104
|
+
s = value.strip()
|
|
105
|
+
if not s:
|
|
106
|
+
return None
|
|
107
|
+
try:
|
|
108
|
+
iv = round(float(s))
|
|
109
|
+
except ValueError:
|
|
110
|
+
return None
|
|
111
|
+
else:
|
|
112
|
+
return None
|
|
113
|
+
if iv < lo or iv > hi:
|
|
114
|
+
return None
|
|
115
|
+
return iv
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _scale_distribution(responses: list[Any], schema: dict[str, Any], n: int) -> dict[str, Any]:
|
|
119
|
+
lo = int(schema["min"])
|
|
120
|
+
hi = int(schema["max"])
|
|
121
|
+
coerced = [coerce_scale_value(r, lo, hi) for r in responses]
|
|
122
|
+
valid = [v for v in coerced if v is not None]
|
|
123
|
+
n_valid = len(valid)
|
|
124
|
+
n_invalid = n - n_valid
|
|
125
|
+
|
|
126
|
+
# Zero-filled frequency table across the full integer range so callers
|
|
127
|
+
# can render a histogram without post-processing.
|
|
128
|
+
freq_counter = Counter(valid)
|
|
129
|
+
distribution = [{"value": v, "count": freq_counter.get(v, 0)} for v in range(lo, hi + 1)]
|
|
130
|
+
|
|
131
|
+
stats: dict[str, Any] = {
|
|
132
|
+
"min": lo,
|
|
133
|
+
"max": hi,
|
|
134
|
+
"n_valid": n_valid,
|
|
135
|
+
"n_invalid": n_invalid,
|
|
136
|
+
}
|
|
137
|
+
if n_valid:
|
|
138
|
+
mean = sum(valid) / n_valid
|
|
139
|
+
stats["mean"] = round(mean, 4)
|
|
140
|
+
stats["median"] = _median(valid)
|
|
141
|
+
if n_valid > 1:
|
|
142
|
+
var = sum((v - mean) ** 2 for v in valid) / (n_valid - 1)
|
|
143
|
+
stats["stdev"] = round(math.sqrt(var), 4)
|
|
144
|
+
else:
|
|
145
|
+
stats["stdev"] = 0.0
|
|
146
|
+
stats["observed_min"] = min(valid)
|
|
147
|
+
stats["observed_max"] = max(valid)
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
"type": "scale",
|
|
151
|
+
"n": n,
|
|
152
|
+
"n_valid": n_valid,
|
|
153
|
+
"n_invalid": n_invalid,
|
|
154
|
+
"distribution": distribution,
|
|
155
|
+
"stats": stats,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _median(values: list[int]) -> float:
|
|
160
|
+
s = sorted(values)
|
|
161
|
+
m = len(s)
|
|
162
|
+
mid = m // 2
|
|
163
|
+
if m % 2:
|
|
164
|
+
return float(s[mid])
|
|
165
|
+
return round((s[mid - 1] + s[mid]) / 2, 4)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Enum
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def coerce_enum_value(value: Any, options: list[str]) -> str | None:
|
|
174
|
+
"""Match a free response to an enum option (case-insensitive, trimmed).
|
|
175
|
+
|
|
176
|
+
Returns the canonical option string as declared in ``options`` when a
|
|
177
|
+
match is found, or ``None`` otherwise. An exact match always wins over
|
|
178
|
+
a case-insensitive one.
|
|
179
|
+
"""
|
|
180
|
+
if not isinstance(value, str):
|
|
181
|
+
return None
|
|
182
|
+
s = value.strip()
|
|
183
|
+
if not s:
|
|
184
|
+
return None
|
|
185
|
+
if s in options:
|
|
186
|
+
return s
|
|
187
|
+
lowered = s.lower()
|
|
188
|
+
for opt in options:
|
|
189
|
+
if opt.lower() == lowered:
|
|
190
|
+
return opt
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _enum_distribution(responses: list[Any], schema: dict[str, Any], n: int) -> dict[str, Any]:
|
|
195
|
+
options: list[str] = list(schema["options"])
|
|
196
|
+
coerced = [coerce_enum_value(r, options) for r in responses]
|
|
197
|
+
valid = [v for v in coerced if v is not None]
|
|
198
|
+
n_valid = len(valid)
|
|
199
|
+
n_invalid = n - n_valid
|
|
200
|
+
|
|
201
|
+
counter = Counter(valid)
|
|
202
|
+
distribution = [{"option": opt, "count": counter.get(opt, 0)} for opt in options]
|
|
203
|
+
|
|
204
|
+
top_option: str | None = None
|
|
205
|
+
if n_valid:
|
|
206
|
+
# Stable: options are iterated in declaration order — the first
|
|
207
|
+
# option that ties for the max count is returned.
|
|
208
|
+
top_option = max(options, key=lambda o: counter.get(o, 0))
|
|
209
|
+
if counter.get(top_option, 0) == 0:
|
|
210
|
+
top_option = None
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
"type": "enum",
|
|
214
|
+
"n": n,
|
|
215
|
+
"n_valid": n_valid,
|
|
216
|
+
"n_invalid": n_invalid,
|
|
217
|
+
"options": options,
|
|
218
|
+
"distribution": distribution,
|
|
219
|
+
"top_option": top_option,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
# Tagged themes
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def coerce_tagged_themes(value: Any, taxonomy: list[str], *, multi: bool) -> tuple[list[str], list[str]] | None:
|
|
229
|
+
"""Split a response into (in-taxonomy tags, off-taxonomy tags).
|
|
230
|
+
|
|
231
|
+
Accepts a list of strings, a single string (which becomes a 1-element
|
|
232
|
+
list), or a delimited string (comma / semicolon / pipe / newline). When
|
|
233
|
+
``multi`` is False, only the first tag is kept. Returns ``None`` if the
|
|
234
|
+
input cannot be coerced into any tag list.
|
|
235
|
+
"""
|
|
236
|
+
if value is None:
|
|
237
|
+
return None
|
|
238
|
+
if isinstance(value, str):
|
|
239
|
+
parts: list[str] = []
|
|
240
|
+
buf = value
|
|
241
|
+
for delim in (",", ";", "|", "\n"):
|
|
242
|
+
buf = buf.replace(delim, "\x00")
|
|
243
|
+
parts = [p.strip() for p in buf.split("\x00") if p.strip()]
|
|
244
|
+
elif isinstance(value, (list, tuple)):
|
|
245
|
+
parts = [str(p).strip() for p in value if str(p).strip()]
|
|
246
|
+
else:
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
if not parts:
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
if not multi:
|
|
253
|
+
parts = parts[:1]
|
|
254
|
+
|
|
255
|
+
lowered = {tag.lower(): tag for tag in taxonomy}
|
|
256
|
+
in_tax: list[str] = []
|
|
257
|
+
off_tax: list[str] = []
|
|
258
|
+
for p in parts:
|
|
259
|
+
canon = lowered.get(p.lower())
|
|
260
|
+
if canon is not None:
|
|
261
|
+
if canon not in in_tax:
|
|
262
|
+
in_tax.append(canon)
|
|
263
|
+
else:
|
|
264
|
+
if p not in off_tax:
|
|
265
|
+
off_tax.append(p)
|
|
266
|
+
return in_tax, off_tax
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _tagged_themes_distribution(responses: list[Any], schema: dict[str, Any], n: int) -> dict[str, Any]:
|
|
270
|
+
taxonomy: list[str] = list(schema["taxonomy"])
|
|
271
|
+
multi: bool = bool(schema.get("multi", False))
|
|
272
|
+
|
|
273
|
+
tag_counter: Counter[str] = Counter()
|
|
274
|
+
off_counter: Counter[str] = Counter()
|
|
275
|
+
n_valid = 0
|
|
276
|
+
total_mentions = 0
|
|
277
|
+
for r in responses:
|
|
278
|
+
coerced = coerce_tagged_themes(r, taxonomy, multi=multi)
|
|
279
|
+
if coerced is None:
|
|
280
|
+
continue
|
|
281
|
+
in_tax, off_tax = coerced
|
|
282
|
+
if in_tax or off_tax:
|
|
283
|
+
n_valid += 1
|
|
284
|
+
total_mentions += len(in_tax) + len(off_tax)
|
|
285
|
+
for tag in in_tax:
|
|
286
|
+
tag_counter[tag] += 1
|
|
287
|
+
for tag in off_tax:
|
|
288
|
+
off_counter[tag] += 1
|
|
289
|
+
|
|
290
|
+
n_invalid = n - n_valid
|
|
291
|
+
distribution = [{"theme": tag, "count": tag_counter.get(tag, 0)} for tag in taxonomy]
|
|
292
|
+
off_taxonomy = [
|
|
293
|
+
{"theme": tag, "count": count} for tag, count in sorted(off_counter.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
"type": "tagged_themes",
|
|
298
|
+
"n": n,
|
|
299
|
+
"n_valid": n_valid,
|
|
300
|
+
"n_invalid": n_invalid,
|
|
301
|
+
"multi": multi,
|
|
302
|
+
"taxonomy": taxonomy,
|
|
303
|
+
"distribution": distribution,
|
|
304
|
+
"off_taxonomy": off_taxonomy,
|
|
305
|
+
"total_mentions": total_mentions,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
# Text
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _text_distribution(responses: list[Any], n: int) -> dict[str, Any]:
|
|
315
|
+
n_nonempty = sum(
|
|
316
|
+
1 for r in responses if (isinstance(r, str) and r.strip()) or (r is not None and not isinstance(r, str))
|
|
317
|
+
)
|
|
318
|
+
return {
|
|
319
|
+
"type": "text",
|
|
320
|
+
"n": n,
|
|
321
|
+
"n_valid": n_nonempty,
|
|
322
|
+
"n_invalid": n - n_nonempty,
|
|
323
|
+
}
|