@ishlabs/cli 0.20.0 → 0.21.0

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,538 @@
1
+ /**
2
+ * Pure filter pipeline for `ish study results`.
3
+ *
4
+ * Input : the raw `GET /studies/{id}` payload, the raw
5
+ * `GET /studies/{id}/participants` payload, the raw
6
+ * `GET /studies/{id}/frames` payload (or [] when --frame wasn't
7
+ * passed), and a `ResultsFilters` struct from the command surface.
8
+ * Output : a `FilteredResults` struct — the trimmed participant graph,
9
+ * pre-filter counts on `totals_unfiltered`, and a `warnings[]`
10
+ * list of modality-mismatch notes for the surface to surface on
11
+ * stderr.
12
+ *
13
+ * Has no IO and no console side-effects — the caller (study results action)
14
+ * owns network calls and stderr; we just compute. That keeps the function
15
+ * trivially unit-testable and lets the projection builders (T3) consume the
16
+ * same shape without re-walking the graph.
17
+ *
18
+ * Defensive null handling is the load-bearing piece. See the plan's
19
+ * "Defensive handling of nullable fields" section — read it before editing
20
+ * any predicate.
21
+ */
22
+ import { resolveId } from "./alias-store.js";
23
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
24
+ function asRecord(v) {
25
+ return v && typeof v === "object" && !Array.isArray(v)
26
+ ? v
27
+ : null;
28
+ }
29
+ function asArray(v) {
30
+ return Array.isArray(v) ? v : [];
31
+ }
32
+ function asString(v) {
33
+ return typeof v === "string" && v.length > 0 ? v : null;
34
+ }
35
+ function normaliseFrames(rawFrames) {
36
+ const out = [];
37
+ for (const row of rawFrames) {
38
+ const r = asRecord(row);
39
+ if (!r)
40
+ continue;
41
+ const id = asString(r.id);
42
+ if (!id)
43
+ continue;
44
+ const versions = [];
45
+ for (const v of asArray(r.versions)) {
46
+ const vr = asRecord(v);
47
+ const vid = vr ? asString(vr.id) : null;
48
+ if (vid)
49
+ versions.push({ id: vid });
50
+ }
51
+ out.push({ id, name: asString(r.name), versions });
52
+ }
53
+ return out;
54
+ }
55
+ function normaliseAssignments(rawAssignments) {
56
+ const out = [];
57
+ for (const row of rawAssignments) {
58
+ const r = asRecord(row);
59
+ if (!r)
60
+ continue;
61
+ const id = asString(r.id);
62
+ if (!id)
63
+ continue;
64
+ const side = r.side === "a" || r.side === "b" ? r.side : null;
65
+ out.push({ id, name: asString(r.name), side });
66
+ }
67
+ return out;
68
+ }
69
+ function normaliseIterations(rawIterations) {
70
+ const out = [];
71
+ for (const row of rawIterations) {
72
+ const r = asRecord(row);
73
+ if (!r)
74
+ continue;
75
+ const id = asString(r.id);
76
+ if (!id)
77
+ continue;
78
+ out.push({ id, label: asString(r.label) });
79
+ }
80
+ return out;
81
+ }
82
+ /** Throw a ValidationError-tagged Error (the wrapper maps it to exit 2). */
83
+ function validationError(message) {
84
+ const err = new Error(message);
85
+ err.name = "ValidationError";
86
+ return err;
87
+ }
88
+ /** Resolve `--frame <ref>` into a set of frame_version_ids.
89
+ *
90
+ * Branches on shape:
91
+ * - `f-<hex>` → frame alias, resolves to a Frame UUID via alias-store
92
+ * - full UUID → first try Frame.id; if no match, treat as frame_version_id
93
+ * - anything else → case-insensitive substring against `frame.name`
94
+ *
95
+ * Ambiguous substring matches (>1 frame) throw with the candidate list.
96
+ * No matches at all throw a not-found ValidationError listing alternatives.
97
+ */
98
+ function resolveFrameRef(ref, frames) {
99
+ const matched = new Set();
100
+ const lookup = new Map();
101
+ const indexFrame = (frame) => {
102
+ for (const v of frame.versions) {
103
+ matched.add(v.id);
104
+ lookup.set(v.id, { frame_id: frame.id, frame_label: frame.name });
105
+ }
106
+ };
107
+ // Build a frame_version_id → frame lookup once; reused on the
108
+ // "is this a frame_version_id?" path.
109
+ const fvToFrame = new Map();
110
+ for (const f of frames) {
111
+ for (const v of f.versions)
112
+ fvToFrame.set(v.id, f);
113
+ }
114
+ // 1. Frame alias (f-…) — resolve to a Frame UUID via alias-store.
115
+ if (/^f-[0-9a-f]{3,}$/i.test(ref)) {
116
+ const resolved = resolveId(ref); // throws not_found if alias is unknown
117
+ const frame = frames.find((f) => f.id === resolved);
118
+ if (!frame) {
119
+ throw validationError(`Frame alias "${ref}" resolved to ${resolved}, but no frame with that id exists on this study.`);
120
+ }
121
+ indexFrame(frame);
122
+ return { matchedFrameVersionIds: matched, frameVersionLookup: lookup };
123
+ }
124
+ // 2. Full UUID — try Frame.id first, then frame_version_id.
125
+ if (UUID_RE.test(ref)) {
126
+ const frame = frames.find((f) => f.id === ref);
127
+ if (frame) {
128
+ indexFrame(frame);
129
+ return { matchedFrameVersionIds: matched, frameVersionLookup: lookup };
130
+ }
131
+ const parentByVersion = fvToFrame.get(ref);
132
+ if (parentByVersion) {
133
+ // Treat as frame_version_id — match only this single version.
134
+ matched.add(ref);
135
+ lookup.set(ref, { frame_id: parentByVersion.id, frame_label: parentByVersion.name });
136
+ return { matchedFrameVersionIds: matched, frameVersionLookup: lookup };
137
+ }
138
+ throw validationError(`No frame or frame_version matches "${ref}" on this study. ` +
139
+ `Run \`ish study results <id> --get frames\` (or check the frames endpoint) to see the list.`);
140
+ }
141
+ // 3. Case-insensitive substring against frame.name. The plan calls
142
+ // for the ambiguous-match error to list candidates explicitly.
143
+ const needle = ref.toLowerCase();
144
+ const candidates = frames.filter((f) => f.name && f.name.toLowerCase().includes(needle));
145
+ if (candidates.length === 0) {
146
+ const available = frames
147
+ .map((f) => f.name)
148
+ .filter((n) => typeof n === "string")
149
+ .slice(0, 10);
150
+ const hint = available.length > 0
151
+ ? ` Available frames: ${available.join(", ")}.`
152
+ : " This study has no named frames yet.";
153
+ throw validationError(`--frame "${ref}" matched no frames on this study.${hint}`);
154
+ }
155
+ if (candidates.length > 1) {
156
+ const names = candidates.map((c) => c.name).filter((n) => !!n);
157
+ throw validationError(`--frame "${ref}" is ambiguous — matched ${candidates.length} frames: ${names.join(", ")}. ` +
158
+ `Use a more specific substring, a full Frame UUID, or an \`f-…\` alias.`);
159
+ }
160
+ indexFrame(candidates[0]);
161
+ return { matchedFrameVersionIds: matched, frameVersionLookup: lookup };
162
+ }
163
+ /** Resolve `--assignment <ref>` into a set of assignment_ids.
164
+ *
165
+ * UUID → exact match. Otherwise case-insensitive substring against name.
166
+ * Unlike --frame, multiple matches are NOT ambiguous — the user might
167
+ * legitimately want every "Sign up *" assignment; we just OR them.
168
+ */
169
+ function resolveAssignmentRef(ref, assignments) {
170
+ const out = new Set();
171
+ if (UUID_RE.test(ref)) {
172
+ const m = assignments.find((a) => a.id === ref);
173
+ if (!m) {
174
+ throw validationError(`No assignment matches "${ref}" on this study.`);
175
+ }
176
+ out.add(m.id);
177
+ return out;
178
+ }
179
+ const needle = ref.toLowerCase();
180
+ for (const a of assignments) {
181
+ if (a.name && a.name.toLowerCase().includes(needle))
182
+ out.add(a.id);
183
+ }
184
+ if (out.size === 0) {
185
+ const names = assignments
186
+ .map((a) => a.name)
187
+ .filter((n) => !!n)
188
+ .slice(0, 10);
189
+ const hint = names.length > 0 ? ` Available: ${names.join(", ")}.` : "";
190
+ throw validationError(`--assignment "${ref}" matched no assignments on this study.${hint}`);
191
+ }
192
+ return out;
193
+ }
194
+ /** Resolve `--iteration <ref>` to a single iteration_id, or null if no
195
+ * match (caller errors). UUID-exact or label-exact. */
196
+ function resolveIterationRef(ref, iterations) {
197
+ if (UUID_RE.test(ref)) {
198
+ const m = iterations.find((i) => i.id === ref);
199
+ if (!m) {
200
+ throw validationError(`No iteration matches "${ref}" on this study.`);
201
+ }
202
+ return m.id;
203
+ }
204
+ // Labels are uppercase A-Z by backend constraint; do a case-insensitive
205
+ // exact compare so `--iteration a` works as well as `--iteration A`.
206
+ const upper = ref.toUpperCase();
207
+ const m = iterations.find((i) => (i.label ?? "").toUpperCase() === upper);
208
+ if (!m) {
209
+ const labels = iterations
210
+ .map((i) => i.label)
211
+ .filter((n) => !!n);
212
+ const hint = labels.length > 0 ? ` Available labels: ${labels.join(", ")}.` : "";
213
+ throw validationError(`--iteration "${ref}" matched no iterations on this study.${hint}`);
214
+ }
215
+ return m.id;
216
+ }
217
+ /** Resolve --participant to a UUID (alias or full UUID, via resolveId). */
218
+ function resolveParticipantRef(ref) {
219
+ // resolveId throws not_found for unknown aliases / invalid IDs.
220
+ return resolveId(ref);
221
+ }
222
+ /** Get the modality of the study. Returns a normalised lowercase string
223
+ * ("interactive" | "chat" | "video" | "audio" | "text" | "image" |
224
+ * "document"), or "unknown" if missing. */
225
+ function getModality(study) {
226
+ const m = asString(study.modality);
227
+ return m ? m.toLowerCase() : "unknown";
228
+ }
229
+ /** True if the study is participant_pair chat (where --side is meaningful). */
230
+ function isParticipantPair(study) {
231
+ if (getModality(study) !== "chat")
232
+ return false;
233
+ // mode_details lives under either `mode_details` on the study or
234
+ // `iteration.details.mode_details` per-iteration. The study-level
235
+ // `pair_mode` field (if present) is the cheap check; otherwise we
236
+ // peek at the assignments — pair studies have side="a"/"b" set.
237
+ const assignments = asArray(study.assignments);
238
+ for (const raw of assignments) {
239
+ const a = asRecord(raw);
240
+ if (a && (a.side === "a" || a.side === "b"))
241
+ return true;
242
+ }
243
+ return false;
244
+ }
245
+ /** Return true if the interaction's parent assignment has the given side. */
246
+ function interactionMatchesSide(interaction, assignmentSideById, wantedSide) {
247
+ const aid = asString(interaction.assignment_id);
248
+ if (!aid)
249
+ return false;
250
+ const side = assignmentSideById.get(aid);
251
+ return side === wantedSide;
252
+ }
253
+ /** Read `actions[0].data` defensively. Returns {} when actions is empty
254
+ * or the first row has no data block. */
255
+ function firstActionData(interaction) {
256
+ const actions = asArray(interaction.actions);
257
+ if (actions.length === 0)
258
+ return {};
259
+ const first = asRecord(actions[0]);
260
+ if (!first)
261
+ return {};
262
+ const data = asRecord(first.data);
263
+ return data ?? {};
264
+ }
265
+ /** Predicate: --segment <ref> matches this interaction.
266
+ *
267
+ * Numeric ref → equality against `data.segment_index`.
268
+ * Non-numeric → case-insensitive substring against `data.segment_label`.
269
+ */
270
+ function interactionMatchesSegment(interaction, segmentRef) {
271
+ const data = firstActionData(interaction);
272
+ const asInt = Number(segmentRef);
273
+ if (Number.isInteger(asInt) && segmentRef.trim() !== "") {
274
+ return data.segment_index === asInt;
275
+ }
276
+ const label = asString(data.segment_label);
277
+ if (!label)
278
+ return false;
279
+ return label.toLowerCase().includes(segmentRef.toLowerCase());
280
+ }
281
+ /** Predicate: --turn <n> matches this interaction. */
282
+ function interactionMatchesTurn(interaction, turn) {
283
+ const data = firstActionData(interaction);
284
+ return data.turn_index === turn;
285
+ }
286
+ /** Per-participant step filter. Walks `participant_assignments[].step_results[]`,
287
+ * keeps verdict rows whose step_id OR step.name match the user's ref, and
288
+ * optionally returns the evidence_interaction_ids set so the caller can
289
+ * drop interactions outside the evidence (when --include-evidence is set).
290
+ *
291
+ * Returns null when this participant has no surviving step_results — the
292
+ * caller treats that as "no match" for the participant.
293
+ */
294
+ function filterStepsOnParticipant(participant, stepRef) {
295
+ const assignments = asArray(participant.participant_assignments);
296
+ const needle = stepRef.toLowerCase();
297
+ const filteredAssignments = [];
298
+ const evidence = new Set();
299
+ let kept = 0;
300
+ for (const raw of assignments) {
301
+ const a = asRecord(raw);
302
+ if (!a)
303
+ continue;
304
+ const steps = asArray(a.step_results);
305
+ const survivors = [];
306
+ for (const sraw of steps) {
307
+ const s = asRecord(sraw);
308
+ if (!s)
309
+ continue;
310
+ const sid = asString(s.step_id);
311
+ const sname = asString(s.name);
312
+ const matchesId = sid !== null && sid.toLowerCase() === needle;
313
+ const matchesName = sname !== null && sname.toLowerCase().includes(needle);
314
+ if (!matchesId && !matchesName)
315
+ continue;
316
+ survivors.push(s);
317
+ for (const eid of asArray(s.evidence_interaction_ids)) {
318
+ const eidStr = asString(eid);
319
+ if (eidStr)
320
+ evidence.add(eidStr);
321
+ }
322
+ kept += 1;
323
+ }
324
+ if (survivors.length > 0) {
325
+ filteredAssignments.push({ ...a, step_results: survivors });
326
+ }
327
+ else {
328
+ // Preserve the assignment row but drop step_results entirely so
329
+ // downstream consumers see "this participant ran this assignment
330
+ // but no matching step verdicts" cleanly.
331
+ filteredAssignments.push({ ...a, step_results: [] });
332
+ }
333
+ }
334
+ if (kept === 0)
335
+ return null;
336
+ return { filteredAssignments, evidenceInteractionIds: evidence };
337
+ }
338
+ /**
339
+ * Pure entry point. See file-level comment for input/output contract.
340
+ */
341
+ export function applyResultsFilters(study, participants, rawFrames, filters) {
342
+ const warnings = [];
343
+ const modality = getModality(study);
344
+ const frames = normaliseFrames(rawFrames);
345
+ const assignments = normaliseAssignments(asArray(study.assignments));
346
+ const iterations = normaliseIterations(asArray(study.iterations));
347
+ const assignmentSideById = new Map(assignments.map((a) => [a.id, a.side]));
348
+ // --- Resolve refs upfront so we can fail fast with clear errors. ---
349
+ let matchedFrameVersionIds = new Set();
350
+ let frameVersionLookup = new Map();
351
+ if (filters.frame !== undefined) {
352
+ if (modality !== "interactive") {
353
+ warnings.push(`--frame is only meaningful on interactive studies; ignored on modality "${modality}".`);
354
+ }
355
+ else {
356
+ const resolved = resolveFrameRef(filters.frame, frames);
357
+ matchedFrameVersionIds = resolved.matchedFrameVersionIds;
358
+ frameVersionLookup = resolved.frameVersionLookup;
359
+ }
360
+ }
361
+ let matchedAssignmentIds = null;
362
+ if (filters.assignment !== undefined) {
363
+ matchedAssignmentIds = resolveAssignmentRef(filters.assignment, assignments);
364
+ }
365
+ let matchedIterationId = null;
366
+ if (filters.iteration !== undefined) {
367
+ matchedIterationId = resolveIterationRef(filters.iteration, iterations);
368
+ }
369
+ let matchedParticipantId = null;
370
+ if (filters.participant !== undefined) {
371
+ matchedParticipantId = resolveParticipantRef(filters.participant);
372
+ }
373
+ // --- Modality-mismatch warnings (filter still runs but degrades to no-op). ---
374
+ if (filters.segment !== undefined) {
375
+ if (modality === "interactive" ||
376
+ modality === "chat" ||
377
+ modality === "image" ||
378
+ modality === "unknown") {
379
+ warnings.push(`--segment is only meaningful on video, audio, text, or document studies; ignored on modality "${modality}".`);
380
+ }
381
+ }
382
+ if (filters.turn !== undefined && modality !== "chat") {
383
+ warnings.push(`--turn is only meaningful on chat studies; ignored on modality "${modality}".`);
384
+ }
385
+ if (filters.side !== undefined && !isParticipantPair(study)) {
386
+ warnings.push(`--side is only meaningful on participant_pair chat studies; ignored.`);
387
+ }
388
+ // Normalise sentiment labels to lowercase up-front (case-insensitive).
389
+ const sentimentFilter = filters.sentiment
390
+ ? new Set(filters.sentiment.map((s) => s.toLowerCase()))
391
+ : null;
392
+ const actorFilter = filters.actor ? filters.actor.toLowerCase() : null;
393
+ // Set of warnings that downgrade a flag to a no-op for the predicate walk.
394
+ const segmentActive = filters.segment !== undefined && modality !== "interactive"
395
+ && modality !== "chat" && modality !== "image" && modality !== "unknown";
396
+ const turnActive = filters.turn !== undefined && modality === "chat";
397
+ const sideActive = filters.side !== undefined && isParticipantPair(study);
398
+ const frameActive = filters.frame !== undefined && modality === "interactive";
399
+ // Track whether ANY interaction-level filter is on. Determines whether
400
+ // we drop empty participants or keep them (per plan §3 "empty participants").
401
+ const interactionLevelFilterActive = frameActive ||
402
+ segmentActive ||
403
+ turnActive ||
404
+ sideActive ||
405
+ matchedAssignmentIds !== null ||
406
+ sentimentFilter !== null ||
407
+ actorFilter !== null;
408
+ // --- Pre-filter totals (for the totals_unfiltered envelope field). ---
409
+ let unfilteredInteractionCount = 0;
410
+ for (const p of participants) {
411
+ unfilteredInteractionCount += asArray(p.interactions).length;
412
+ }
413
+ const totals_unfiltered = {
414
+ participant_count: participants.length,
415
+ interaction_count: unfilteredInteractionCount,
416
+ };
417
+ // --- Walk participants. ---
418
+ const out = [];
419
+ for (const participant of participants) {
420
+ // 1. Participant-level filters (drop the whole row up-front).
421
+ if (matchedParticipantId !== null &&
422
+ asString(participant.id) !== matchedParticipantId) {
423
+ continue;
424
+ }
425
+ if (matchedIterationId !== null &&
426
+ asString(participant.iteration_id) !== matchedIterationId) {
427
+ continue;
428
+ }
429
+ // 2. --step is per-participant: rewrites participant_assignments
430
+ // and surfaces evidence_interaction_ids if --include-evidence.
431
+ let evidenceInteractionIds = null;
432
+ let rewrittenAssignments = null;
433
+ if (filters.step !== undefined) {
434
+ const stepResult = filterStepsOnParticipant(participant, filters.step);
435
+ if (!stepResult) {
436
+ // This participant has no matching step verdicts — drop entirely
437
+ // (--step is participant-level "matched at least one verdict").
438
+ continue;
439
+ }
440
+ rewrittenAssignments = stepResult.filteredAssignments;
441
+ if (filters.includeEvidence) {
442
+ evidenceInteractionIds = stepResult.evidenceInteractionIds;
443
+ }
444
+ }
445
+ // 3. Walk interactions[] and drop those failing any predicate.
446
+ const survivingInteractions = [];
447
+ for (const raw of asArray(participant.interactions)) {
448
+ const interaction = asRecord(raw);
449
+ if (!interaction)
450
+ continue;
451
+ // --frame (interactive only)
452
+ if (frameActive) {
453
+ const fvId = asString(interaction.frame_version_id);
454
+ if (fvId === null) {
455
+ if (!filters.includeUnmatched)
456
+ continue;
457
+ // Keep this row; the projection layer surfaces it under the
458
+ // synthetic `_unmatched` bucket. We don't mutate the row here;
459
+ // the lookup map simply lacks an entry, which the projection
460
+ // layer interprets as "_unmatched".
461
+ }
462
+ else if (!matchedFrameVersionIds.has(fvId)) {
463
+ continue;
464
+ }
465
+ }
466
+ // --segment (video/audio/text/document)
467
+ if (segmentActive) {
468
+ if (!interactionMatchesSegment(interaction, filters.segment)) {
469
+ continue;
470
+ }
471
+ }
472
+ // --turn (chat)
473
+ if (turnActive) {
474
+ if (!interactionMatchesTurn(interaction, filters.turn)) {
475
+ continue;
476
+ }
477
+ }
478
+ // --side (participant_pair chat)
479
+ if (sideActive) {
480
+ if (!interactionMatchesSide(interaction, assignmentSideById, filters.side)) {
481
+ continue;
482
+ }
483
+ }
484
+ // --assignment
485
+ if (matchedAssignmentIds !== null) {
486
+ const aid = asString(interaction.assignment_id);
487
+ if (!aid || !matchedAssignmentIds.has(aid))
488
+ continue;
489
+ }
490
+ // --actor
491
+ if (actorFilter !== null) {
492
+ const actor = asString(interaction.actor);
493
+ if (!actor || actor.toLowerCase() !== actorFilter)
494
+ continue;
495
+ }
496
+ // --sentiment (drops null-sentiment rows only when this filter set)
497
+ if (sentimentFilter !== null) {
498
+ const sentiment = asRecord(interaction.sentiment);
499
+ const label = sentiment ? asString(sentiment.label) : null;
500
+ if (!label || !sentimentFilter.has(label.toLowerCase()))
501
+ continue;
502
+ }
503
+ // --include-evidence (paired with --step)
504
+ if (evidenceInteractionIds !== null) {
505
+ const iid = asString(interaction.id);
506
+ if (!iid || !evidenceInteractionIds.has(iid))
507
+ continue;
508
+ }
509
+ survivingInteractions.push(interaction);
510
+ }
511
+ // 4. Drop participants with zero surviving interactions only when
512
+ // an interaction-level filter is set (preserves the stable schema
513
+ // when the caller just asked "who ran?" without slicing).
514
+ if (interactionLevelFilterActive && survivingInteractions.length === 0) {
515
+ continue;
516
+ }
517
+ const next = {
518
+ ...participant,
519
+ interactions: survivingInteractions,
520
+ };
521
+ if (rewrittenAssignments !== null) {
522
+ next.participant_assignments = rewrittenAssignments;
523
+ }
524
+ out.push(next);
525
+ }
526
+ // The frames list returned to the caller is the raw payload (only the
527
+ // surface uses it for the `--get frames` shortcut). The projection
528
+ // layer relies on `frameVersionLookup` instead.
529
+ return {
530
+ study: { ...study },
531
+ participants: out,
532
+ frames: rawFrames,
533
+ totals_unfiltered,
534
+ warnings,
535
+ matchedFrameVersionIds,
536
+ frameVersionLookup,
537
+ };
538
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Pure projection builders for `ish study results --group-by <kind>`.
3
+ *
4
+ * Each `buildStudyResultsPer<Kind>` consumes a `FilteredResults` (the output
5
+ * of `applyResultsFilters` in `study-results-filters.ts`) and returns a
6
+ * plain JSON-serialisable value. The surface (T5) hands the result to
7
+ * `output(..., json, { preProjected: true })` for JSON, or to
8
+ * `formatStudyResultsGroupBy` (T6) for human mode.
9
+ *
10
+ * Per-iteration is the only projection that wraps a `{study, slices, ...}`
11
+ * envelope; the others return plain arrays of slice objects. The surface
12
+ * attaches `totals_unfiltered` and `warnings` from the same `FilteredResults`
13
+ * for the array projections.
14
+ *
15
+ * Conventions mirror `buildStudyResultsEnvelope` (`output.ts:1081`) and
16
+ * `buildStudyResultsSummary` (`output.ts:1292`):
17
+ * - deterministic field order (object literals are emitted in source order)
18
+ * - stable empties: empty arrays, never `null` for "no rows yet"
19
+ * - sample_comments capped at 5 per group, truncated to 200 chars
20
+ * - sentiment histograms are { label → count } records
21
+ * - participant_aliases capped at 10 per group
22
+ *
23
+ * Has no IO and no console side-effects.
24
+ */
25
+ import type { FilteredResults } from "./study-results-filters.js";
26
+ export type { FilteredResults } from "./study-results-filters.js";
27
+ /**
28
+ * `--group-by iteration` — one slice per iteration that has any surviving
29
+ * participants. Slices are ordered by the iteration order on the study
30
+ * (so callers see them in the same order as `ish study get`).
31
+ *
32
+ * Unlike the array-returning projections, this one wraps a stable envelope
33
+ * with `totals_unfiltered` + `warnings` because per-iteration is the
34
+ * "default agent slice" — the one most likely to be piped directly without
35
+ * the surface re-wrapping.
36
+ */
37
+ export declare function buildStudyResultsPerIteration(filtered: FilteredResults): Record<string, unknown>;
38
+ interface FrameSlice {
39
+ frame_id: string;
40
+ frame_label: string | null;
41
+ interaction_count: number;
42
+ sentiment_histogram: Record<string, number>;
43
+ sample_comments: string[];
44
+ participant_aliases: string[];
45
+ }
46
+ /**
47
+ * `--group-by frame` — one slice per Frame that had a surviving interaction.
48
+ * Interactive only — the surface (T5) errors before reaching here when the
49
+ * study isn't interactive. Includes a synthetic `_unmatched` bucket when
50
+ * `--include-unmatched` was set and null-frame_version_id rows survived.
51
+ *
52
+ * Returns a bare array (no wrapper) — callers attach totals_unfiltered.
53
+ */
54
+ export declare function buildStudyResultsPerFrame(filtered: FilteredResults): FrameSlice[];
55
+ interface SegmentSlice {
56
+ segment_index: number | null;
57
+ segment_label: string | null;
58
+ interaction_count: number;
59
+ sentiment_histogram: Record<string, number>;
60
+ engagement_histogram: Record<string, number>;
61
+ sample_comments: string[];
62
+ }
63
+ /**
64
+ * `--group-by segment` — media studies (video / audio / text / document).
65
+ * Groups by `actions[0].data.segment_index`, falling back to `segment_label`
66
+ * when the index isn't present.
67
+ */
68
+ export declare function buildStudyResultsPerSegment(filtered: FilteredResults): SegmentSlice[];
69
+ interface TurnSlice {
70
+ turn_index: number;
71
+ interaction_count: number;
72
+ sentiment_histogram: Record<string, number>;
73
+ sample_replies: string[];
74
+ failures: number;
75
+ }
76
+ /**
77
+ * `--group-by turn` — chat studies. Groups by `actions[0].data.turn_index`
78
+ * and surfaces both a count of bot-failure stubs (`bot_reply.failure`
79
+ * populated) and up to 5 sample bot replies per turn.
80
+ */
81
+ export declare function buildStudyResultsPerTurn(filtered: FilteredResults): TurnSlice[];
82
+ interface AssignmentSlice {
83
+ assignment_id: string;
84
+ assignment_name: string | null;
85
+ interaction_count: number;
86
+ sentiment_histogram: Record<string, number>;
87
+ step_completion: unknown[];
88
+ }
89
+ /**
90
+ * `--group-by assignment` — one slice per study assignment, with each
91
+ * assignment's `step_completion[]` (from the study payload) attached so the
92
+ * caller can see pass / inconclusive / fail rollups inline.
93
+ */
94
+ export declare function buildStudyResultsPerAssignment(filtered: FilteredResults): AssignmentSlice[];
95
+ interface StepVerdict {
96
+ participant_alias: string | null;
97
+ verdict: string | null;
98
+ reason: string | null;
99
+ evidence_interaction_ids: string[];
100
+ }
101
+ interface StepSlice {
102
+ assignment_id: string;
103
+ assignment_name: string | null;
104
+ step_id: string;
105
+ step_name: string | null;
106
+ total: number;
107
+ passed: number;
108
+ inconclusive: number;
109
+ failed: number;
110
+ rate: number;
111
+ participant_verdicts: StepVerdict[];
112
+ }
113
+ /**
114
+ * `--group-by step` — one slice per `(assignment, step_id)` pair with verdict
115
+ * totals (re-derived from surviving participants, NOT the pre-computed
116
+ * step_completion) and per-participant verdict rows inline.
117
+ *
118
+ * Re-deriving totals matters when filters are applied: e.g. a caller asking
119
+ * for `--iteration B --group-by step` wants verdict counts for iteration B
120
+ * only, not the study-wide rollup.
121
+ */
122
+ export declare function buildStudyResultsPerStep(filtered: FilteredResults): StepSlice[];