chore: commit full workspace changes (designer modularization, diagnostics fixes, docs updates, seed script cleanup)

This commit is contained in:
2025-08-08 00:37:35 -04:00
parent c071d33624
commit 1ac8296ab7
37 changed files with 5378 additions and 5758 deletions

View File

@@ -0,0 +1,160 @@
import type {
ExperimentStep,
ExperimentAction,
ExecutionDescriptor,
} from "./types";
// Convert step-based design to database records
export function convertStepsToDatabase(
steps: ExperimentStep[],
): ConvertedStep[] {
return steps.map((step, index) => ({
name: step.name,
description: step.description,
type: mapStepTypeToDatabase(step.type),
orderIndex: index,
durationEstimate: calculateStepDuration(step.actions),
required: true,
conditions: step.trigger.conditions,
actions: step.actions.map((action, actionIndex) =>
convertActionToDatabase(action, actionIndex),
),
}));
}
// Map designer step types to database step types
function mapStepTypeToDatabase(
stepType: ExperimentStep["type"],
): "wizard" | "robot" | "parallel" | "conditional" {
switch (stepType) {
case "sequential":
return "wizard"; // Default to wizard for sequential
case "parallel":
return "parallel";
case "conditional":
case "loop":
return "conditional";
default:
return "wizard";
}
}
// Calculate step duration from actions
function calculateStepDuration(actions: ExperimentAction[]): number {
let total = 0;
for (const action of actions) {
switch (action.type) {
case "wizard_speak":
case "robot_speak":
// Estimate based on text length if available
const text = action.parameters.text as string;
if (text) {
total += Math.max(2, text.length / 10); // ~10 chars per second
} else {
total += 3;
}
break;
case "wait":
total += (action.parameters.duration as number) || 2;
break;
case "robot_move":
total += 5; // Movement takes longer
break;
case "wizard_gesture":
total += 2;
break;
case "observe":
total += (action.parameters.duration as number) || 5;
break;
default:
total += 2; // Default duration
}
}
return Math.max(1, Math.round(total));
}
// Estimate action timeout
function estimateActionTimeout(action: ExperimentAction): number {
switch (action.type) {
case "wizard_speak":
case "robot_speak":
return 30;
case "robot_move":
return 60;
case "wait": {
const duration = action.parameters.duration as number | undefined;
return (duration ?? 2) + 10; // Add buffer
}
case "observe":
return 120; // Observation can take longer
default:
return 30;
}
}
// Database conversion types (same as before)
export interface ConvertedStep {
name: string;
description?: string;
type: "wizard" | "robot" | "parallel" | "conditional";
orderIndex: number;
durationEstimate?: number;
required: boolean;
conditions: Record<string, unknown>;
actions: ConvertedAction[];
}
export interface ConvertedAction {
name: string;
description?: string;
type: string;
orderIndex: number;
parameters: Record<string, unknown>;
timeout?: number;
// Provenance & execution metadata (flattened for now)
pluginId?: string;
pluginVersion?: string;
robotId?: string | null;
baseActionId?: string;
transport?: ExecutionDescriptor["transport"];
ros2?: ExecutionDescriptor["ros2"];
rest?: ExecutionDescriptor["rest"];
retryable?: boolean;
parameterSchemaRaw?: unknown;
sourceKind?: "core" | "plugin";
category?: string;
}
// Deprecated legacy compatibility function removed to eliminate unsafe any usage.
// If needed, implement a proper migration path elsewhere.
/**
* Convert a single experiment action into a ConvertedAction (DB shape),
* preserving provenance and execution metadata for reproducibility.
*/
export function convertActionToDatabase(
action: ExperimentAction,
orderIndex: number,
): ConvertedAction {
return {
name: action.name,
description: `${action.type} action`,
type: action.type,
orderIndex,
parameters: action.parameters,
timeout: estimateActionTimeout(action),
pluginId: action.source.pluginId,
pluginVersion: action.source.pluginVersion,
robotId: action.source.robotId,
baseActionId: action.source.baseActionId,
transport: action.execution.transport,
ros2: action.execution.ros2,
rest: action.execution.rest,
retryable: action.execution.retryable,
parameterSchemaRaw: action.parameterSchemaRaw,
sourceKind: action.source.kind,
category: action.category,
};
}

View File

