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
@@ -0,0 +1,598 @@
1
+ """CLI-level subgroup analysis for ``althing analyze subgroup``.
2
+
3
+ Takes a saved panel result + a persona YAML/pack (because results store
4
+ persona names, not full dicts) and produces per-subgroup distributions
5
+ for each question that has a ``response_schema``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from althing.analysis.distribution import InvalidResponseSchemaError
15
+ from althing.analysis.subgroup import UnknownPersonaFieldError, subgroup_breakdown
16
+
17
+ __all__ = [
18
+ "AGE_DECADE_5Y_BANDS",
19
+ "AGE_DECADE_BANDS",
20
+ "FieldBreakdown",
21
+ "QuestionSubgroupResult",
22
+ "SubgroupAnalysisResult",
23
+ "analyze_subgroup",
24
+ "format_subgroup_json",
25
+ "format_subgroup_text",
26
+ ]
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Age auto-bin strategies
30
+ # ---------------------------------------------------------------------------
31
+
32
+ AGE_DECADE_BANDS: list[tuple[int, int]] = [
33
+ (18, 27),
34
+ (28, 37),
35
+ (38, 47),
36
+ (48, 57),
37
+ (58, 67),
38
+ (68, 77),
39
+ (78, 99),
40
+ ]
41
+
42
+ AGE_DECADE_5Y_BANDS: list[tuple[int, int]] = [
43
+ (18, 22),
44
+ (23, 27),
45
+ (28, 32),
46
+ (33, 37),
47
+ (38, 42),
48
+ (43, 47),
49
+ (48, 52),
50
+ (53, 57),
51
+ (58, 62),
52
+ (63, 67),
53
+ (68, 72),
54
+ (73, 99),
55
+ ]
56
+
57
+ # Maps special --by token → (actual persona field, age_bands or None)
58
+ _AUTO_BIN_STRATEGIES: dict[str, tuple[str, list[tuple[int, int]] | None]] = {
59
+ "age_decade": ("age", AGE_DECADE_BANDS),
60
+ "age_decade_5y": ("age", AGE_DECADE_5Y_BANDS),
61
+ }
62
+
63
+ _SPARSE_N = 3 # subgroups with n < this trigger a warning
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Result dataclasses
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ @dataclass
72
+ class FieldBreakdown:
73
+ field: str
74
+ n_buckets: int
75
+ buckets: list[dict[str, Any]]
76
+ effect_size: float | None
77
+ effect_size_type: str # "eta-squared" | "cramers-v" | "density-ratio" | "none"
78
+ p_value: float | None
79
+ warnings: list[str]
80
+
81
+
82
+ @dataclass
83
+ class QuestionSubgroupResult:
84
+ question_index: int
85
+ question_text: str
86
+ response_schema_type: str
87
+ n_responses: int
88
+ by_field: list[FieldBreakdown]
89
+
90
+
91
+ @dataclass
92
+ class SubgroupAnalysisResult:
93
+ result_id: str
94
+ fields: list[str]
95
+ per_question: list[QuestionSubgroupResult]
96
+ global_warnings: list[str]
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Effect size helpers
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ def _eta_squared(groups: list[list[float]]) -> float | None:
105
+ """One-way ANOVA eta-squared from grouped numeric values."""
106
+ all_vals = [v for g in groups for v in g]
107
+ n = len(all_vals)
108
+ if n == 0:
109
+ return None
110
+ grand_mean = sum(all_vals) / n
111
+ ss_total = sum((v - grand_mean) ** 2 for v in all_vals)
112
+ if ss_total == 0.0:
113
+ return 0.0
114
+ ss_between = sum(len(g) * ((sum(g) / len(g)) - grand_mean) ** 2 for g in groups if g)
115
+ return round(ss_between / ss_total, 4)
116
+
117
+
118
+ def _cramers_v_contingency(rows: list[list[str]]) -> tuple[float, float, int]:
119
+ """Cramer's V for a groups x categories contingency table.
120
+
121
+ ``rows[i]`` is the list of categorical responses in group i.
122
+ Returns (cramers_v, chi2, df).
123
+ """
124
+ categories: list[str] = sorted({c for row in rows for c in row})
125
+ n_cols = len(categories)
126
+ n_rows = len(rows)
127
+ if n_rows < 2 or n_cols < 2:
128
+ return 0.0, 0.0, 0
129
+
130
+ cat_idx = {c: j for j, c in enumerate(categories)}
131
+ table = [[0] * n_cols for _ in range(n_rows)]
132
+ for i, row in enumerate(rows):
133
+ for val in row:
134
+ j = cat_idx.get(val)
135
+ if j is not None:
136
+ table[i][j] += 1
137
+
138
+ row_totals = [sum(table[i]) for i in range(n_rows)]
139
+ col_totals = [sum(table[i][j] for i in range(n_rows)) for j in range(n_cols)]
140
+ n_total = sum(row_totals)
141
+ if n_total == 0:
142
+ return 0.0, 0.0, 0
143
+
144
+ chi2 = 0.0
145
+ for i in range(n_rows):
146
+ if row_totals[i] == 0:
147
+ continue
148
+ for j in range(n_cols):
149
+ if col_totals[j] == 0:
150
+ continue
151
+ exp = (row_totals[i] * col_totals[j]) / n_total
152
+ diff = table[i][j] - exp
153
+ chi2 += diff * diff / exp
154
+
155
+ df = (n_rows - 1) * (n_cols - 1)
156
+ if df <= 0 or n_total <= 0:
157
+ return 0.0, chi2, df
158
+
159
+ denom = n_total * min(n_rows - 1, n_cols - 1)
160
+ if denom <= 0:
161
+ return 0.0, chi2, df
162
+
163
+ v = math.sqrt(chi2 / denom)
164
+ return round(max(0.0, min(1.0, v)), 4), round(chi2, 4), df
165
+
166
+
167
+ def _chi2_sf(x: float, df: int) -> float:
168
+ """Survival function (1 - CDF) of the chi-squared distribution."""
169
+ from althing.stats import _chi2_sf as _stats_chi2_sf # type: ignore[attr-defined]
170
+
171
+ return _stats_chi2_sf(x, df)
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Response value extraction
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ def _response_value(resp: dict[str, Any]) -> Any:
180
+ """Extract the typed response value from a stored response dict."""
181
+ if resp.get("structured"):
182
+ return resp.get("response")
183
+ extraction = resp.get("extraction")
184
+ if extraction is not None:
185
+ return extraction
186
+ return resp.get("response")
187
+
188
+
189
+ def _flatten_structured_value(val: Any, schema_type: str) -> Any:
190
+ """Try to unwrap a dict-wrapped structured response to a scalar/list."""
191
+ if not isinstance(val, dict):
192
+ return val
193
+ if schema_type == "scale":
194
+ for key in ("score", "rating", "value", "answer", "response"):
195
+ v = val.get(key)
196
+ if isinstance(v, (int, float)):
197
+ return v
198
+ elif schema_type == "enum":
199
+ for key in ("option", "answer", "value", "choice", "response"):
200
+ v = val.get(key)
201
+ if isinstance(v, str):
202
+ return v
203
+ elif schema_type == "tagged_themes":
204
+ for key in ("themes", "tags", "values", "response"):
205
+ v = val.get(key)
206
+ if isinstance(v, list):
207
+ return v
208
+ return val
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Core analysis
213
+ # ---------------------------------------------------------------------------
214
+
215
+
216
+ def _resolve_field_and_bands(by_token: str) -> tuple[str, list[tuple[int, int]] | None]:
217
+ """Map a --by token to (persona_field, age_bands or None)."""
218
+ if by_token in _AUTO_BIN_STRATEGIES:
219
+ return _AUTO_BIN_STRATEGIES[by_token]
220
+ return by_token, None
221
+
222
+
223
+ def _build_field_breakdown(
224
+ by_token: str,
225
+ responses_per_panelist: list[Any],
226
+ personas: list[dict[str, Any]],
227
+ response_schema: dict[str, Any],
228
+ ) -> FieldBreakdown:
229
+ persona_field, age_bands = _resolve_field_and_bands(by_token)
230
+ schema_type = response_schema.get("type", "text")
231
+ warnings: list[str] = []
232
+
233
+ try:
234
+ breakdown = subgroup_breakdown(
235
+ responses_per_panelist,
236
+ personas,
237
+ field=persona_field,
238
+ response_schema=response_schema,
239
+ age_bands=age_bands,
240
+ )
241
+ except UnknownPersonaFieldError:
242
+ return FieldBreakdown(
243
+ field=by_token,
244
+ n_buckets=0,
245
+ buckets=[],
246
+ effect_size=None,
247
+ effect_size_type="none",
248
+ p_value=None,
249
+ warnings=[f"Persona field '{persona_field}' not found in any persona."],
250
+ )
251
+ except InvalidResponseSchemaError as exc:
252
+ return FieldBreakdown(
253
+ field=by_token,
254
+ n_buckets=0,
255
+ buckets=[],
256
+ effect_size=None,
257
+ effect_size_type="none",
258
+ p_value=None,
259
+ warnings=[f"Cannot analyze question with response_schema: {exc}"],
260
+ )
261
+
262
+ buckets = breakdown["buckets"]
263
+
264
+ # Sparse subgroup warnings
265
+ sparse = [b["label"] for b in buckets if b["n"] < _SPARSE_N and b["n"] > 0]
266
+ if sparse:
267
+ warnings.append(
268
+ f"Sparse subgroup(s) (n < {_SPARSE_N}): {', '.join(sparse)}. "
269
+ "Effect-size and significance tests suppressed for sparse groups."
270
+ )
271
+
272
+ non_sparse_buckets = [b for b in buckets if b["n"] >= _SPARSE_N]
273
+ suppress_stats = len(non_sparse_buckets) < 2
274
+
275
+ effect_size: float | None = None
276
+ effect_size_type = "none"
277
+ p_value: float | None = None
278
+
279
+ if not suppress_stats:
280
+ if schema_type == "scale":
281
+ effect_size, p_value = _scale_effect_size(non_sparse_buckets, response_schema)
282
+ effect_size_type = "eta-squared"
283
+ elif schema_type == "enum":
284
+ effect_size, p_value = _enum_effect_size(non_sparse_buckets)
285
+ effect_size_type = "cramers-v"
286
+ elif schema_type == "tagged_themes":
287
+ effect_size_type = "density-ratio"
288
+ # No single effect-size for multi-tag; leave None (per-tag density in buckets)
289
+
290
+ return FieldBreakdown(
291
+ field=by_token,
292
+ n_buckets=breakdown["n_buckets"],
293
+ buckets=buckets,
294
+ effect_size=effect_size,
295
+ effect_size_type=effect_size_type,
296
+ p_value=p_value,
297
+ warnings=warnings,
298
+ )
299
+
300
+
301
+ def _scale_effect_size(
302
+ non_sparse_buckets: list[dict[str, Any]],
303
+ response_schema: dict[str, Any],
304
+ ) -> tuple[float | None, float | None]:
305
+ groups: list[list[float]] = []
306
+ for bucket in non_sparse_buckets:
307
+ dist = bucket.get("distribution", {})
308
+ vals: list[float] = []
309
+ for entry in dist.get("distribution", []):
310
+ v = entry.get("value")
311
+ c = entry.get("count", 0)
312
+ if isinstance(v, (int, float)) and c > 0:
313
+ vals.extend([float(v)] * c)
314
+ groups.append(vals)
315
+ eta_sq = _eta_squared(groups)
316
+ return eta_sq, None # F-test p-value requires F-distribution; omitted
317
+
318
+
319
+ def _enum_effect_size(
320
+ non_sparse_buckets: list[dict[str, Any]],
321
+ ) -> tuple[float | None, float | None]:
322
+ rows: list[list[str]] = []
323
+ for bucket in non_sparse_buckets:
324
+ dist = bucket.get("distribution", {})
325
+ row: list[str] = []
326
+ for entry in dist.get("distribution", []):
327
+ opt = entry.get("option", "")
328
+ cnt = entry.get("count", 0)
329
+ row.extend([opt] * cnt)
330
+ rows.append(row)
331
+ v, chi2, df = _cramers_v_contingency(rows)
332
+ p_value: float | None = None
333
+ if df > 0 and chi2 > 0:
334
+ import contextlib
335
+
336
+ with contextlib.suppress(Exception):
337
+ p_value = round(_chi2_sf(chi2, df), 4)
338
+ return v if v > 0 else None, p_value
339
+
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # Public entry point
343
+ # ---------------------------------------------------------------------------
344
+
345
+
346
+ def analyze_subgroup(
347
+ data: dict[str, Any],
348
+ personas: list[dict[str, Any]],
349
+ by_tokens: list[str],
350
+ *,
351
+ question_indices: list[int] | None = None,
352
+ ) -> SubgroupAnalysisResult:
353
+ """Run subgroup analysis on a saved panel result.
354
+
355
+ Args:
356
+ data: Panel result dict (from result JSON file or MCP data).
357
+ personas: Full persona dicts to match against result by name.
358
+ by_tokens: List of persona fields (or auto-bin tokens like
359
+ ``age_decade``) to group by.
360
+ question_indices: Optional filter — only analyze these question
361
+ indices (0-based). None means all questions.
362
+
363
+ Returns:
364
+ SubgroupAnalysisResult with per-question breakdowns.
365
+ """
366
+ result_id = data.get("id", "unknown")
367
+ raw_results = data.get("results") or []
368
+ saved_questions = data.get("questions") or []
369
+ global_warnings: list[str] = []
370
+
371
+ # Build name → persona dict lookup
372
+ persona_by_name: dict[str, dict[str, Any]] = {p.get("name", ""): p for p in personas if p.get("name")}
373
+
374
+ # Align panelists with their full persona dicts
375
+ ordered_personas: list[dict[str, Any]] = []
376
+ ordered_raw_responses: list[list[dict[str, Any]]] = []
377
+
378
+ matched = 0
379
+ for entry in raw_results:
380
+ name = entry.get("persona", "")
381
+ persona_dict = persona_by_name.get(name)
382
+ if persona_dict is None:
383
+ global_warnings.append(f"Persona '{name}' not found in provided --personas file. Skipped.")
384
+ continue
385
+ matched += 1
386
+ ordered_personas.append(persona_dict)
387
+ ordered_raw_responses.append(entry.get("responses") or [])
388
+
389
+ if not matched:
390
+ global_warnings.append(
391
+ "No panelist names matched the provided personas. Check that --personas is the same file used for the run."
392
+ )
393
+ return SubgroupAnalysisResult(
394
+ result_id=result_id,
395
+ fields=by_tokens,
396
+ per_question=[],
397
+ global_warnings=global_warnings,
398
+ )
399
+
400
+ # Derive question list (from saved or from first panelist)
401
+ if saved_questions:
402
+ questions = saved_questions
403
+ else:
404
+ questions = _infer_questions(ordered_raw_responses)
405
+
406
+ per_question: list[QuestionSubgroupResult] = []
407
+ for qi, q in enumerate(questions):
408
+ if question_indices is not None and qi not in question_indices:
409
+ continue
410
+
411
+ q_text = q.get("text", f"Question {qi + 1}") if isinstance(q, dict) else str(q)
412
+ response_schema: dict[str, Any] = {}
413
+ if isinstance(q, dict):
414
+ response_schema = q.get("response_schema") or q.get("extraction_schema") or {}
415
+
416
+ schema_type = response_schema.get("type", "text") if isinstance(response_schema, dict) else "text"
417
+ if not isinstance(response_schema, dict) or schema_type not in (
418
+ "scale",
419
+ "enum",
420
+ "tagged_themes",
421
+ ):
422
+ # text-only questions: nothing meaningful to aggregate
423
+ continue
424
+
425
+ # Extract the typed response value for each matched panelist
426
+ per_panelist_values: list[Any] = []
427
+ for panelist_responses in ordered_raw_responses:
428
+ if qi < len(panelist_responses):
429
+ resp = panelist_responses[qi]
430
+ if resp.get("error") or resp.get("follow_up"):
431
+ per_panelist_values.append(None)
432
+ else:
433
+ raw_val = _response_value(resp)
434
+ per_panelist_values.append(_flatten_structured_value(raw_val, schema_type))
435
+ else:
436
+ per_panelist_values.append(None)
437
+
438
+ by_breakdowns: list[FieldBreakdown] = []
439
+ for token in by_tokens:
440
+ bd = _build_field_breakdown(
441
+ token,
442
+ per_panelist_values,
443
+ ordered_personas,
444
+ response_schema,
445
+ )
446
+ by_breakdowns.append(bd)
447
+
448
+ per_question.append(
449
+ QuestionSubgroupResult(
450
+ question_index=qi,
451
+ question_text=q_text,
452
+ response_schema_type=schema_type,
453
+ n_responses=sum(1 for v in per_panelist_values if v is not None),
454
+ by_field=by_breakdowns,
455
+ )
456
+ )
457
+
458
+ return SubgroupAnalysisResult(
459
+ result_id=result_id,
460
+ fields=by_tokens,
461
+ per_question=per_question,
462
+ global_warnings=global_warnings,
463
+ )
464
+
465
+
466
+ def _infer_questions(ordered_raw_responses: list[list[dict[str, Any]]]) -> list[dict[str, Any]]:
467
+ """Derive question list from first panelist's responses."""
468
+ if not ordered_raw_responses:
469
+ return []
470
+ seen: list[dict[str, Any]] = []
471
+ seen_texts: set[str] = set()
472
+ for resp in ordered_raw_responses[0]:
473
+ if resp.get("follow_up"):
474
+ continue
475
+ q_text = resp.get("question", "")
476
+ if q_text and q_text not in seen_texts:
477
+ seen.append({"text": q_text})
478
+ seen_texts.add(q_text)
479
+ return seen
480
+
481
+
482
+ # ---------------------------------------------------------------------------
483
+ # Text formatter
484
+ # ---------------------------------------------------------------------------
485
+
486
+ _EFFECT_LABELS = {
487
+ "eta-squared": "η²",
488
+ "cramers-v": "V",
489
+ "density-ratio": "density",
490
+ "none": "",
491
+ }
492
+
493
+
494
+ def format_subgroup_text(result: SubgroupAnalysisResult) -> str:
495
+ lines: list[str] = []
496
+ lines.append(f"Subgroup analysis — {result.result_id}")
497
+ lines.append(f"Grouping by: {', '.join(result.fields)}")
498
+ if result.global_warnings:
499
+ for w in result.global_warnings:
500
+ lines.append(f" WARNING: {w}")
501
+ lines.append("")
502
+
503
+ if not result.per_question:
504
+ lines.append("No structured questions found. Run the panel with response_schema to enable subgroup analysis.")
505
+ return "\n".join(lines)
506
+
507
+ for qr in result.per_question:
508
+ lines.append(f"Q{qr.question_index + 1}: {qr.question_text} [{qr.response_schema_type}] (n={qr.n_responses})")
509
+ for bd in qr.by_field:
510
+ label = _EFFECT_LABELS.get(bd.effect_size_type, "")
511
+ if bd.effect_size is not None:
512
+ eff_str = f"{label}={bd.effect_size:.3f}"
513
+ if bd.p_value is not None:
514
+ eff_str += f" p={bd.p_value:.4f}"
515
+ lines.append(f" --by {bd.field} ({eff_str})")
516
+ else:
517
+ lines.append(f" --by {bd.field}")
518
+ for w in bd.warnings:
519
+ lines.append(f" ! {w}")
520
+ for bucket in bd.buckets:
521
+ dist = bucket.get("distribution", {})
522
+ lines.append(_format_bucket(bucket, dist, qr.response_schema_type))
523
+ lines.append("")
524
+
525
+ return "\n".join(lines)
526
+
527
+
528
+ def _format_bucket(bucket: dict[str, Any], dist: dict[str, Any], schema_type: str) -> str:
529
+ label = bucket.get("label", "?")
530
+ n = bucket.get("n", 0)
531
+ parts: list[str] = [f" {label} (n={n})"]
532
+
533
+ if schema_type == "scale":
534
+ stats = dist.get("stats", {})
535
+ mean = stats.get("mean")
536
+ stdev = stats.get("stdev")
537
+ if mean is not None:
538
+ parts.append(f" mean={mean:.2f}")
539
+ if stdev is not None:
540
+ parts.append(f" sd={stdev:.2f}")
541
+ elif schema_type == "enum":
542
+ entries = dist.get("distribution", [])
543
+ total = sum(e.get("count", 0) for e in entries)
544
+ parts.append(
545
+ " "
546
+ + " ".join(
547
+ f"{e['option']}={e['count']}" + (f"({e['count'] / total:.0%})" if total else "")
548
+ for e in entries
549
+ if e.get("count", 0) > 0
550
+ )
551
+ )
552
+ elif schema_type == "tagged_themes":
553
+ entries = dist.get("distribution", [])
554
+ total_resp = dist.get("n_valid", n)
555
+ theme_parts = []
556
+ for e in entries:
557
+ cnt = e.get("count", 0)
558
+ tag = e.get("tag", "?")
559
+ if cnt > 0:
560
+ density = cnt / total_resp if total_resp > 0 else 0
561
+ theme_parts.append(f"{tag}={density:.0%}")
562
+ parts.append(" " + " ".join(theme_parts) if theme_parts else " (no themes)")
563
+
564
+ return "".join(parts)
565
+
566
+
567
+ # ---------------------------------------------------------------------------
568
+ # JSON formatter
569
+ # ---------------------------------------------------------------------------
570
+
571
+
572
+ def format_subgroup_json(result: SubgroupAnalysisResult) -> dict[str, Any]:
573
+ return {
574
+ "result_id": result.result_id,
575
+ "fields": result.fields,
576
+ "global_warnings": result.global_warnings,
577
+ "per_question": [
578
+ {
579
+ "question_index": qr.question_index,
580
+ "question_text": qr.question_text,
581
+ "response_schema_type": qr.response_schema_type,
582
+ "n_responses": qr.n_responses,
583
+ "by_field": [
584
+ {
585
+ "field": bd.field,
586
+ "n_buckets": bd.n_buckets,
587
+ "effect_size_type": bd.effect_size_type,
588
+ "effect_size": bd.effect_size,
589
+ "p_value": bd.p_value,
590
+ "warnings": bd.warnings,
591
+ "buckets": bd.buckets,
592
+ }
593
+ for bd in qr.by_field
594
+ ],
595
+ }
596
+ for qr in result.per_question
597
+ ],
598
+ }