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.
Files changed (156) hide show
  1. althing/__init__.py +46 -0
  2. althing/__main__.py +9 -0
  3. althing/__version__.py +1 -0
  4. althing/_compat_env.py +27 -0
  5. althing/_runners.py +953 -0
  6. althing/agent_assets/__init__.py +3 -0
  7. althing/agent_assets/commands/althing-poll.md +75 -0
  8. althing/agent_assets/skills/concept-test/SKILL.md +72 -0
  9. althing/agent_assets/skills/focus-group/SKILL.md +77 -0
  10. althing/agent_assets/skills/name-test/SKILL.md +62 -0
  11. althing/agent_assets/skills/pricing-probe/SKILL.md +75 -0
  12. althing/agent_assets/skills/survey-prescreen/SKILL.md +76 -0
  13. althing/aggregation.py +240 -0
  14. althing/analysis/__init__.py +46 -0
  15. althing/analysis/distribution.py +323 -0
  16. althing/analysis/inspect.py +586 -0
  17. althing/analysis/subgroup.py +168 -0
  18. althing/analysis/subgroup_cli.py +598 -0
  19. althing/analyze.py +839 -0
  20. althing/attachments/__init__.py +77 -0
  21. althing/attachments/filter.py +67 -0
  22. althing/attachments/models.py +50 -0
  23. althing/attachments/pdf.py +537 -0
  24. althing/attachments/store.py +111 -0
  25. althing/calibration.py +239 -0
  26. althing/checkpoint.py +763 -0
  27. althing/cli/__init__.py +1 -0
  28. althing/cli/commands.py +8080 -0
  29. althing/cli/mcp_install.py +487 -0
  30. althing/cli/output.py +106 -0
  31. althing/cli/parser.py +1706 -0
  32. althing/cli/progress.py +93 -0
  33. althing/cli/repl.py +122 -0
  34. althing/cli/slash.py +197 -0
  35. althing/conditions.py +261 -0
  36. althing/convergence.py +1091 -0
  37. althing/cost.py +950 -0
  38. althing/cost_summary.py +452 -0
  39. althing/credentials.py +311 -0
  40. althing/data/__init__.py +8 -0
  41. althing/data/synthbench-snapshot.json +120 -0
  42. althing/diff.py +331 -0
  43. althing/domains.py +284 -0
  44. althing/ensemble.py +922 -0
  45. althing/fetch/__init__.py +38 -0
  46. althing/fetch/cache.py +360 -0
  47. althing/fetch/ladder.py +437 -0
  48. althing/fetch/lower.py +199 -0
  49. althing/fetch/perimeter.py +494 -0
  50. althing/instrument.py +751 -0
  51. althing/integrations/__init__.py +21 -0
  52. althing/integrations/composio.py +345 -0
  53. althing/llm/__init__.py +32 -0
  54. althing/llm/aliases.py +138 -0
  55. althing/llm/capabilities.py +86 -0
  56. althing/llm/client.py +267 -0
  57. althing/llm/errors.py +162 -0
  58. althing/llm/fast_default.py +71 -0
  59. althing/llm/models.py +340 -0
  60. althing/llm/providers/__init__.py +18 -0
  61. althing/llm/providers/_anthropic_format.py +281 -0
  62. althing/llm/providers/_openai_format.py +381 -0
  63. althing/llm/providers/anthropic.py +172 -0
  64. althing/llm/providers/base.py +60 -0
  65. althing/llm/providers/gemini.py +107 -0
  66. althing/llm/providers/openai_compat.py +102 -0
  67. althing/llm/providers/openrouter.py +357 -0
  68. althing/llm/providers/xai.py +98 -0
  69. althing/llm/retry.py +113 -0
  70. althing/logging_config.py +80 -0
  71. althing/main.py +308 -0
  72. althing/mcp/__init__.py +7 -0
  73. althing/mcp/compat.py +110 -0
  74. althing/mcp/data.py +700 -0
  75. althing/mcp/sampling.py +380 -0
  76. althing/mcp/server.py +3371 -0
  77. althing/metadata.py +214 -0
  78. althing/metadata_migrations.py +70 -0
  79. althing/orchestrator.py +2119 -0
  80. althing/pack_diff.py +271 -0
  81. althing/packs/__init__.py +3 -0
  82. althing/packs/ai-eval-buyers.yaml +247 -0
  83. althing/packs/broad-professionals.yaml +251 -0
  84. althing/packs/developer.yaml +271 -0
  85. althing/packs/enterprise-ai-buyers.yaml +245 -0
  86. althing/packs/enterprise-buyer.yaml +252 -0
  87. althing/packs/general-consumer.yaml +271 -0
  88. althing/packs/healthcare-patient.yaml +271 -0
  89. althing/packs/instruments/__init__.py +1 -0
  90. althing/packs/instruments/churn-diagnosis.yaml +73 -0
  91. althing/packs/instruments/feature-prioritization.yaml +75 -0
  92. althing/packs/instruments/general-survey.yaml +46 -0
  93. althing/packs/instruments/happiness-probe.yaml +41 -0
  94. althing/packs/instruments/landing-page-comprehension.yaml +75 -0
  95. althing/packs/instruments/market-research.yaml +50 -0
  96. althing/packs/instruments/name-test.yaml +74 -0
  97. althing/packs/instruments/pricing-discovery.yaml +80 -0
  98. althing/packs/instruments/product-feedback.yaml +46 -0
  99. althing/packs/job-seekers.yaml +188 -0
  100. althing/packs/market-research-critics.yaml +217 -0
  101. althing/packs/product-research.yaml +248 -0
  102. althing/packs/recruiters-talent.yaml +264 -0
  103. althing/packs/skeptical-executives.yaml +227 -0
  104. althing/packs/startup-founder.yaml +242 -0
  105. althing/packs/students.yaml +352 -0
  106. althing/persistence.py +510 -0
  107. althing/perturbation.py +333 -0
  108. althing/plugins/__init__.py +34 -0
  109. althing/plugins/hooks.py +159 -0
  110. althing/plugins/lint.py +352 -0
  111. althing/plugins/manager.py +176 -0
  112. althing/plugins/manifest.py +100 -0
  113. althing/plugins/registry.py +66 -0
  114. althing/poll_summary.py +1195 -0
  115. althing/preflight.py +155 -0
  116. althing/profiles.py +264 -0
  117. althing/prompts.py +311 -0
  118. althing/py.typed +0 -0
  119. althing/question_budget.py +140 -0
  120. althing/registry/__init__.py +178 -0
  121. althing/registry/cache.py +223 -0
  122. althing/registry/fetch.py +104 -0
  123. althing/registry/github.py +212 -0
  124. althing/reporting/__init__.py +17 -0
  125. althing/reporting/loader.py +69 -0
  126. althing/reporting/markdown.py +450 -0
  127. althing/response_coercion.py +161 -0
  128. althing/routing.py +132 -0
  129. althing/runtime.py +552 -0
  130. althing/schemas/__init__.py +53 -0
  131. althing/schemas/v1.0.0.json +161 -0
  132. althing/sdk.py +1458 -0
  133. althing/stats.py +1522 -0
  134. althing/structured/__init__.py +5 -0
  135. althing/structured/models.py +149 -0
  136. althing/structured/output.py +376 -0
  137. althing/structured/retry.py +170 -0
  138. althing/structured/schemas.py +127 -0
  139. althing/structured/validate.py +303 -0
  140. althing/structured/verdict.py +146 -0
  141. althing/synthbench.py +768 -0
  142. althing/synthbench_submit.py +582 -0
  143. althing/synthesis.py +1529 -0
  144. althing/synthesis_recovery.py +467 -0
  145. althing/templates.py +179 -0
  146. althing/text_width.py +126 -0
  147. althing-1.7.0.dist-info/METADATA +1473 -0
  148. althing-1.7.0.dist-info/RECORD +156 -0
  149. althing-1.7.0.dist-info/WHEEL +5 -0
  150. althing-1.7.0.dist-info/entry_points.txt +3 -0
  151. althing-1.7.0.dist-info/licenses/LICENSE +21 -0
  152. althing-1.7.0.dist-info/top_level.txt +3 -0
  153. synth_panel/__init__.py +60 -0
  154. synth_panel/__main__.py +17 -0
  155. synthpanel/__init__.py +60 -0
  156. 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
+ }