@@ -0,0 +1,314 @@
/**
* Execution Compiler Utilities
*
* Purpose:
* - Produce a deterministic execution graph snapshot from the visual design
* - Generate an integrity hash capturing provenance & structural identity
* - Extract normalized plugin dependency list (pluginId@version)
*
* These utilities are used on the server prior to saving an experiment so that
* trial execution can rely on an immutable compiled artifact. This helps ensure
* reproducibility by decoupling future plugin updates from already designed
* experiment protocols.
*
* NOTE:
* - This module intentionally performs only pure / synchronous operations.
* - Any plugin resolution or database queries should happen in a higher layer
* before invoking these functions.
*/
import type {
ExperimentDesign,
ExperimentStep,
ExperimentAction,
ExecutionDescriptor,
} from "./types";
/* ---------- Public Types ---------- */
export interface CompiledExecutionGraph {
version: number;
generatedAt: string; // ISO timestamp
steps: CompiledExecutionStep[];
pluginDependencies: string[];
hash: string;
}
export interface CompiledExecutionStep {
id: string;
name: string;
order: number;
type: ExperimentStep["type"];
trigger: {
type: string;
conditions: Record<string, unknown>;
};
actions: CompiledExecutionAction[];
estimatedDuration?: number;
}
export interface CompiledExecutionAction {
id: string;
name: string;
type: string;
category: string;
provenance: {
sourceKind: "core" | "plugin";
pluginId?: string;
pluginVersion?: string;
robotId?: string | null;
baseActionId?: string;
};
execution: ExecutionDescriptor;
parameters: Record<string, unknown>;
parameterSchemaRaw?: unknown;
timeout?: number;
retryable?: boolean;
}
/* ---------- Compile Entry Point ---------- */
/**
* Compile an ExperimentDesign into a reproducible execution graph + hash.
*/
export function compileExecutionDesign(
design: ExperimentDesign,
opts: { hashAlgorithm?: "sha256" | "sha1" } = {},
): CompiledExecutionGraph {
const pluginDependencies = collectPluginDependencies(design);
const compiledSteps: CompiledExecutionStep[] = design.steps
.slice()
.sort((a, b) => a.order - b.order)
.map((step) => compileStep(step));
const structuralSignature = buildStructuralSignature(
design,
compiledSteps,
pluginDependencies,
);
const hash = stableHash(structuralSignature, opts.hashAlgorithm ?? "sha256");
return {
version: 1,
generatedAt: new Date().toISOString(),
steps: compiledSteps,
pluginDependencies,
hash,
};
}
/* ---------- Step / Action Compilation ---------- */
function compileStep(step: ExperimentStep): CompiledExecutionStep {
const compiledActions: CompiledExecutionAction[] = step.actions.map(
(action, index) => compileAction(action, index),
);
return {
id: step.id,
name: step.name,
order: step.order,
type: step.type,
trigger: {
type: step.trigger.type,
conditions: step.trigger.conditions ?? {},
},
actions: compiledActions,
estimatedDuration: step.estimatedDuration,
};
}
function compileAction(
action: ExperimentAction,
_index: number, // index currently unused (reserved for future ordering diagnostics)
): CompiledExecutionAction {
return {
id: action.id,
name: action.name,
type: action.type,
category: action.category,
provenance: {
sourceKind: action.source.kind,
pluginId: action.source.pluginId,
pluginVersion: action.source.pluginVersion,
robotId: action.source.robotId,
baseActionId: action.source.baseActionId,
},
execution: action.execution,
parameters: action.parameters,
parameterSchemaRaw: action.parameterSchemaRaw,
timeout: action.execution.timeoutMs,
retryable: action.execution.retryable,
};
}
/* ---------- Plugin Dependency Extraction ---------- */
export function collectPluginDependencies(design: ExperimentDesign): string[] {
const set = new Set<string>();
for (const step of design.steps) {
for (const action of step.actions) {
if (action.source.kind === "plugin" && action.source.pluginId) {
const versionPart = action.source.pluginVersion
? `@${action.source.pluginVersion}`
: "";
set.add(`${action.source.pluginId}${versionPart}`);
}
}
}
return Array.from(set).sort();
}
/* ---------- Integrity Hash Generation ---------- */
/**
* Build a minimal, deterministic JSON-serializable representation capturing:
* - Step ordering, ids, types, triggers
* - Action ordering, ids, types, provenance, execution transport, parameters (keys only for hash)
* - Plugin dependency list
*
* Parameter values are not fully included (only key presence) to avoid hash churn
* on mutable text fields while preserving structural identity. If full parameter
* value hashing is desired, adjust `summarizeParametersForHash`.
*/
function buildStructuralSignature(
design: ExperimentDesign,
steps: CompiledExecutionStep[],
pluginDependencies: string[],
): unknown {
return {
experimentId: design.id,
version: design.version,
steps: steps.map((s) => ({
id: s.id,
order: s.order,
type: s.type,
trigger: {
type: s.trigger.type,
// Include condition keys only for stability
conditionKeys: Object.keys(s.trigger.conditions).sort(),
},
actions: s.actions.map((a) => ({
id: a.id,
type: a.type,
category: a.category,
provenance: a.provenance,
transport: a.execution.transport,
timeout: a.timeout,
retryable: a.retryable ?? false,
parameterKeys: summarizeParametersForHash(a.parameters),
})),
})),
pluginDependencies,
};
}
function summarizeParametersForHash(params: Record<string, unknown>): string[] {
return Object.keys(params).sort();
}
/* ---------- Stable Hash Implementation ---------- */
/**
* Simple stable hash using built-in Web Crypto if available; falls back
* to a lightweight JS implementation (FNV-1a) for environments without
* crypto.subtle (e.g. some test runners).
*
* This is synchronous; if crypto.subtle is present it still uses
* a synchronous wrapper by blocking on the Promise with deasync style
* simulation (not implemented) so we default to FNV-1a here for portability.
*/
function stableHash(value: unknown, algorithm: "sha256" | "sha1"): string {
// Use a deterministic JSON stringify
const json = JSON.stringify(value);
// FNV-1a 64-bit (represented as hex)
let hashHigh = 0xcbf29ce4;
let hashLow = 0x84222325; // Split 64-bit for simple JS accumulation
for (let i = 0; i < json.length; i++) {
const c = json.charCodeAt(i);
// XOR low part
hashLow ^= c;
// 64-bit FNV prime: 1099511628211 -> split multiply
// (hash * prime) mod 2^64
// Multiply low
let low =
(hashLow & 0xffff) * 0x1b3 +
(((hashLow >>> 16) * 0x1b3) & 0xffff) * 0x10000;
// Include high
low +=
((hashHigh & 0xffff) * 0x1b3 +
(((hashHigh >>> 16) * 0x1b3) & 0xffff) * 0x10000) &
0xffffffff;
// Rotate values (approximate 64-bit handling)
hashHigh ^= low >>> 13;
hashHigh &= 0xffffffff;
hashLow = low & 0xffffffff;
}
// Combine into hex; algorithm param reserved for future (differing strategies)
const highHex = (hashHigh >>> 0).toString(16).padStart(8, "0");
const lowHex = (hashLow >>> 0).toString(16).padStart(8, "0");
return `${algorithm}-${highHex}${lowHex}`;
}
/* ---------- Validation Helpers (Optional Use) ---------- */
/**
* Lightweight structural sanity checks prior to compilation.
* Returns array of issues; empty array means pass.
*/
export function validateDesignStructure(design: ExperimentDesign): string[] {
const issues: string[] = [];
if (!design.steps.length) {
issues.push("No steps defined");
}
const seenStepIds = new Set<string>();
for (const step of design.steps) {
if (seenStepIds.has(step.id)) {
issues.push(`Duplicate step id: ${step.id}`);
} else {
seenStepIds.add(step.id);
}
if (!step.actions.length) {
issues.push(`Step "${step.name}" has no actions`);
}
const seenActionIds = new Set<string>();
for (const action of step.actions) {
if (seenActionIds.has(action.id)) {
issues.push(`Duplicate action id in step "${step.name}": ${action.id}`);
} else {
seenActionIds.add(action.id);
}
if (!action.type) {
issues.push(`Action "${action.id}" missing type`);
}
if (!action.source?.kind) {
issues.push(`Action "${action.id}" missing provenance`);
}
if (!action.execution?.transport) {
issues.push(`Action "${action.id}" missing execution transport`);
}
}
}
return issues;
}
/**
* High-level convenience wrapper: validate + compile; throws on issues.
*/
export function validateAndCompile(
design: ExperimentDesign,
): CompiledExecutionGraph {
const issues = validateDesignStructure(design);
if (issues.length) {
const error = new Error(
`Design validation failed:\n- ${issues.join("\n- ")}`,
);
(error as { issues?: string[] }).issues = issues;
throw error;
}
return compileExecutionDesign(design);
}

