@ishlabs/cli 0.20.0 → 0.22.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,559 @@
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
+ * Optional `availableValues` rides along as a structured field in the
84
+ * emitted JSON envelope so agents can parse the recovery set without
85
+ * string-extracting it from the message (Pattern V from round 3). */
86
+ function validationError(message, availableValues) {
87
+ const err = new Error(message);
88
+ err.name = "ValidationError";
89
+ if (availableValues && availableValues.length > 0) {
90
+ err.available_values = availableValues;
91
+ }
92
+ return err;
93
+ }
94
+ /** Resolve `--frame <ref>` into a set of frame_version_ids.
95
+ *
96
+ * Branches on shape:
97
+ * - `f-<hex>` → frame alias, resolves to a Frame UUID via alias-store
98
+ * - full UUID → first try Frame.id; if no match, treat as frame_version_id
99
+ * - anything else → case-insensitive substring against `frame.name`
100
+ *
101
+ * Ambiguous substring matches (>1 frame) throw with the candidate list.
102
+ * No matches at all throw a not-found ValidationError listing alternatives.
103
+ */
104
+ function resolveFrameRef(ref, frames) {
105
+ const matched = new Set();
106
+ const lookup = new Map();
107
+ const indexFrame = (frame) => {
108
+ for (const v of frame.versions) {
109
+ matched.add(v.id);
110
+ lookup.set(v.id, { frame_id: frame.id, frame_label: frame.name });
111
+ }
112
+ };
113
+ // Build a frame_version_id → frame lookup once; reused on the
114
+ // "is this a frame_version_id?" path.
115
+ const fvToFrame = new Map();
116
+ for (const f of frames) {
117
+ for (const v of f.versions)
118
+ fvToFrame.set(v.id, f);
119
+ }
120
+ // 1. Frame alias (f-…) — resolve to a Frame UUID via alias-store.
121
+ if (/^f-[0-9a-f]{3,}$/i.test(ref)) {
122
+ const resolved = resolveId(ref); // throws not_found if alias is unknown
123
+ const frame = frames.find((f) => f.id === resolved);
124
+ if (!frame) {
125
+ throw validationError(`Frame alias "${ref}" resolved to ${resolved}, but no frame with that id exists on this study.`);
126
+ }
127
+ indexFrame(frame);
128
+ return { matchedFrameVersionIds: matched, frameVersionLookup: lookup };
129
+ }
130
+ // 2. Full UUID — try Frame.id first, then frame_version_id.
131
+ if (UUID_RE.test(ref)) {
132
+ const frame = frames.find((f) => f.id === ref);
133
+ if (frame) {
134
+ indexFrame(frame);
135
+ return { matchedFrameVersionIds: matched, frameVersionLookup: lookup };
136
+ }
137
+ const parentByVersion = fvToFrame.get(ref);
138
+ if (parentByVersion) {
139
+ // Treat as frame_version_id — match only this single version.
140
+ matched.add(ref);
141
+ lookup.set(ref, { frame_id: parentByVersion.id, frame_label: parentByVersion.name });
142
+ return { matchedFrameVersionIds: matched, frameVersionLookup: lookup };
143
+ }
144
+ throw validationError(`No frame or frame_version matches "${ref}" on this study. ` +
145
+ `Run \`ish study results <id> --get frames\` (or check the frames endpoint) to see the list.`);
146
+ }
147
+ // 3. Case-insensitive substring against frame.name. The plan calls
148
+ // for the ambiguous-match error to list candidates explicitly.
149
+ const needle = ref.toLowerCase();
150
+ const candidates = frames.filter((f) => f.name && f.name.toLowerCase().includes(needle));
151
+ if (candidates.length === 0) {
152
+ // Pattern V: dedupe + cap the frame list so duplicates (workspace-side
153
+ // data hazards) don't double-list in the error prose. Carry the
154
+ // deduped list as a structured `available_values` field for agents.
155
+ const dedupedNames = Array.from(new Set(frames
156
+ .map((f) => f.name)
157
+ .filter((n) => typeof n === "string"))).slice(0, 10);
158
+ const hint = dedupedNames.length > 0
159
+ ? ` Available frames: ${dedupedNames.join(", ")}.`
160
+ : " This study has no named frames yet.";
161
+ throw validationError(`--frame "${ref}" matched no frames on this study.${hint}`, dedupedNames);
162
+ }
163
+ if (candidates.length > 1) {
164
+ const names = Array.from(new Set(candidates.map((c) => c.name).filter((n) => !!n)));
165
+ throw validationError(`--frame "${ref}" is ambiguous — matched ${candidates.length} frames: ${names.join(", ")}. ` +
166
+ `Use a more specific substring, a full Frame UUID, or an \`f-…\` alias.`, names);
167
+ }
168
+ indexFrame(candidates[0]);
169
+ return { matchedFrameVersionIds: matched, frameVersionLookup: lookup };
170
+ }
171
+ /** Resolve `--assignment <ref>` into a set of assignment_ids.
172
+ *
173
+ * UUID → exact match. Otherwise case-insensitive substring against name.
174
+ * Unlike --frame, multiple matches are NOT ambiguous — the user might
175
+ * legitimately want every "Sign up *" assignment; we just OR them.
176
+ */
177
+ function resolveAssignmentRef(ref, assignments) {
178
+ const out = new Set();
179
+ if (UUID_RE.test(ref)) {
180
+ const m = assignments.find((a) => a.id === ref);
181
+ if (!m) {
182
+ throw validationError(`No assignment matches "${ref}" on this study.`);
183
+ }
184
+ out.add(m.id);
185
+ return out;
186
+ }
187
+ const needle = ref.toLowerCase();
188
+ for (const a of assignments) {
189
+ if (a.name && a.name.toLowerCase().includes(needle))
190
+ out.add(a.id);
191
+ }
192
+ if (out.size === 0) {
193
+ const names = assignments
194
+ .map((a) => a.name)
195
+ .filter((n) => !!n)
196
+ .slice(0, 10);
197
+ const hint = names.length > 0 ? ` Available: ${names.join(", ")}.` : "";
198
+ throw validationError(`--assignment "${ref}" matched no assignments on this study.${hint}`);
199
+ }
200
+ return out;
201
+ }
202
+ /** Resolve `--iteration <ref>` to a single iteration_id, or null if no
203
+ * match (caller errors). Accepts UUID, iteration alias (`i-…`), or label. */
204
+ function resolveIterationRef(ref, iterations) {
205
+ // Pattern M: iteration aliases (`i-…`) are the canonical short ID
206
+ // everywhere else in the CLI; accept them here too. Try alias resolution
207
+ // first, then fall through to UUID-direct, then label match.
208
+ let candidate = ref;
209
+ if (ref.startsWith("i-")) {
210
+ try {
211
+ candidate = resolveId(ref);
212
+ }
213
+ catch {
214
+ // Unknown alias — let the downstream "matched no iterations" branch
215
+ // emit the labels hint; the resolveId error doesn't add value here.
216
+ }
217
+ }
218
+ if (UUID_RE.test(candidate)) {
219
+ const m = iterations.find((i) => i.id === candidate);
220
+ if (!m) {
221
+ throw validationError(`No iteration matches "${ref}" on this study.`);
222
+ }
223
+ return m.id;
224
+ }
225
+ // Labels are uppercase A-Z by backend constraint; do a case-insensitive
226
+ // exact compare so `--iteration a` works as well as `--iteration A`.
227
+ const upper = ref.toUpperCase();
228
+ const m = iterations.find((i) => (i.label ?? "").toUpperCase() === upper);
229
+ if (!m) {
230
+ const labels = iterations
231
+ .map((i) => i.label)
232
+ .filter((n) => !!n);
233
+ const hint = labels.length > 0 ? ` Available labels: ${labels.join(", ")}.` : "";
234
+ throw validationError(`--iteration "${ref}" matched no iterations on this study.${hint}`);
235
+ }
236
+ return m.id;
237
+ }
238
+ /** Resolve --participant to a UUID (alias or full UUID, via resolveId). */
239
+ function resolveParticipantRef(ref) {
240
+ // resolveId throws not_found for unknown aliases / invalid IDs.
241
+ return resolveId(ref);
242
+ }
243
+ /** Get the modality of the study. Returns a normalised lowercase string
244
+ * ("interactive" | "chat" | "video" | "audio" | "text" | "image" |
245
+ * "document"), or "unknown" if missing. */
246
+ function getModality(study) {
247
+ const m = asString(study.modality);
248
+ return m ? m.toLowerCase() : "unknown";
249
+ }
250
+ /** True if the study is participant_pair chat (where --side is meaningful). */
251
+ function isParticipantPair(study) {
252
+ if (getModality(study) !== "chat")
253
+ return false;
254
+ // mode_details lives under either `mode_details` on the study or
255
+ // `iteration.details.mode_details` per-iteration. The study-level
256
+ // `pair_mode` field (if present) is the cheap check; otherwise we
257
+ // peek at the assignments — pair studies have side="a"/"b" set.
258
+ const assignments = asArray(study.assignments);
259
+ for (const raw of assignments) {
260
+ const a = asRecord(raw);
261
+ if (a && (a.side === "a" || a.side === "b"))
262
+ return true;
263
+ }
264
+ return false;
265
+ }
266
+ /** Return true if the interaction's parent assignment has the given side. */
267
+ function interactionMatchesSide(interaction, assignmentSideById, wantedSide) {
268
+ const aid = asString(interaction.assignment_id);
269
+ if (!aid)
270
+ return false;
271
+ const side = assignmentSideById.get(aid);
272
+ return side === wantedSide;
273
+ }
274
+ /** Read `actions[0].data` defensively. Returns {} when actions is empty
275
+ * or the first row has no data block. */
276
+ function firstActionData(interaction) {
277
+ const actions = asArray(interaction.actions);
278
+ if (actions.length === 0)
279
+ return {};
280
+ const first = asRecord(actions[0]);
281
+ if (!first)
282
+ return {};
283
+ const data = asRecord(first.data);
284
+ return data ?? {};
285
+ }
286
+ /** Predicate: --segment <ref> matches this interaction.
287
+ *
288
+ * Numeric ref → equality against `data.segment_index`.
289
+ * Non-numeric → case-insensitive substring against `data.segment_label`.
290
+ */
291
+ function interactionMatchesSegment(interaction, segmentRef) {
292
+ const data = firstActionData(interaction);
293
+ const asInt = Number(segmentRef);
294
+ if (Number.isInteger(asInt) && segmentRef.trim() !== "") {
295
+ return data.segment_index === asInt;
296
+ }
297
+ const label = asString(data.segment_label);
298
+ if (!label)
299
+ return false;
300
+ return label.toLowerCase().includes(segmentRef.toLowerCase());
301
+ }
302
+ /** Predicate: --turn <n> matches this interaction. */
303
+ function interactionMatchesTurn(interaction, turn) {
304
+ const data = firstActionData(interaction);
305
+ return data.turn_index === turn;
306
+ }
307
+ /** Per-participant step filter. Walks `participant_assignments[].step_results[]`,
308
+ * keeps verdict rows whose step_id OR step.name match the user's ref, and
309
+ * optionally returns the evidence_interaction_ids set so the caller can
310
+ * drop interactions outside the evidence (when --include-evidence is set).
311
+ *
312
+ * Returns null when this participant has no surviving step_results — the
313
+ * caller treats that as "no match" for the participant.
314
+ */
315
+ function filterStepsOnParticipant(participant, stepRef) {
316
+ const assignments = asArray(participant.participant_assignments);
317
+ const needle = stepRef.toLowerCase();
318
+ const filteredAssignments = [];
319
+ const evidence = new Set();
320
+ let kept = 0;
321
+ for (const raw of assignments) {
322
+ const a = asRecord(raw);
323
+ if (!a)
324
+ continue;
325
+ const steps = asArray(a.step_results);
326
+ const survivors = [];
327
+ for (const sraw of steps) {
328
+ const s = asRecord(sraw);
329
+ if (!s)
330
+ continue;
331
+ const sid = asString(s.step_id);
332
+ const sname = asString(s.name);
333
+ const matchesId = sid !== null && sid.toLowerCase() === needle;
334
+ const matchesName = sname !== null && sname.toLowerCase().includes(needle);
335
+ if (!matchesId && !matchesName)
336
+ continue;
337
+ survivors.push(s);
338
+ for (const eid of asArray(s.evidence_interaction_ids)) {
339
+ const eidStr = asString(eid);
340
+ if (eidStr)
341
+ evidence.add(eidStr);
342
+ }
343
+ kept += 1;
344
+ }
345
+ if (survivors.length > 0) {
346
+ filteredAssignments.push({ ...a, step_results: survivors });
347
+ }
348
+ else {
349
+ // Preserve the assignment row but drop step_results entirely so
350
+ // downstream consumers see "this participant ran this assignment
351
+ // but no matching step verdicts" cleanly.
352
+ filteredAssignments.push({ ...a, step_results: [] });
353
+ }
354
+ }
355
+ if (kept === 0)
356
+ return null;
357
+ return { filteredAssignments, evidenceInteractionIds: evidence };
358
+ }
359
+ /**
360
+ * Pure entry point. See file-level comment for input/output contract.
361
+ */
362
+ export function applyResultsFilters(study, participants, rawFrames, filters) {
363
+ const warnings = [];
364
+ const modality = getModality(study);
365
+ const frames = normaliseFrames(rawFrames);
366
+ const assignments = normaliseAssignments(asArray(study.assignments));
367
+ const iterations = normaliseIterations(asArray(study.iterations));
368
+ const assignmentSideById = new Map(assignments.map((a) => [a.id, a.side]));
369
+ // --- Resolve refs upfront so we can fail fast with clear errors. ---
370
+ let matchedFrameVersionIds = new Set();
371
+ let frameVersionLookup = new Map();
372
+ if (filters.frame !== undefined) {
373
+ if (modality !== "interactive") {
374
+ warnings.push(`--frame is only meaningful on interactive studies; ignored on modality "${modality}".`);
375
+ }
376
+ else {
377
+ const resolved = resolveFrameRef(filters.frame, frames);
378
+ matchedFrameVersionIds = resolved.matchedFrameVersionIds;
379
+ frameVersionLookup = resolved.frameVersionLookup;
380
+ }
381
+ }
382
+ let matchedAssignmentIds = null;
383
+ if (filters.assignment !== undefined) {
384
+ matchedAssignmentIds = resolveAssignmentRef(filters.assignment, assignments);
385
+ }
386
+ let matchedIterationId = null;
387
+ if (filters.iteration !== undefined) {
388
+ matchedIterationId = resolveIterationRef(filters.iteration, iterations);
389
+ }
390
+ let matchedParticipantId = null;
391
+ if (filters.participant !== undefined) {
392
+ matchedParticipantId = resolveParticipantRef(filters.participant);
393
+ }
394
+ // --- Modality-mismatch warnings (filter still runs but degrades to no-op). ---
395
+ if (filters.segment !== undefined) {
396
+ if (modality === "interactive" ||
397
+ modality === "chat" ||
398
+ modality === "image" ||
399
+ modality === "unknown") {
400
+ warnings.push(`--segment is only meaningful on video, audio, text, or document studies; ignored on modality "${modality}".`);
401
+ }
402
+ }
403
+ if (filters.turn !== undefined && modality !== "chat") {
404
+ warnings.push(`--turn is only meaningful on chat studies; ignored on modality "${modality}".`);
405
+ }
406
+ if (filters.side !== undefined && !isParticipantPair(study)) {
407
+ warnings.push(`--side is only meaningful on participant_pair chat studies; ignored on modality "${modality}".`);
408
+ }
409
+ // Normalise sentiment labels to lowercase up-front (case-insensitive).
410
+ const sentimentFilter = filters.sentiment
411
+ ? new Set(filters.sentiment.map((s) => s.toLowerCase()))
412
+ : null;
413
+ const actorFilter = filters.actor ? filters.actor.toLowerCase() : null;
414
+ // Set of warnings that downgrade a flag to a no-op for the predicate walk.
415
+ const segmentActive = filters.segment !== undefined && modality !== "interactive"
416
+ && modality !== "chat" && modality !== "image" && modality !== "unknown";
417
+ const turnActive = filters.turn !== undefined && modality === "chat";
418
+ const sideActive = filters.side !== undefined && isParticipantPair(study);
419
+ const frameActive = filters.frame !== undefined && modality === "interactive";
420
+ // Track whether ANY interaction-level filter is on. Determines whether
421
+ // we drop empty participants or keep them (per plan §3 "empty participants").
422
+ const interactionLevelFilterActive = frameActive ||
423
+ segmentActive ||
424
+ turnActive ||
425
+ sideActive ||
426
+ matchedAssignmentIds !== null ||
427
+ sentimentFilter !== null ||
428
+ actorFilter !== null;
429
+ // --- Pre-filter totals (for the totals_unfiltered envelope field). ---
430
+ let unfilteredInteractionCount = 0;
431
+ for (const p of participants) {
432
+ unfilteredInteractionCount += asArray(p.interactions).length;
433
+ }
434
+ const totals_unfiltered = {
435
+ participant_count: participants.length,
436
+ interaction_count: unfilteredInteractionCount,
437
+ };
438
+ // --- Walk participants. ---
439
+ const out = [];
440
+ for (const participant of participants) {
441
+ // 1. Participant-level filters (drop the whole row up-front).
442
+ if (matchedParticipantId !== null &&
443
+ asString(participant.id) !== matchedParticipantId) {
444
+ continue;
445
+ }
446
+ if (matchedIterationId !== null &&
447
+ asString(participant.iteration_id) !== matchedIterationId) {
448
+ continue;
449
+ }
450
+ // 2. --step is per-participant: rewrites participant_assignments
451
+ // and surfaces evidence_interaction_ids if --include-evidence.
452
+ let evidenceInteractionIds = null;
453
+ let rewrittenAssignments = null;
454
+ if (filters.step !== undefined) {
455
+ const stepResult = filterStepsOnParticipant(participant, filters.step);
456
+ if (!stepResult) {
457
+ // This participant has no matching step verdicts — drop entirely
458
+ // (--step is participant-level "matched at least one verdict").
459
+ continue;
460
+ }
461
+ rewrittenAssignments = stepResult.filteredAssignments;
462
+ if (filters.includeEvidence) {
463
+ evidenceInteractionIds = stepResult.evidenceInteractionIds;
464
+ }
465
+ }
466
+ // 3. Walk interactions[] and drop those failing any predicate.
467
+ const survivingInteractions = [];
468
+ for (const raw of asArray(participant.interactions)) {
469
+ const interaction = asRecord(raw);
470
+ if (!interaction)
471
+ continue;
472
+ // --frame (interactive only)
473
+ if (frameActive) {
474
+ const fvId = asString(interaction.frame_version_id);
475
+ if (fvId === null) {
476
+ if (!filters.includeUnmatched)
477
+ continue;
478
+ // Keep this row; the projection layer surfaces it under the
479
+ // synthetic `_unmatched` bucket. We don't mutate the row here;
480
+ // the lookup map simply lacks an entry, which the projection
481
+ // layer interprets as "_unmatched".
482
+ }
483
+ else if (!matchedFrameVersionIds.has(fvId)) {
484
+ continue;
485
+ }
486
+ }
487
+ // --segment (video/audio/text/document)
488
+ if (segmentActive) {
489
+ if (!interactionMatchesSegment(interaction, filters.segment)) {
490
+ continue;
491
+ }
492
+ }
493
+ // --turn (chat)
494
+ if (turnActive) {
495
+ if (!interactionMatchesTurn(interaction, filters.turn)) {
496
+ continue;
497
+ }
498
+ }
499
+ // --side (participant_pair chat)
500
+ if (sideActive) {
501
+ if (!interactionMatchesSide(interaction, assignmentSideById, filters.side)) {
502
+ continue;
503
+ }
504
+ }
505
+ // --assignment
506
+ if (matchedAssignmentIds !== null) {
507
+ const aid = asString(interaction.assignment_id);
508
+ if (!aid || !matchedAssignmentIds.has(aid))
509
+ continue;
510
+ }
511
+ // --actor
512
+ if (actorFilter !== null) {
513
+ const actor = asString(interaction.actor);
514
+ if (!actor || actor.toLowerCase() !== actorFilter)
515
+ continue;
516
+ }
517
+ // --sentiment (drops null-sentiment rows only when this filter set)
518
+ if (sentimentFilter !== null) {
519
+ const sentiment = asRecord(interaction.sentiment);
520
+ const label = sentiment ? asString(sentiment.label) : null;
521
+ if (!label || !sentimentFilter.has(label.toLowerCase()))
522
+ continue;
523
+ }
524
+ // --include-evidence (paired with --step)
525
+ if (evidenceInteractionIds !== null) {
526
+ const iid = asString(interaction.id);
527
+ if (!iid || !evidenceInteractionIds.has(iid))
528
+ continue;
529
+ }
530
+ survivingInteractions.push(interaction);
531
+ }
532
+ // 4. Drop participants with zero surviving interactions only when
533
+ // an interaction-level filter is set (preserves the stable schema
534
+ // when the caller just asked "who ran?" without slicing).
535
+ if (interactionLevelFilterActive && survivingInteractions.length === 0) {
536
+ continue;
537
+ }
538
+ const next = {
539
+ ...participant,
540
+ interactions: survivingInteractions,
541
+ };
542
+ if (rewrittenAssignments !== null) {
543
+ next.participant_assignments = rewrittenAssignments;
544
+ }
545
+ out.push(next);
546
+ }
547
+ // The frames list returned to the caller is the raw payload (only the
548
+ // surface uses it for the `--get frames` shortcut). The projection
549
+ // layer relies on `frameVersionLookup` instead.
550
+ return {
551
+ study: { ...study },
552
+ participants: out,
553
+ frames: rawFrames,
554
+ totals_unfiltered,
555
+ warnings,
556
+ matchedFrameVersionIds,
557
+ frameVersionLookup,
558
+ };
559
+ }