@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.
@@ -992,7 +992,7 @@ export function formatStudyDetail(study, json, options = {}, participants) {
992
992
  * study state — fields default to `null`, `0`, or `[]` when nothing has run.
993
993
  * Agents can rely on the keys always being present (M4).
994
994
  */
995
- function buildStudyResultsEnvelope(study, participants) {
995
+ export function buildStudyResultsEnvelope(study, participants) {
996
996
  const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
997
997
  const studyAlias = study.id
998
998
  ? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
@@ -2226,3 +2226,219 @@ function formatDate(value) {
2226
2226
  return str.slice(0, 10);
2227
2227
  }
2228
2228
  }
2229
+ const POSITIVE_SENTIMENT = new Set(["satisfied", "curious", "engaged", "confident", "delighted"]);
2230
+ const NEGATIVE_SENTIMENT = new Set(["frustrated", "confused", "blocked", "anxious", "disappointed"]);
2231
+ function sentimentColor(label) {
2232
+ const l = label.toLowerCase();
2233
+ if (POSITIVE_SENTIMENT.has(l))
2234
+ return c.green;
2235
+ if (NEGATIVE_SENTIMENT.has(l))
2236
+ return c.red;
2237
+ return c.dim;
2238
+ }
2239
+ function asciiHistogram(hist, options = {}) {
2240
+ const width = options.width ?? 20;
2241
+ const indent = options.indent ?? " ";
2242
+ const entries = Object.entries(hist).filter(([, v]) => v > 0);
2243
+ if (entries.length === 0)
2244
+ return [];
2245
+ const max = entries.reduce((acc, [, v]) => (v > acc ? v : acc), 0);
2246
+ const labelWidth = entries.reduce((acc, [k]) => (k.length > acc ? k.length : acc), 0);
2247
+ return entries
2248
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
2249
+ .map(([label, count]) => {
2250
+ const bars = max > 0 ? Math.max(1, Math.round((count / max) * width)) : 0;
2251
+ const color = sentimentColor(label);
2252
+ return `${indent}${label.padEnd(labelWidth)} ${color}${"█".repeat(bars)}${c.reset} ${count}`;
2253
+ });
2254
+ }
2255
+ function slicesFromProjection(projection) {
2256
+ // Iteration projection wraps `{ study, slices, totals_unfiltered, warnings }`;
2257
+ // all others are bare arrays. Both come through here.
2258
+ if (Array.isArray(projection)) {
2259
+ return projection.filter((s) => Boolean(s) && typeof s === "object" && !Array.isArray(s));
2260
+ }
2261
+ if (projection && typeof projection === "object") {
2262
+ const wrapped = projection;
2263
+ const slices = wrapped.slices;
2264
+ if (Array.isArray(slices)) {
2265
+ return slices.filter((s) => Boolean(s) && typeof s === "object" && !Array.isArray(s));
2266
+ }
2267
+ }
2268
+ return [];
2269
+ }
2270
+ function totalInteractionsFromSlices(slices) {
2271
+ let total = 0;
2272
+ for (const s of slices) {
2273
+ const n = typeof s.interaction_count === "number" ? s.interaction_count : 0;
2274
+ total += n;
2275
+ }
2276
+ return total;
2277
+ }
2278
+ function totalsUnfilteredFromProjection(projection) {
2279
+ if (projection && typeof projection === "object" && !Array.isArray(projection)) {
2280
+ const t = projection.totals_unfiltered;
2281
+ if (t && typeof t === "object" && !Array.isArray(t)) {
2282
+ return t;
2283
+ }
2284
+ }
2285
+ return null;
2286
+ }
2287
+ function renderIterationSlice(slice) {
2288
+ const label = String(slice.iteration_label ?? slice.iteration_id ?? "?");
2289
+ const pCount = Number(slice.participant_count ?? 0);
2290
+ const iCount = Number(slice.interaction_count ?? 0);
2291
+ console.log(`\n ${c.bold}Iteration ${label}${c.reset} ${c.dim}${pCount} participant${pCount !== 1 ? "s" : ""} · ${iCount} interaction${iCount !== 1 ? "s" : ""}${c.reset}`);
2292
+ const hist = slice.sentiment ?? {};
2293
+ for (const line of asciiHistogram(hist, { indent: " " }))
2294
+ console.log(line);
2295
+ const top = Array.isArray(slice.top_actions) ? slice.top_actions : [];
2296
+ if (top.length > 0) {
2297
+ const parts = top.map((a) => `${a.action_type} ×${a.count}`);
2298
+ console.log(` ${c.dim}Top actions:${c.reset} ${parts.join(", ")}`);
2299
+ }
2300
+ const comments = Array.isArray(slice.sample_comments) ? slice.sample_comments : [];
2301
+ for (const ccomment of comments) {
2302
+ console.log(` ${c.dim}"${ccomment}"${c.reset}`);
2303
+ }
2304
+ }
2305
+ function renderFrameSlice(slice) {
2306
+ const label = slice.frame_label ? String(slice.frame_label) : String(slice.frame_id);
2307
+ const iCount = Number(slice.interaction_count ?? 0);
2308
+ const aliases = Array.isArray(slice.participant_aliases) ? slice.participant_aliases : [];
2309
+ console.log(`\n ${c.bold}${label}${c.reset} ${c.dim}${iCount} interaction${iCount !== 1 ? "s" : ""} · ${aliases.length} participant${aliases.length !== 1 ? "s" : ""}${c.reset}`);
2310
+ const hist = slice.sentiment_histogram ?? {};
2311
+ for (const line of asciiHistogram(hist, { indent: " " }))
2312
+ console.log(line);
2313
+ const comments = Array.isArray(slice.sample_comments) ? slice.sample_comments : [];
2314
+ for (const ccomment of comments) {
2315
+ console.log(` ${c.dim}"${ccomment}"${c.reset}`);
2316
+ }
2317
+ }
2318
+ function renderSegmentSlice(slice) {
2319
+ const idx = slice.segment_index;
2320
+ const label = slice.segment_label ? String(slice.segment_label) : null;
2321
+ const header = idx !== null && idx !== undefined
2322
+ ? `Segment ${idx}${label ? ` — ${label}` : ""}`
2323
+ : (label ?? "Segment ?");
2324
+ const iCount = Number(slice.interaction_count ?? 0);
2325
+ console.log(`\n ${c.bold}${header}${c.reset} ${c.dim}${iCount} interaction${iCount !== 1 ? "s" : ""}${c.reset}`);
2326
+ const hist = slice.sentiment_histogram ?? {};
2327
+ for (const line of asciiHistogram(hist, { indent: " " }))
2328
+ console.log(line);
2329
+ const engagement = slice.engagement_histogram ?? {};
2330
+ if (Object.keys(engagement).length > 0) {
2331
+ const parts = Object.entries(engagement).map(([k, v]) => `${v} ${k}`);
2332
+ console.log(` ${c.dim}Engagement:${c.reset} ${parts.join(", ")}`);
2333
+ }
2334
+ const comments = Array.isArray(slice.sample_comments) ? slice.sample_comments : [];
2335
+ for (const ccomment of comments) {
2336
+ console.log(` ${c.dim}"${ccomment}"${c.reset}`);
2337
+ }
2338
+ }
2339
+ function renderTurnSlice(slice) {
2340
+ const turn = Number(slice.turn_index ?? 0);
2341
+ const iCount = Number(slice.interaction_count ?? 0);
2342
+ const failures = Number(slice.failures ?? 0);
2343
+ const failPart = failures > 0 ? ` ${c.red}${failures} failure${failures !== 1 ? "s" : ""}${c.reset}` : "";
2344
+ console.log(`\n ${c.bold}Turn ${turn}${c.reset} ${c.dim}${iCount} interaction${iCount !== 1 ? "s" : ""}${c.reset}${failPart}`);
2345
+ const hist = slice.sentiment_histogram ?? {};
2346
+ for (const line of asciiHistogram(hist, { indent: " " }))
2347
+ console.log(line);
2348
+ const replies = Array.isArray(slice.sample_replies) ? slice.sample_replies : [];
2349
+ for (const r of replies) {
2350
+ console.log(` ${c.dim}"${r}"${c.reset}`);
2351
+ }
2352
+ }
2353
+ function renderAssignmentSlice(slice) {
2354
+ const name = String(slice.assignment_name ?? slice.assignment_id ?? "?");
2355
+ const iCount = Number(slice.interaction_count ?? 0);
2356
+ console.log(`\n ${c.bold}${name}${c.reset} ${c.dim}${iCount} interaction${iCount !== 1 ? "s" : ""}${c.reset}`);
2357
+ const hist = slice.sentiment_histogram ?? {};
2358
+ for (const line of asciiHistogram(hist, { indent: " " }))
2359
+ console.log(line);
2360
+ const sc = Array.isArray(slice.step_completion) ? slice.step_completion : [];
2361
+ if (sc.length > 0) {
2362
+ const rows = sc.map((s) => [
2363
+ String(s.name ?? s.step_id ?? "?"),
2364
+ String(s.passed ?? 0),
2365
+ String(s.inconclusive ?? 0),
2366
+ String(s.failed ?? 0),
2367
+ typeof s.rate === "number" ? s.rate.toFixed(2) : "-",
2368
+ ]);
2369
+ console.log(` ${c.dim}Steps:${c.reset}`);
2370
+ printTable(["STEP", "PASSED", "INCONCLUSIVE", "FAILED", "RATE"], rows);
2371
+ }
2372
+ }
2373
+ function renderStepSlice(slice) {
2374
+ const name = String(slice.step_name ?? slice.step_id ?? "?");
2375
+ const assignment = String(slice.assignment_name ?? "?");
2376
+ const total = Number(slice.total ?? 0);
2377
+ const passed = Number(slice.passed ?? 0);
2378
+ const inconclusive = Number(slice.inconclusive ?? 0);
2379
+ const failed = Number(slice.failed ?? 0);
2380
+ const rate = typeof slice.rate === "number" ? slice.rate.toFixed(2) : "-";
2381
+ const rateColor = failed > passed ? c.red : (passed > failed ? c.green : c.dim);
2382
+ console.log(`\n ${c.bold}${assignment} › ${name}${c.reset} ${rateColor}${passed}/${total} passed${c.reset} ${c.dim}(${inconclusive} inconclusive, ${failed} failed, rate ${rate})${c.reset}`);
2383
+ const verdicts = Array.isArray(slice.participant_verdicts)
2384
+ ? slice.participant_verdicts
2385
+ : [];
2386
+ if (verdicts.length > 0) {
2387
+ const rows = verdicts.map((v) => [
2388
+ String(v.participant_alias ?? "-"),
2389
+ String(v.verdict ?? "-"),
2390
+ v.reason ? truncate(String(v.reason), 60) : "-",
2391
+ ]);
2392
+ printTable(["PARTICIPANT", "VERDICT", "REASON"], rows);
2393
+ }
2394
+ }
2395
+ /**
2396
+ * Render a `--group-by <kind>` projection. JSON mode is a thin pass-through
2397
+ * to jsonOutput with `preProjected: true` so the lean transform doesn't
2398
+ * strip our stable empties. Human mode renders one section per slice plus
2399
+ * a small ASCII sentiment histogram.
2400
+ *
2401
+ * The renderer accepts both the wrapped `{study, slices, ...}` shape (per-
2402
+ * iteration) and the bare-array shape (every other --group-by); the
2403
+ * surface (T5) doesn't need to know the difference.
2404
+ */
2405
+ export function formatStudyResultsGroupBy(projection, kind, json) {
2406
+ if (json) {
2407
+ console.log(jsonOutput(projection, { preProjected: true }));
2408
+ return;
2409
+ }
2410
+ const slices = slicesFromProjection(projection);
2411
+ const totalInteractions = totalInteractionsFromSlices(slices);
2412
+ const unfiltered = totalsUnfilteredFromProjection(projection);
2413
+ const totalUnfiltered = unfiltered && typeof unfiltered.interaction_count === "number"
2414
+ ? unfiltered.interaction_count
2415
+ : null;
2416
+ const headline = `Sliced by ${kind}: ${slices.length} group${slices.length !== 1 ? "s" : ""} (${totalInteractions}${totalUnfiltered !== null ? `/${totalUnfiltered}` : ""} interaction${totalInteractions !== 1 ? "s" : ""})`;
2417
+ console.log(`${c.bold}${headline}${c.reset}`);
2418
+ if (slices.length === 0) {
2419
+ console.log(` ${c.dim}(no groups matched)${c.reset}`);
2420
+ return;
2421
+ }
2422
+ for (const slice of slices) {
2423
+ switch (kind) {
2424
+ case "iteration":
2425
+ renderIterationSlice(slice);
2426
+ break;
2427
+ case "frame":
2428
+ renderFrameSlice(slice);
2429
+ break;
2430
+ case "segment":
2431
+ renderSegmentSlice(slice);
2432
+ break;
2433
+ case "turn":
2434
+ renderTurnSlice(slice);
2435
+ break;
2436
+ case "assignment":
2437
+ renderAssignmentSlice(slice);
2438
+ break;
2439
+ case "step":
2440
+ renderStepSlice(slice);
2441
+ break;
2442
+ }
2443
+ }
2444
+ }
@@ -917,6 +917,70 @@ Rules to remember:
917
917
  See \`ish docs get-page concepts/extending-a-simulation\` for the full
918
918
  mental model (cancel + extend as a pair, error envelopes, cost model).
919
919
 
920
+ ## 12. Slice study results by frame / segment / turn / sentiment
921
+
922
+ Goal: ask narrower questions of a finished run than the kitchen-sink
923
+ \`ish study results\` envelope answers. The canonical use case:
924
+ **"what differed on the login screen across these five iterations?"**.
925
+
926
+ \`\`\`bash
927
+ # 12a. Across-iterations comparison on one frame (the canonical question).
928
+ # --frame matches frame names by case-insensitive substring; pass
929
+ # a full Frame UUID or an f-… alias when the name is ambiguous.
930
+ ish study results s-b2c --frame login --group-by iteration --json
931
+
932
+ # 12b. Frustrated reactions to one segment of a video study:
933
+ ish study results s-b2c --segment 3 --sentiment Frustrated
934
+
935
+ # 12c. Who failed the "verify email" step, and why?
936
+ # --group-by step exposes per-participant verdicts inline so you
937
+ # don't fan out across participants.
938
+ ish study results s-b2c --assignment "Sign up" --step verify-email \\
939
+ --group-by step --json
940
+
941
+ # 12d. Pair-mode chat: only side A turn 4.
942
+ ish study results s-b2c --side a --turn 4
943
+
944
+ # 12e. Sanity-check coverage when a filter narrows the slice:
945
+ ish study results s-b2c --frame checkout --json \\
946
+ | jq '{matched: .participant_count, total: .totals_unfiltered.participant_count}'
947
+
948
+ # 12f. A filter that matches zero interactions still returns the stable
949
+ # envelope shape — participant_count: 0, totals_unfiltered populated,
950
+ # exit code 0 (not 4). Never error on no-match.
951
+ ish study results s-b2c --frame doesnotexist --json
952
+ # → ValidationError because "doesnotexist" matches no frame names; pass
953
+ # --include-unmatched only when --frame DID resolve and you want the
954
+ # degraded captures (frame_version_id: null) back.
955
+ \`\`\`
956
+
957
+ Rules to remember:
958
+ - **Filters compose with AND across flags; OR within \`--sentiment\`.**
959
+ \`--frame login --sentiment Frustrated,Confused\` keeps only login-frame
960
+ interactions whose sentiment is Frustrated OR Confused.
961
+ - **Modality mismatch is not an error.** \`--segment 0\` on an
962
+ interactive study emits a stderr warning and is ignored. The
963
+ exception is **\`--group-by\`** — \`--group-by frame\` on a chat study,
964
+ \`--group-by turn\` on a video study, etc. error at the router (exit 2).
965
+ - **Empty-slice contract: exit 0, not 4.** Zero matches return a
966
+ stable envelope with \`participant_count: 0\` and
967
+ \`totals_unfiltered\` populated. Agents key on
968
+ \`totals_unfiltered.participant_count\` to ask "is the filter too
969
+ tight, or did the run not produce data?".
970
+ - \`--frame\` accepts a name substring, a Frame UUID, an \`f-…\` alias,
971
+ or a \`frame_version_id\` UUID. Ambiguous substring (matches >1
972
+ frame) errors with the candidate list.
973
+ - \`--summary\` is orthogonal to filters and narrows the summary over
974
+ the filtered set. \`--transcript\` is single-participant and errors
975
+ (exit 2) when **any** filter or \`--group-by\` is set.
976
+ - Per-step output exposes \`participant_verdicts: [{participant_alias,
977
+ verdict, reason, evidence_interaction_ids}]\` — not
978
+ \`per_participant_verdicts\`. The verdict enum is \`passed\` /
979
+ \`inconclusive\` / \`failed\`.
980
+
981
+ See \`ish docs get-page guides/slicing-results\` for the full filter
982
+ table, projection shapes, and the defensive null-handling rules.
983
+
920
984
  ## Tips for chaining commands as an agent
921
985
 
922
986
  - Capture aliases from JSON: \`ITER=$(ish iteration create --url … --json | jq -r .alias)\`
@@ -1010,6 +1074,10 @@ mental model (cancel + extend as a pair, error envelopes, cost model).
1010
1074
  | List of participants from \`study run\` | \`--json \\| jq '.participants[].id'\` | \`--get participant_aliases\` (or \`participant_ids\` for UUIDs) |
1011
1075
  | Per-answer sentiment | \`--json \\| jq '...'\` per participant | \`ish study results <id> --json\` (sentiment is on every answer row) |
1012
1076
  | "Did this run land?" headline | \`study results --json\` + jq filtering | \`ish study results <id> --summary --json\` |
1077
+ | Across-iterations comparison on one frame | \`study results --json\` + jq per iteration | \`ish study results <id> --frame login --group-by iteration --json\` |
1078
+ | Per-step pass/fail with reasons inline | \`study participant --json\` per participant + jq | \`ish study results <id> --step verify-email --group-by step --json\` |
1079
+ | Frustrated reactions to one media segment | \`study results --json\` + jq | \`ish study results <id> --segment 3 --sentiment Frustrated --json\` |
1080
+ | Sanity-check filter coverage | hand-count \`.participants\` vs total | \`--get totals_unfiltered.participant_count\` (set on every sliced envelope) |
1013
1081
  | Chat transcript for one participant (external_chatbot) | \`study participant --json\` + jq | \`ish study results <id> --transcript <participant_id> --json\` |
1014
1082
  | Pair-mode conversation transcripts | \`study participant --json\` per participant | \`ish iteration get <iter-id> --json \\| jq '.conversations[]'\` |
1015
1083
  | Participant headline only (no action timeline) | \`study participant --json\` + jq | \`ish study participant <id> --summary --json\` |
@@ -6,6 +6,19 @@
6
6
  * (person, interactions[], participant_summary, interview_answers, …) that
7
7
  * used to be embedded under `study.iterations[*].participants[*]` on the
8
8
  * legacy `GET /studies/{id}` response.
9
+ *
10
+ * Audit (study-results-slice plan, T4): the flat endpoint already returns
11
+ * everything the new `ish study results --frame/--segment/--step/...` filter
12
+ * pipeline needs in a single round-trip — no per-participant fan-out:
13
+ * - `interactions[]` (modality-discriminated via `ParticipantWithAttributesPublicResponse`)
14
+ * - `participant_assignments[].step_results[]` with `{step_id, name,
15
+ * description, verdict, reason, evidence_interaction_ids[]}`, hydrated
16
+ * by `attach_participant_step_results_flat` in the study repository
17
+ * before serialisation (`ish-backend/app/api/study/repository.py:315`)
18
+ * - `participant_summary`, `interview_answers`
19
+ * If a future filter ever needs `conversation_id` on each interaction (for
20
+ * `--group-by conversation`), that's a backend-side addition on
21
+ * `_InteractionResponseBase`, not a CLI change.
9
22
  */
10
23
  import type { ApiClient } from "./api-client.js";
11
24
  import type { Participant } from "./types.js";
@@ -6,6 +6,19 @@
6
6
  * (person, interactions[], participant_summary, interview_answers, …) that
7
7
  * used to be embedded under `study.iterations[*].participants[*]` on the
8
8
  * legacy `GET /studies/{id}` response.
9
+ *
10
+ * Audit (study-results-slice plan, T4): the flat endpoint already returns
11
+ * everything the new `ish study results --frame/--segment/--step/...` filter
12
+ * pipeline needs in a single round-trip — no per-participant fan-out:
13
+ * - `interactions[]` (modality-discriminated via `ParticipantWithAttributesPublicResponse`)
14
+ * - `participant_assignments[].step_results[]` with `{step_id, name,
15
+ * description, verdict, reason, evidence_interaction_ids[]}`, hydrated
16
+ * by `attach_participant_step_results_flat` in the study repository
17
+ * before serialisation (`ish-backend/app/api/study/repository.py:315`)
18
+ * - `participant_summary`, `interview_answers`
19
+ * If a future filter ever needs `conversation_id` on each interaction (for
20
+ * `--group-by conversation`), that's a backend-side addition on
21
+ * `_InteractionResponseBase`, not a CLI change.
9
22
  */
10
23
  export async function fetchStudyParticipants(client, studyId, opts) {
11
24
  return await client.get(`/studies/${studyId}/participants`, undefined, opts);
@@ -0,0 +1,91 @@
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
+ export interface ResultsFilters {
23
+ /** Frame name (case-insensitive substring), Frame UUID, frame alias `f-...`,
24
+ * or a `frame_version_id` UUID. Resolved against the study's frames list. */
25
+ frame?: string;
26
+ /** Segment index (parseable int) OR a substring matched against
27
+ * `actions[0].data.segment_label` on each interaction. */
28
+ segment?: string;
29
+ /** Chat turn index — matched against `actions[0].data.turn_index`. */
30
+ turn?: number;
31
+ /** participant_pair side — matched against the parent assignment's `side`. */
32
+ side?: "a" | "b";
33
+ /** Assignment UUID, OR a substring matched against
34
+ * `study.assignments[].name`. */
35
+ assignment?: string;
36
+ /** Step id OR a case-insensitive substring against step `name`. Walks
37
+ * `participant_assignments[].step_results[]`. */
38
+ step?: string;
39
+ /** Comma-or-repeat list of sentiment labels (case-insensitive). */
40
+ sentiment?: string[];
41
+ /** Actor field — case-insensitive match against `interaction.actor`. */
42
+ actor?: "ai" | "human" | "user";
43
+ /** Iteration UUID or `label`. */
44
+ iteration?: string;
45
+ /** Participant UUID or alias (`pt-...`). */
46
+ participant?: string;
47
+ /** When --frame is set, keep interactions with null frame_version_id
48
+ * under a synthetic `_unmatched` bucket instead of dropping them. */
49
+ includeUnmatched?: boolean;
50
+ /** Pair with --step: also drop interactions whose id is not in any
51
+ * surviving `step_results[].evidence_interaction_ids[]`. */
52
+ includeEvidence?: boolean;
53
+ }
54
+ export interface FilteredResults {
55
+ /** Shallow copy of the study payload — same shape as the raw response.
56
+ * Participants are NOT embedded here; they're carried alongside on
57
+ * `participants`. */
58
+ study: Record<string, unknown>;
59
+ /** Participants whose interactions[] survived the predicate walk.
60
+ * Empty participants are dropped only when an interaction-level filter
61
+ * was set (preserves the stable schema when the caller just asked
62
+ * "who ran?" without slicing). */
63
+ participants: Record<string, unknown>[];
64
+ /** The frame list returned by the surface, with each frame's
65
+ * `frame_version_ids[]` flattened onto the row for downstream
66
+ * enrichment. Empty when --frame wasn't passed or the modality isn't
67
+ * interactive. */
68
+ frames: Record<string, unknown>[];
69
+ /** Pre-filter participant + interaction counts, so callers can see
70
+ * "matched X / Y". */
71
+ totals_unfiltered: {
72
+ participant_count: number;
73
+ interaction_count: number;
74
+ };
75
+ /** Modality-mismatch notes (e.g. "--segment ignored on interactive").
76
+ * The surface emits these on stderr. */
77
+ warnings: string[];
78
+ /** When --frame was set, the resolved set of frame_version_ids that
79
+ * passed. Used by the projection builders (T3) to enrich surviving
80
+ * interactions with frame_id / frame_label without re-resolving. */
81
+ matchedFrameVersionIds: Set<string>;
82
+ /** Maps frame_version_id → {frame_id, frame_label} for enrichment. */
83
+ frameVersionLookup: Map<string, {
84
+ frame_id: string;
85
+ frame_label: string | null;
86
+ }>;
87
+ }
88
+ /**
89
+ * Pure entry point. See file-level comment for input/output contract.
90
+ */
91
+ export declare function applyResultsFilters(study: Record<string, unknown>, participants: Record<string, unknown>[], rawFrames: Record<string, unknown>[], filters: ResultsFilters): FilteredResults;