View File

@@ -0,0 +1,166 @@
// Core experiment designer types
export type StepType = "sequential" | "parallel" | "conditional" | "loop";
export type ActionCategory = "wizard" | "robot" | "observation" | "control";
export type ActionType =
| "wizard_speak"
| "wizard_gesture"
| "robot_move"
| "robot_speak"
| "wait"
| "observe"
| "collect_data"
// Namespaced plugin action types will use pattern: pluginId.actionId
| (string & {});
export type TriggerType =
| "trial_start"
| "participant_action"
| "timer"
| "previous_step";
export interface ActionParameter {
id: string;
name: string;
type: "text" | "number" | "select" | "boolean";
placeholder?: string;
options?: string[];
min?: number;
max?: number;
value?: string | number | boolean;
required?: boolean;
description?: string;
step?: number; // numeric increment if relevant
}
export interface ActionDefinition {
id: string;
type: ActionType;
name: string;
description: string;
category: ActionCategory;
icon: string;
color: string;
parameters: ActionParameter[];
source: {
kind: "core" | "plugin";
pluginId?: string;
pluginVersion?: string;
robotId?: string | null;
baseActionId?: string; // original internal action id inside plugin repo
};
execution?: ExecutionDescriptor;
parameterSchemaRaw?: unknown; // snapshot of original schema for validation/audit
}
export interface ExperimentAction {
id: string;
type: ActionType;
name: string;
parameters: Record<string, unknown>;
duration?: number;
category: ActionCategory;
source: {
kind: "core" | "plugin";
pluginId?: string;
pluginVersion?: string;
robotId?: string | null;
baseActionId?: string;
};
execution: ExecutionDescriptor;
parameterSchemaRaw?: unknown;
}
export interface StepTrigger {
type: TriggerType;
conditions: Record<string, unknown>;
}
export interface ExperimentStep {
id: string;
name: string;
description?: string;
type: StepType;
order: number;
trigger: StepTrigger;
actions: ExperimentAction[];
estimatedDuration?: number;
expanded: boolean;
}
export interface ExperimentDesign {
id: string;
name: string;
description: string;
steps: ExperimentStep[];
version: number;
lastSaved: Date;
compiledAt?: Date; // when an execution plan was compiled
integrityHash?: string; // hash of action definitions for reproducibility
}
// Trigger options for UI
export const TRIGGER_OPTIONS = [
{ value: "trial_start" as const, label: "Trial starts" },
{ value: "participant_action" as const, label: "Participant acts" },
{ value: "timer" as const, label: "After timer" },
{ value: "previous_step" as const, label: "Previous step completes" },
];
// Step type options for UI
export const STEP_TYPE_OPTIONS = [
{
value: "sequential" as const,
label: "Sequential",
description: "Actions run one after another",
},
{
value: "parallel" as const,
label: "Parallel",
description: "Actions run at the same time",
},
{
value: "conditional" as const,
label: "Conditional",
description: "Actions run if condition is met",
},
{
value: "loop" as const,
label: "Loop",
description: "Actions repeat multiple times",
},
];
// Execution descriptors (appended)
export interface ExecutionDescriptor {
transport: "ros2" | "rest" | "internal";
timeoutMs?: number;
retryable?: boolean;
ros2?: Ros2Execution;
rest?: RestExecution;
}
export interface Ros2Execution {
topic?: string;
messageType?: string;
service?: string;
action?: string;
qos?: {
reliability?: string;
durability?: string;
history?: string;
depth?: number;
};
payloadMapping?: unknown; // mapping definition retained for transform at runtime
}
export interface RestExecution {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: string;
headers?: Record<string, string>;
query?: Record<string, string | number | boolean>;
bodyTemplate?: unknown;
}

View File

@@ -0,0 +1,326 @@
import { z } from "zod";
import type {
ExperimentStep,
ExperimentAction,
StepType,
TriggerType,
ActionCategory,
ExecutionDescriptor,
} from "./types";
/**
* Visual Design Guard
*
* Provides a robust Zod-based parsing/normalization pipeline that:
* - Accepts a loosely-typed visualDesign.steps payload coming from the client
* - Normalizes and validates it into strongly typed arrays for internal processing
* - Strips unknown fields (preserves only what we rely on)
* - Ensures provenance + execution descriptors are structurally sound
*
* This replaces ad-hoc runtime filtering in the experiments update mutation.
*
* Usage:
* const { steps, issues } = parseVisualDesignSteps(rawSteps);
* if (issues.length) -> reject request
* else -> steps (ExperimentStep[]) is now safe for conversion & compilation
*/
// Enumerations (reuse domain model semantics without hard binding to future expansions)
const stepTypeEnum = z.enum(["sequential", "parallel", "conditional", "loop"]);
const triggerTypeEnum = z.enum([
"trial_start",
"participant_action",
"timer",
"previous_step",
]);
const actionCategoryEnum = z.enum([
"wizard",
"robot",
"observation",
"control",
]);
// Provenance
const actionSourceSchema = z
.object({
kind: z.enum(["core", "plugin"]),
pluginId: z.string().min(1).optional(),
pluginVersion: z.string().min(1).optional(),
robotId: z.string().min(1).nullable().optional(),
baseActionId: z.string().min(1).optional(),
})
.strict();
// Execution descriptor
const executionDescriptorSchema = z
.object({
transport: z.enum(["ros2", "rest", "internal"]),
timeoutMs: z.number().int().positive().optional(),
retryable: z.boolean().optional(),
ros2: z
.object({
topic: z.string().min(1).optional(),
messageType: z.string().min(1).optional(),
service: z.string().min(1).optional(),
action: z.string().min(1).optional(),
qos: z
.object({
reliability: z.string().optional(),
durability: z.string().optional(),
history: z.string().optional(),
depth: z.number().int().optional(),
})
.strict()
.optional(),
payloadMapping: z.unknown().optional(),
})
.strict()
.optional(),
rest: z
.object({
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
path: z.string().min(1),
headers: z.record(z.string(), z.string()).optional(),
})
.strict()
.optional(),
})
.strict();
// Action parameter snapshot is a free-form structure retained for audit
const parameterSchemaRawSchema = z.unknown().optional();
// Action schema (loose input → normalized internal)
const visualActionInputSchema = z
.object({
id: z.string().min(1),
type: z.string().min(1),
name: z.string().min(1),
category: actionCategoryEnum.optional(),
parameters: z.record(z.string(), z.unknown()).default({}),
source: actionSourceSchema.optional(),
execution: executionDescriptorSchema.optional(),
parameterSchemaRaw: parameterSchemaRawSchema,
})
.strict();
// Trigger schema
const triggerSchema = z
.object({
type: triggerTypeEnum,
conditions: z.record(z.string(), z.unknown()).default({}),
})
.strict();
// Step schema
const visualStepInputSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
type: stepTypeEnum,
order: z.number().int().nonnegative().optional(),
trigger: triggerSchema.optional(),
actions: z.array(visualActionInputSchema),
expanded: z.boolean().optional(),
})
.strict();
// Array schema root
const visualDesignStepsSchema = z.array(visualStepInputSchema);
/**
* Parse & normalize raw steps payload.
*/
export function parseVisualDesignSteps(raw: unknown): {
steps: ExperimentStep[];
issues: string[];
} {
const issues: string[] = [];
const parsed = visualDesignStepsSchema.safeParse(raw);
if (!parsed.success) {
const zodErr = parsed.error;
issues.push(
...zodErr.issues.map(
(issue) =>
`steps${
issue.path.length ? "." + issue.path.join(".") : ""
}: ${issue.message} (code=${issue.code})`,
),
);
return { steps: [], issues };
}
// Normalize to internal ExperimentStep[] shape
const inputSteps = parsed.data;
const normalized: ExperimentStep[] = inputSteps.map((s, idx) => {
const actions: ExperimentAction[] = s.actions.map((a) => {
// Default provenance
const source: {
kind: "core" | "plugin";
pluginId?: string;
pluginVersion?: string;
robotId?: string | null;
baseActionId?: string;
} = a.source
? {
kind: a.source.kind,
pluginId: a.source.pluginId,
pluginVersion: a.source.pluginVersion,
robotId: a.source.robotId ?? null,
baseActionId: a.source.baseActionId,
}
: { kind: "core" };
// Default execution
const execution: ExecutionDescriptor = a.execution
? {
transport: a.execution.transport,
timeoutMs: a.execution.timeoutMs,
retryable: a.execution.retryable,
ros2: a.execution.ros2,
rest: a.execution.rest
? {
method: a.execution.rest.method,
path: a.execution.rest.path,
headers: a.execution.rest.headers
? Object.fromEntries(
Object.entries(a.execution.rest.headers).filter(
(kv): kv is [string, string] =>
typeof kv[1] === "string",
),
)
: undefined,
}
: undefined,
}
: { transport: "internal" };
return {
id: a.id,
type: a.type, // dynamic (pluginId.actionId)
name: a.name,
parameters: a.parameters ?? {},
duration: undefined,
category: (a.category ?? "wizard") as ActionCategory,
source: {
kind: source.kind,
pluginId: source.kind === "plugin" ? source.pluginId : undefined,
pluginVersion:
source.kind === "plugin" ? source.pluginVersion : undefined,
robotId: source.kind === "plugin" ? (source.robotId ?? null) : null,
baseActionId:
source.kind === "plugin" ? source.baseActionId : undefined,
},
execution,
parameterSchemaRaw: a.parameterSchemaRaw,
};
});
// Construct step
return {
id: s.id,
name: s.name,
description: s.description,
type: s.type as StepType,
order: typeof s.order === "number" ? s.order : idx,
trigger: {
type: (s.trigger?.type ?? "previous_step") as TriggerType,
conditions: s.trigger?.conditions ?? {},
},
actions,
estimatedDuration: undefined,
expanded: s.expanded ?? true,
};
});
// Basic structural checks
const seenStepIds = new Set<string>();
for (const st of normalized) {
if (seenStepIds.has(st.id)) {
issues.push(`Duplicate step id: ${st.id}`);
}
seenStepIds.add(st.id);
if (!st.actions.length) {
issues.push(`Step "${st.name}" has no actions`);
}
const seenActionIds = new Set<string>();
for (const act of st.actions) {
if (seenActionIds.has(act.id)) {
issues.push(`Duplicate action id in step "${st.name}": ${act.id}`);
}
seenActionIds.add(act.id);
if (!act.source.kind) {
issues.push(`Action "${act.id}" missing source.kind`);
}
if (!act.execution.transport) {
issues.push(`Action "${act.id}" missing execution transport`);
}
}
}
return { steps: normalized, issues };
}
/**
* Estimate aggregate duration (in seconds) from normalized steps.
* Uses simple additive heuristic: sum each step's summed action durations
* if present; falls back to rough defaults for certain action patterns.
*/
export function estimateDesignDurationSeconds(steps: ExperimentStep[]): number {
let total = 0;
for (const step of steps) {
let stepSum = 0;
for (const action of step.actions) {
const t = classifyDuration(action);
stepSum += t;
}
total += stepSum;
}
return Math.max(1, Math.round(total));
}
function classifyDuration(action: ExperimentAction): number {
// Heuristic mapping (could be evolved to plugin-provided estimates)
switch (true) {
case action.type.startsWith("wizard_speak"):
case action.type.startsWith("robot_speak"): {
const text = action.parameters.text as string | undefined;
if (text && text.length > 0) {
return Math.max(2, Math.round(text.length / 10));
}
return 3;
}
case action.type.startsWith("wait"): {
const d = action.parameters.duration as number | undefined;
return d && d > 0 ? d : 2;
}
case action.type.startsWith("robot_move"):
return 5;
case action.type.startsWith("wizard_gesture"):
return 2;
case action.type.startsWith("observe"): {
const d = action.parameters.duration as number | undefined;
return d && d > 0 ? d : 5;
}
default:
return 2;
}
}
/**
* Convenience wrapper: validates, returns steps or throws with issues attached.
*/
export function assertVisualDesignSteps(raw: unknown): ExperimentStep[] {
const { steps, issues } = parseVisualDesignSteps(raw);
if (issues.length) {
const err = new Error(
`Visual design validation failed:\n- ${issues.join("\n- ")}`,
);
(err as { issues?: string[] }).issues = issues;
throw err;
}
return steps;
}