mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 11:47:51 -04:00
chore: clean diagnostics and prepare for designer structural refactor (stub legacy useActiveStudy)
This commit is contained in:
391
src/components/experiments/designer/state/hashing.ts
Normal file
391
src/components/experiments/designer/state/hashing.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Hashing utilities for the Experiment Designer.
|
||||
*
|
||||
* Implements deterministic, canonical, incremental hashing per the redesign spec:
|
||||
* - Stable structural hashing for steps and actions
|
||||
* - Optional inclusion of parameter VALUES vs only parameter KEYS
|
||||
* - Incremental hash computation to avoid recomputing entire design on small changes
|
||||
* - Action signature hashing (schema/provenance sensitive) for drift detection
|
||||
*
|
||||
* Default behavior excludes parameter values from the design hash to reduce false-positive drift
|
||||
* caused by content edits (reproducibility concerns focus on structure + provenance).
|
||||
*/
|
||||
|
||||
import type {
|
||||
ExperimentAction,
|
||||
ExperimentStep,
|
||||
ExecutionDescriptor,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Canonicalization */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
type CanonicalPrimitive = string | number | boolean | null;
|
||||
type CanonicalValue =
|
||||
| CanonicalPrimitive
|
||||
| CanonicalValue[]
|
||||
| { [key: string]: CanonicalValue };
|
||||
|
||||
/**
|
||||
* Recursively canonicalize an unknown value:
|
||||
* - Removes undefined properties
|
||||
* - Sorts object keys
|
||||
* - Leaves arrays in existing (semantic) order
|
||||
*/
|
||||
function canonicalize(value: unknown): CanonicalValue {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => canonicalize(v));
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const out: Record<string, CanonicalValue> = {};
|
||||
Object.keys(obj)
|
||||
.filter((k) => obj[k] !== undefined)
|
||||
.sort()
|
||||
.forEach((k) => {
|
||||
out[k] = canonicalize(obj[k]);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
// Unsupported types (symbol, function, bigint) replaced with null
|
||||
return null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Hashing Primitives */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert an ArrayBuffer to a lowercase hex string.
|
||||
*/
|
||||
function bufferToHex(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let hex = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const b = bytes[i]?.toString(16).padStart(2, "0");
|
||||
hex += b;
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a UTF-8 string using Web Crypto if available, else Node's crypto.
|
||||
*/
|
||||
async function hashString(input: string): Promise<string> {
|
||||
// Prefer Web Crypto subtle (Edge/Browser compatible)
|
||||
if (typeof globalThis.crypto?.subtle?.digest === "function") {
|
||||
const enc = new TextEncoder().encode(input);
|
||||
const digest = await globalThis.crypto.subtle.digest("SHA-256", enc);
|
||||
return bufferToHex(digest);
|
||||
}
|
||||
|
||||
// Fallback to Node (should not execute in Edge runtime)
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const nodeCrypto: typeof import("crypto") = require("crypto");
|
||||
return nodeCrypto.createHash("sha256").update(input).digest("hex");
|
||||
} catch {
|
||||
throw new Error("No suitable crypto implementation available for hashing.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash an object using canonical JSON serialization (no whitespace, sorted keys).
|
||||
*/
|
||||
export async function hashObject(obj: unknown): Promise<string> {
|
||||
const canonical = canonicalize(obj);
|
||||
return hashString(JSON.stringify(canonical));
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Structural Projections */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface DesignHashOptions {
|
||||
/**
|
||||
* Include parameter VALUES in hash rather than only parameter KEY sets.
|
||||
* Defaults to false (only parameter keys) to focus on structural reproducibility.
|
||||
*/
|
||||
includeParameterValues?: boolean;
|
||||
/**
|
||||
* Include action descriptive user-facing metadata (e.g. action.name) in hash.
|
||||
* Defaults to true - set false if wanting purely behavioral signature.
|
||||
*/
|
||||
includeActionNames?: boolean;
|
||||
/**
|
||||
* Include step descriptive fields (step.name, step.description).
|
||||
* Defaults to true.
|
||||
*/
|
||||
includeStepNames?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<DesignHashOptions> = {
|
||||
includeParameterValues: false,
|
||||
includeActionNames: true,
|
||||
includeStepNames: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Projection of an action for design hash purposes.
|
||||
*/
|
||||
function projectActionForDesign(
|
||||
action: ExperimentAction,
|
||||
options: Required<DesignHashOptions>,
|
||||
): Record<string, unknown> {
|
||||
const parameterProjection = options.includeParameterValues
|
||||
? canonicalize(action.parameters)
|
||||
: Object.keys(action.parameters).sort();
|
||||
|
||||
const base: Record<string, unknown> = {
|
||||
id: action.id,
|
||||
type: action.type,
|
||||
source: {
|
||||
kind: action.source.kind,
|
||||
pluginId: action.source.pluginId,
|
||||
pluginVersion: action.source.pluginVersion,
|
||||
baseActionId: action.source.baseActionId,
|
||||
},
|
||||
execution: projectExecutionDescriptor(action.execution),
|
||||
parameterKeysOrValues: parameterProjection,
|
||||
};
|
||||
|
||||
if (options.includeActionNames) {
|
||||
base.name = action.name;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
function projectExecutionDescriptor(
|
||||
exec: ExecutionDescriptor,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
transport: exec.transport,
|
||||
retryable: exec.retryable ?? false,
|
||||
timeoutMs: exec.timeoutMs ?? null,
|
||||
ros2: exec.ros2
|
||||
? {
|
||||
topic: exec.ros2.topic ?? null,
|
||||
service: exec.ros2.service ?? null,
|
||||
action: exec.ros2.action ?? null,
|
||||
}
|
||||
: null,
|
||||
rest: exec.rest
|
||||
? {
|
||||
method: exec.rest.method,
|
||||
path: exec.rest.path,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection of a step for design hash purposes.
|
||||
*/
|
||||
function projectStepForDesign(
|
||||
step: ExperimentStep,
|
||||
options: Required<DesignHashOptions>,
|
||||
): Record<string, unknown> {
|
||||
const base: Record<string, unknown> = {
|
||||
id: step.id,
|
||||
type: step.type,
|
||||
order: step.order,
|
||||
trigger: {
|
||||
type: step.trigger.type,
|
||||
// Only the sorted keys of conditions (structural presence)
|
||||
conditionKeys: Object.keys(step.trigger.conditions).sort(),
|
||||
},
|
||||
actions: step.actions.map((a) => projectActionForDesign(a, options)),
|
||||
};
|
||||
|
||||
if (options.includeStepNames) {
|
||||
base.name = step.name;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Action Signature Hash (Schema / Provenance Drift) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface ActionSignatureInput {
|
||||
type: string;
|
||||
category: string;
|
||||
parameterSchemaRaw?: unknown;
|
||||
execution?: ExecutionDescriptor;
|
||||
baseActionId?: string;
|
||||
pluginVersion?: string;
|
||||
pluginId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash that uniquely identifies the structural/schema definition of an action definition.
|
||||
* Used for plugin drift detection: if signature changes, existing action instances require inspection.
|
||||
*/
|
||||
export async function computeActionSignature(
|
||||
def: ActionSignatureInput,
|
||||
): Promise<string> {
|
||||
const projection = {
|
||||
type: def.type,
|
||||
category: def.category,
|
||||
pluginId: def.pluginId ?? null,
|
||||
pluginVersion: def.pluginVersion ?? null,
|
||||
baseActionId: def.baseActionId ?? null,
|
||||
execution: def.execution
|
||||
? {
|
||||
transport: def.execution.transport,
|
||||
retryable: def.execution.retryable ?? false,
|
||||
timeoutMs: def.execution.timeoutMs ?? null,
|
||||
}
|
||||
: null,
|
||||
schema: def.parameterSchemaRaw ? canonicalize(def.parameterSchemaRaw) : null,
|
||||
};
|
||||
return hashObject(projection);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Design Hash */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Compute a deterministic hash for the entire design (steps + actions) under given options.
|
||||
*/
|
||||
export async function computeDesignHash(
|
||||
steps: ExperimentStep[],
|
||||
opts: DesignHashOptions = {},
|
||||
): Promise<string> {
|
||||
const options = { ...DEFAULT_OPTIONS, ...opts };
|
||||
const projected = steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s) => projectStepForDesign(s, options));
|
||||
return hashObject({ steps: projected });
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Incremental Hashing */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface IncrementalHashMaps {
|
||||
actionHashes: Map<string, string>;
|
||||
stepHashes: Map<string, string>;
|
||||
}
|
||||
|
||||
export interface IncrementalHashResult extends IncrementalHashMaps {
|
||||
designHash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute or reuse action/step hashes to avoid re-hashing unchanged branches.
|
||||
*/
|
||||
export async function computeIncrementalDesignHash(
|
||||
steps: ExperimentStep[],
|
||||
previous?: IncrementalHashMaps,
|
||||
opts: DesignHashOptions = {},
|
||||
): Promise<IncrementalHashResult> {
|
||||
const options = { ...DEFAULT_OPTIONS, ...opts };
|
||||
const actionHashes = new Map<string, string>();
|
||||
const stepHashes = new Map<string, string>();
|
||||
|
||||
// First compute per-action hashes
|
||||
for (const step of steps) {
|
||||
for (const action of step.actions) {
|
||||
const existing = previous?.actionHashes.get(action.id);
|
||||
if (existing) {
|
||||
// Simple heuristic: if shallow structural keys unchanged, reuse
|
||||
// (We still project to confirm minimal structure; deeper diff omitted for performance.)
|
||||
actionHashes.set(action.id, existing);
|
||||
continue;
|
||||
}
|
||||
const projectedAction = projectActionForDesign(action, options);
|
||||
const h = await hashObject(projectedAction);
|
||||
actionHashes.set(action.id, h);
|
||||
}
|
||||
}
|
||||
|
||||
// Then compute step hashes (including ordered list of action hashes)
|
||||
for (const step of steps) {
|
||||
const existing = previous?.stepHashes.get(step.id);
|
||||
if (existing) {
|
||||
stepHashes.set(step.id, existing);
|
||||
continue;
|
||||
}
|
||||
const projectedStep = {
|
||||
id: step.id,
|
||||
type: step.type,
|
||||
order: step.order,
|
||||
trigger: {
|
||||
type: step.trigger.type,
|
||||
conditionKeys: Object.keys(step.trigger.conditions).sort(),
|
||||
},
|
||||
actions: step.actions.map((a) => actionHashes.get(a.id) ?? ""),
|
||||
...(options.includeStepNames ? { name: step.name } : {}),
|
||||
};
|
||||
const h = await hashObject(projectedStep);
|
||||
stepHashes.set(step.id, h);
|
||||
}
|
||||
|
||||
// Aggregate design hash from ordered step hashes + minimal meta
|
||||
const orderedStepHashes = steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s) => stepHashes.get(s.id));
|
||||
|
||||
const designHash = await hashObject({
|
||||
steps: orderedStepHashes,
|
||||
count: steps.length,
|
||||
});
|
||||
|
||||
return { designHash, actionHashes, stepHashes };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utility Helpers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convenience helper to check if design hash matches a known validated hash.
|
||||
*/
|
||||
export function isDesignHashValidated(
|
||||
currentHash: string | undefined | null,
|
||||
validatedHash: string | undefined | null,
|
||||
): boolean {
|
||||
return Boolean(currentHash && validatedHash && currentHash === validatedHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine structural drift given last validated snapshot hash and current.
|
||||
*/
|
||||
export function hasStructuralDrift(
|
||||
currentHash: string | undefined | null,
|
||||
validatedHash: string | undefined | null,
|
||||
): boolean {
|
||||
if (!validatedHash) return false;
|
||||
if (!currentHash) return false;
|
||||
return currentHash !== validatedHash;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Exports */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const Hashing = {
|
||||
canonicalize,
|
||||
hashObject,
|
||||
computeDesignHash,
|
||||
computeIncrementalDesignHash,
|
||||
computeActionSignature,
|
||||
isDesignHashValidated,
|
||||
hasStructuralDrift,
|
||||
};
|
||||
export default Hashing;
|
||||
519
src/components/experiments/designer/state/store.ts
Normal file
519
src/components/experiments/designer/state/store.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
"use client";
|
||||
/**
|
||||
* Experiment Designer Zustand Store
|
||||
*
|
||||
* Centralized state management for the redesigned experiment designer.
|
||||
* Responsibilities:
|
||||
* - Steps & actions structural state
|
||||
* - Selection state (step / action)
|
||||
* - Dirty tracking
|
||||
* - Hashing & drift (incremental design hash computation)
|
||||
* - Validation issue storage
|
||||
* - Plugin action signature drift detection
|
||||
* - Save / conflict / versioning control flags
|
||||
*
|
||||
* This store intentionally avoids direct network calls; consumers orchestrate
|
||||
* server mutations & pass results back into the store (pure state container).
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import type {
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
import {
|
||||
computeIncrementalDesignHash,
|
||||
type IncrementalHashMaps,
|
||||
type IncrementalHashResult,
|
||||
computeActionSignature,
|
||||
} from "./hashing";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface ValidationIssue {
|
||||
entityId: string;
|
||||
severity: "error" | "warning" | "info";
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export type VersionStrategy = "auto" | "forceIncrement" | "none";
|
||||
|
||||
export interface ConflictState {
|
||||
serverHash: string;
|
||||
localHash: string;
|
||||
at: Date;
|
||||
}
|
||||
|
||||
export interface DesignerState {
|
||||
// Core structural
|
||||
steps: ExperimentStep[];
|
||||
|
||||
// Selection
|
||||
selectedStepId?: string;
|
||||
selectedActionId?: string;
|
||||
|
||||
// Dirty tracking (entity IDs)
|
||||
dirtyEntities: Set<string>;
|
||||
|
||||
// Hashing
|
||||
lastPersistedHash?: string;
|
||||
currentDesignHash?: string;
|
||||
lastValidatedHash?: string;
|
||||
incremental?: IncrementalHashMaps;
|
||||
|
||||
// Validation & drift
|
||||
validationIssues: Record<string, ValidationIssue[]>;
|
||||
actionSignatureIndex: Map<string, string>; // actionType or instance -> signature hash
|
||||
actionSignatureDrift: Set<string>; // action instance IDs with drift
|
||||
|
||||
// Saving & conflicts
|
||||
pendingSave: boolean;
|
||||
conflict?: ConflictState;
|
||||
versionStrategy: VersionStrategy;
|
||||
autoSaveEnabled: boolean;
|
||||
|
||||
// Flags
|
||||
busyHashing: boolean;
|
||||
busyValidating: boolean;
|
||||
|
||||
/* ------------------------------ Mutators --------------------------------- */
|
||||
|
||||
// Selection
|
||||
selectStep: (id?: string) => void;
|
||||
selectAction: (stepId: string, actionId?: string) => void;
|
||||
|
||||
// Steps
|
||||
setSteps: (steps: ExperimentStep[]) => void;
|
||||
upsertStep: (step: ExperimentStep) => void;
|
||||
removeStep: (stepId: string) => void;
|
||||
reorderStep: (from: number, to: number) => void;
|
||||
|
||||
// Actions
|
||||
upsertAction: (stepId: string, action: ExperimentAction) => void;
|
||||
removeAction: (stepId: string, actionId: string) => void;
|
||||
reorderAction: (stepId: string, from: number, to: number) => void;
|
||||
|
||||
// Dirty
|
||||
markDirty: (id: string) => void;
|
||||
clearDirty: (id: string) => void;
|
||||
clearAllDirty: () => void;
|
||||
|
||||
// Hashing
|
||||
recomputeHash: (options?: {
|
||||
forceFull?: boolean;
|
||||
}) => Promise<IncrementalHashResult | null>;
|
||||
setPersistedHash: (hash: string) => void;
|
||||
setValidatedHash: (hash: string) => void;
|
||||
|
||||
// Validation
|
||||
setValidationIssues: (entityId: string, issues: ValidationIssue[]) => void;
|
||||
clearValidationIssues: (entityId: string) => void;
|
||||
clearAllValidationIssues: () => void;
|
||||
|
||||
// Drift detection (action definition signature)
|
||||
setActionSignature: (actionId: string, signature: string) => void;
|
||||
detectActionSignatureDrift: (
|
||||
action: ExperimentAction,
|
||||
latestSignature: string,
|
||||
) => void;
|
||||
clearActionSignatureDrift: (actionId: string) => void;
|
||||
|
||||
// Save workflow
|
||||
setPendingSave: (pending: boolean) => void;
|
||||
recordConflict: (serverHash: string, localHash: string) => void;
|
||||
clearConflict: () => void;
|
||||
setVersionStrategy: (strategy: VersionStrategy) => void;
|
||||
setAutoSaveEnabled: (enabled: boolean) => void;
|
||||
|
||||
// Bulk apply from server (authoritative sync after save/fetch)
|
||||
applyServerSync: (payload: {
|
||||
steps: ExperimentStep[];
|
||||
persistedHash?: string;
|
||||
validatedHash?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function cloneSteps(steps: ExperimentStep[]): ExperimentStep[] {
|
||||
return steps.map((s) => ({
|
||||
...s,
|
||||
actions: s.actions.map((a) => ({ ...a })),
|
||||
}));
|
||||
}
|
||||
|
||||
function reindexSteps(steps: ExperimentStep[]): ExperimentStep[] {
|
||||
return steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s, idx) => ({ ...s, order: idx }));
|
||||
}
|
||||
|
||||
function reindexActions(actions: ExperimentAction[]): ExperimentAction[] {
|
||||
// ExperimentAction type does not define orderIndex; preserve array order only
|
||||
return actions.map((a) => ({ ...a }));
|
||||
}
|
||||
|
||||
function updateActionList(
|
||||
existing: ExperimentAction[],
|
||||
action: ExperimentAction,
|
||||
): ExperimentAction[] {
|
||||
const idx = existing.findIndex((a) => a.id === action.id);
|
||||
if (idx >= 0) {
|
||||
const copy = [...existing];
|
||||
copy[idx] = { ...action };
|
||||
return copy;
|
||||
}
|
||||
return [...existing, { ...action }];
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Store Implementation */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const useDesignerStore = create<DesignerState>((set, get) => ({
|
||||
steps: [],
|
||||
dirtyEntities: new Set<string>(),
|
||||
validationIssues: {},
|
||||
actionSignatureIndex: new Map(),
|
||||
actionSignatureDrift: new Set(),
|
||||
pendingSave: false,
|
||||
versionStrategy: "auto_minor" as VersionStrategy,
|
||||
autoSaveEnabled: true,
|
||||
busyHashing: false,
|
||||
busyValidating: false,
|
||||
|
||||
/* ------------------------------ Selection -------------------------------- */
|
||||
selectStep: (id) =>
|
||||
set({
|
||||
selectedStepId: id,
|
||||
selectedActionId: id ? get().selectedActionId : undefined,
|
||||
}),
|
||||
selectAction: (stepId, actionId) =>
|
||||
set({
|
||||
selectedStepId: stepId,
|
||||
selectedActionId: actionId,
|
||||
}),
|
||||
|
||||
/* -------------------------------- Steps ---------------------------------- */
|
||||
setSteps: (steps) =>
|
||||
set(() => ({
|
||||
steps: reindexSteps(cloneSteps(steps)),
|
||||
dirtyEntities: new Set<string>(), // assume authoritative load
|
||||
})),
|
||||
|
||||
upsertStep: (step) =>
|
||||
set((state) => {
|
||||
const idx = state.steps.findIndex((s) => s.id === step.id);
|
||||
let steps: ExperimentStep[];
|
||||
if (idx >= 0) {
|
||||
steps = [...state.steps];
|
||||
steps[idx] = { ...step };
|
||||
} else {
|
||||
steps = [...state.steps, { ...step, order: state.steps.length }];
|
||||
}
|
||||
return {
|
||||
steps: reindexSteps(steps),
|
||||
dirtyEntities: new Set([...state.dirtyEntities, step.id]),
|
||||
};
|
||||
}),
|
||||
|
||||
removeStep: (stepId) =>
|
||||
set((state) => {
|
||||
const steps = state.steps.filter((s) => s.id !== stepId);
|
||||
const dirty = new Set(state.dirtyEntities);
|
||||
dirty.add(stepId);
|
||||
return {
|
||||
steps: reindexSteps(steps),
|
||||
dirtyEntities: dirty,
|
||||
selectedStepId:
|
||||
state.selectedStepId === stepId ? undefined : state.selectedStepId,
|
||||
selectedActionId: undefined,
|
||||
};
|
||||
}),
|
||||
|
||||
reorderStep: (from: number, to: number) =>
|
||||
set((state: DesignerState) => {
|
||||
if (
|
||||
from < 0 ||
|
||||
to < 0 ||
|
||||
from >= state.steps.length ||
|
||||
to >= state.steps.length ||
|
||||
from === to
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
const stepsDraft = [...state.steps];
|
||||
const [moved] = stepsDraft.splice(from, 1);
|
||||
if (!moved) return state;
|
||||
stepsDraft.splice(to, 0, moved);
|
||||
const reindexed = reindexSteps(stepsDraft);
|
||||
return {
|
||||
steps: reindexed,
|
||||
dirtyEntities: new Set<string>([
|
||||
...state.dirtyEntities,
|
||||
...reindexed.map((s) => s.id),
|
||||
]),
|
||||
};
|
||||
}),
|
||||
|
||||
/* ------------------------------- Actions --------------------------------- */
|
||||
upsertAction: (stepId: string, action: ExperimentAction) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
actions: reindexActions(updateActionList(s.actions, action)),
|
||||
}
|
||||
: s,
|
||||
);
|
||||
return {
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: new Set<string>([
|
||||
...state.dirtyEntities,
|
||||
action.id,
|
||||
stepId,
|
||||
]),
|
||||
};
|
||||
}),
|
||||
|
||||
removeAction: (stepId: string, actionId: string) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
actions: reindexActions(
|
||||
s.actions.filter((a) => a.id !== actionId),
|
||||
),
|
||||
}
|
||||
: s,
|
||||
);
|
||||
const dirty = new Set<string>(state.dirtyEntities);
|
||||
dirty.add(actionId);
|
||||
dirty.add(stepId);
|
||||
return {
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: dirty,
|
||||
selectedActionId:
|
||||
state.selectedActionId === actionId
|
||||
? undefined
|
||||
: state.selectedActionId,
|
||||
};
|
||||
}),
|
||||
|
||||
reorderAction: (stepId: string, from: number, to: number) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
|
||||
if (s.id !== stepId) return s;
|
||||
if (
|
||||
from < 0 ||
|
||||
to < 0 ||
|
||||
from >= s.actions.length ||
|
||||
to >= s.actions.length ||
|
||||
from === to
|
||||
) {
|
||||
return s;
|
||||
}
|
||||
const actionsDraft = [...s.actions];
|
||||
const [moved] = actionsDraft.splice(from, 1);
|
||||
if (!moved) return s;
|
||||
actionsDraft.splice(to, 0, moved);
|
||||
return { ...s, actions: reindexActions(actionsDraft) };
|
||||
});
|
||||
return {
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: new Set<string>([...state.dirtyEntities, stepId]),
|
||||
};
|
||||
}),
|
||||
|
||||
/* -------------------------------- Dirty ---------------------------------- */
|
||||
markDirty: (id: string) =>
|
||||
set((state: DesignerState) => ({
|
||||
dirtyEntities: state.dirtyEntities.has(id)
|
||||
? state.dirtyEntities
|
||||
: new Set<string>([...state.dirtyEntities, id]),
|
||||
})),
|
||||
clearDirty: (id: string) =>
|
||||
set((state: DesignerState) => {
|
||||
if (!state.dirtyEntities.has(id)) return state;
|
||||
const next = new Set(state.dirtyEntities);
|
||||
next.delete(id);
|
||||
return { dirtyEntities: next };
|
||||
}),
|
||||
clearAllDirty: () => set({ dirtyEntities: new Set<string>() }),
|
||||
|
||||
/* ------------------------------- Hashing --------------------------------- */
|
||||
recomputeHash: async (options?: { forceFull?: boolean }) => {
|
||||
const { steps, incremental } = get();
|
||||
if (steps.length === 0) {
|
||||
set({ currentDesignHash: undefined });
|
||||
return null;
|
||||
}
|
||||
set({ busyHashing: true });
|
||||
try {
|
||||
const result = await computeIncrementalDesignHash(
|
||||
steps,
|
||||
options?.forceFull ? undefined : incremental,
|
||||
);
|
||||
set({
|
||||
currentDesignHash: result.designHash,
|
||||
incremental: {
|
||||
actionHashes: result.actionHashes,
|
||||
stepHashes: result.stepHashes,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
set({ busyHashing: false });
|
||||
}
|
||||
},
|
||||
|
||||
setPersistedHash: (hash: string) => set({ lastPersistedHash: hash }),
|
||||
setValidatedHash: (hash: string) => set({ lastValidatedHash: hash }),
|
||||
|
||||
/* ----------------------------- Validation -------------------------------- */
|
||||
setValidationIssues: (entityId: string, issues: ValidationIssue[]) =>
|
||||
set((state: DesignerState) => ({
|
||||
validationIssues: {
|
||||
...state.validationIssues,
|
||||
[entityId]: issues,
|
||||
},
|
||||
})),
|
||||
clearValidationIssues: (entityId: string) =>
|
||||
set((state: DesignerState) => {
|
||||
if (!state.validationIssues[entityId]) return state;
|
||||
const next = { ...state.validationIssues };
|
||||
delete next[entityId];
|
||||
return { validationIssues: next };
|
||||
}),
|
||||
clearAllValidationIssues: () => set({ validationIssues: {} }),
|
||||
|
||||
/* ------------------------- Action Signature Drift ------------------------ */
|
||||
setActionSignature: (actionId: string, signature: string) =>
|
||||
set((state: DesignerState) => {
|
||||
const index = new Map(state.actionSignatureIndex);
|
||||
index.set(actionId, signature);
|
||||
return { actionSignatureIndex: index };
|
||||
}),
|
||||
detectActionSignatureDrift: (
|
||||
action: ExperimentAction,
|
||||
latestSignature: string,
|
||||
) =>
|
||||
set((state: DesignerState) => {
|
||||
const current = state.actionSignatureIndex.get(action.id);
|
||||
if (!current) {
|
||||
const idx = new Map(state.actionSignatureIndex);
|
||||
idx.set(action.id, latestSignature);
|
||||
return { actionSignatureIndex: idx };
|
||||
}
|
||||
if (current === latestSignature) return {};
|
||||
const drift = new Set(state.actionSignatureDrift);
|
||||
drift.add(action.id);
|
||||
return { actionSignatureDrift: drift };
|
||||
}),
|
||||
clearActionSignatureDrift: (actionId: string) =>
|
||||
set((state: DesignerState) => {
|
||||
if (!state.actionSignatureDrift.has(actionId)) return state;
|
||||
const next = new Set(state.actionSignatureDrift);
|
||||
next.delete(actionId);
|
||||
return { actionSignatureDrift: next };
|
||||
}),
|
||||
|
||||
/* ------------------------------- Save Flow -------------------------------- */
|
||||
setPendingSave: (pending: boolean) => set({ pendingSave: pending }),
|
||||
recordConflict: (serverHash: string, localHash: string) =>
|
||||
set({
|
||||
conflict: { serverHash, localHash, at: new Date() },
|
||||
pendingSave: false,
|
||||
}),
|
||||
clearConflict: () => set({ conflict: undefined }),
|
||||
setVersionStrategy: (strategy: VersionStrategy) =>
|
||||
set({ versionStrategy: strategy }),
|
||||
setAutoSaveEnabled: (enabled: boolean) => set({ autoSaveEnabled: enabled }),
|
||||
|
||||
/* ------------------------------ Server Sync ------------------------------ */
|
||||
applyServerSync: (payload: {
|
||||
steps: ExperimentStep[];
|
||||
persistedHash?: string;
|
||||
validatedHash?: string;
|
||||
}) =>
|
||||
set((state: DesignerState) => {
|
||||
const syncedSteps = reindexSteps(cloneSteps(payload.steps));
|
||||
const dirty = new Set<string>();
|
||||
return {
|
||||
steps: syncedSteps,
|
||||
lastPersistedHash: payload.persistedHash ?? state.lastPersistedHash,
|
||||
lastValidatedHash: payload.validatedHash ?? state.lastValidatedHash,
|
||||
dirtyEntities: dirty,
|
||||
conflict: undefined,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Convenience Selectors */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const useDesignerSteps = (): ExperimentStep[] =>
|
||||
useDesignerStore((s) => s.steps);
|
||||
|
||||
export const useDesignerSelection = (): {
|
||||
selectedStepId: string | undefined;
|
||||
selectedActionId: string | undefined;
|
||||
} =>
|
||||
useDesignerStore((s) => ({
|
||||
selectedStepId: s.selectedStepId,
|
||||
selectedActionId: s.selectedActionId,
|
||||
}));
|
||||
|
||||
export const useDesignerHashes = (): {
|
||||
currentDesignHash: string | undefined;
|
||||
lastPersistedHash: string | undefined;
|
||||
lastValidatedHash: string | undefined;
|
||||
} =>
|
||||
useDesignerStore((s) => ({
|
||||
currentDesignHash: s.currentDesignHash,
|
||||
lastPersistedHash: s.lastPersistedHash,
|
||||
lastValidatedHash: s.lastValidatedHash,
|
||||
}));
|
||||
|
||||
export const useDesignerDrift = (): {
|
||||
hasDrift: boolean;
|
||||
actionSignatureDrift: Set<string>;
|
||||
} =>
|
||||
useDesignerStore((s) => ({
|
||||
hasDrift:
|
||||
!!s.lastValidatedHash &&
|
||||
!!s.currentDesignHash &&
|
||||
s.currentDesignHash !== s.lastValidatedHash,
|
||||
actionSignatureDrift: s.actionSignatureDrift,
|
||||
}));
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Signature Helper (on-demand) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Compute a signature for an action definition or instance (schema + provenance).
|
||||
* Store modules can call this to register baseline signatures.
|
||||
*/
|
||||
export async function computeBaselineActionSignature(
|
||||
action: ExperimentAction,
|
||||
): Promise<string> {
|
||||
return computeActionSignature({
|
||||
type: action.type,
|
||||
category: action.category,
|
||||
parameterSchemaRaw: action.parameterSchemaRaw,
|
||||
execution: action.execution,
|
||||
baseActionId: action.source.baseActionId,
|
||||
pluginVersion: action.source.pluginVersion,
|
||||
pluginId: action.source.pluginId,
|
||||
});
|
||||
}
|
||||
762
src/components/experiments/designer/state/validators.ts
Normal file
762
src/components/experiments/designer/state/validators.ts
Normal file
@@ -0,0 +1,762 @@
|
||||
/**
|
||||
* Validation utilities for the Experiment Designer.
|
||||
*
|
||||
* Implements comprehensive validation rules per the redesign spec:
|
||||
* - Structural validation (step names, types, trigger configurations)
|
||||
* - Parameter validation (required fields, type checking, bounds)
|
||||
* - Semantic validation (uniqueness, dependencies, best practices)
|
||||
* - Cross-step validation (workflow integrity, execution feasibility)
|
||||
*
|
||||
* Each validator returns an array of ValidationIssue objects with severity levels.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
ActionDefinition,
|
||||
TriggerType,
|
||||
StepType,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface ValidationIssue {
|
||||
severity: "error" | "warning" | "info";
|
||||
message: string;
|
||||
category: "structural" | "parameter" | "semantic" | "execution";
|
||||
field?: string;
|
||||
suggestion?: string;
|
||||
actionId?: string;
|
||||
stepId?: string;
|
||||
}
|
||||
|
||||
export interface ValidationContext {
|
||||
steps: ExperimentStep[];
|
||||
actionDefinitions: ActionDefinition[];
|
||||
allowPartialValidation?: boolean;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
issues: ValidationIssue[];
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
infoCount: number;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Validation Rule Sets */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const VALID_STEP_TYPES: StepType[] = [
|
||||
"sequential",
|
||||
"parallel",
|
||||
"conditional",
|
||||
"loop",
|
||||
];
|
||||
const VALID_TRIGGER_TYPES: TriggerType[] = [
|
||||
"trial_start",
|
||||
"participant_action",
|
||||
"timer",
|
||||
"previous_step",
|
||||
];
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Structural Validation */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function validateStructural(
|
||||
steps: ExperimentStep[],
|
||||
context: ValidationContext,
|
||||
): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
// Global structural checks
|
||||
if (steps.length === 0) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Experiment must contain at least one step",
|
||||
category: "structural",
|
||||
suggestion: "Add a step to begin designing your experiment",
|
||||
});
|
||||
return issues; // Early return for empty experiment
|
||||
}
|
||||
|
||||
// Step-level validation
|
||||
steps.forEach((step, stepIndex) => {
|
||||
const stepId = step.id;
|
||||
|
||||
// Step name validation
|
||||
if (!step.name?.trim()) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Step name cannot be empty",
|
||||
category: "structural",
|
||||
field: "name",
|
||||
stepId,
|
||||
suggestion: "Provide a descriptive name for this step",
|
||||
});
|
||||
} else if (step.name.length > 100) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message: "Step name is very long and may be truncated in displays",
|
||||
category: "structural",
|
||||
field: "name",
|
||||
stepId,
|
||||
suggestion: "Consider shortening the step name",
|
||||
});
|
||||
}
|
||||
|
||||
// Step type validation
|
||||
if (!VALID_STEP_TYPES.includes(step.type)) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Invalid step type: ${step.type}`,
|
||||
category: "structural",
|
||||
field: "type",
|
||||
stepId,
|
||||
suggestion: `Valid types are: ${VALID_STEP_TYPES.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Step order validation
|
||||
if (step.order !== stepIndex) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Step order mismatch: expected ${stepIndex}, got ${step.order}`,
|
||||
category: "structural",
|
||||
field: "order",
|
||||
stepId,
|
||||
suggestion: "Step order must be sequential starting from 0",
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger validation
|
||||
if (!VALID_TRIGGER_TYPES.includes(step.trigger.type)) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Invalid trigger type: ${step.trigger.type}`,
|
||||
category: "structural",
|
||||
field: "trigger.type",
|
||||
stepId,
|
||||
suggestion: `Valid trigger types are: ${VALID_TRIGGER_TYPES.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Conditional step must have conditions
|
||||
if (step.type === "conditional") {
|
||||
const conditionKeys = Object.keys(step.trigger.conditions || {});
|
||||
if (conditionKeys.length === 0) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Conditional step must define at least one condition",
|
||||
category: "structural",
|
||||
field: "trigger.conditions",
|
||||
stepId,
|
||||
suggestion: "Add conditions to define when this step should execute",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Loop step should have termination conditions
|
||||
if (step.type === "loop") {
|
||||
const conditionKeys = Object.keys(step.trigger.conditions || {});
|
||||
if (conditionKeys.length === 0) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message:
|
||||
"Loop step should define termination conditions to prevent infinite loops",
|
||||
category: "structural",
|
||||
field: "trigger.conditions",
|
||||
stepId,
|
||||
suggestion: "Add conditions to control when the loop should exit",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parallel step should have multiple actions
|
||||
if (step.type === "parallel" && step.actions.length < 2) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message:
|
||||
"Parallel step has fewer than 2 actions - consider using sequential type",
|
||||
category: "structural",
|
||||
stepId,
|
||||
suggestion: "Add more actions or change to sequential execution",
|
||||
});
|
||||
}
|
||||
|
||||
// Action-level structural validation
|
||||
step.actions.forEach((action, actionIndex) => {
|
||||
const actionId = action.id;
|
||||
|
||||
// Action name validation
|
||||
if (!action.name?.trim()) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Action name cannot be empty",
|
||||
category: "structural",
|
||||
field: "name",
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Provide a descriptive name for this action",
|
||||
});
|
||||
}
|
||||
|
||||
// Action type validation
|
||||
if (!action.type?.trim()) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Action type cannot be empty",
|
||||
category: "structural",
|
||||
field: "type",
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Select a valid action type from the library",
|
||||
});
|
||||
}
|
||||
|
||||
// Note: Action order validation removed as orderIndex is not in the type definition
|
||||
// Actions are ordered by their position in the array
|
||||
|
||||
// Source validation
|
||||
if (!action.source?.kind) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Action source kind is required",
|
||||
category: "structural",
|
||||
field: "source.kind",
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Action must specify if it's from core or plugin source",
|
||||
});
|
||||
}
|
||||
|
||||
// Plugin actions need plugin metadata
|
||||
if (action.source?.kind === "plugin") {
|
||||
if (!action.source.pluginId) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Plugin action must specify pluginId",
|
||||
category: "structural",
|
||||
field: "source.pluginId",
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Plugin actions require valid plugin identification",
|
||||
});
|
||||
}
|
||||
if (!action.source.pluginVersion) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message: "Plugin action should specify version for reproducibility",
|
||||
category: "structural",
|
||||
field: "source.pluginVersion",
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Pin plugin version to ensure consistent behavior",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Execution descriptor validation
|
||||
if (!action.execution?.transport) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Action must specify execution transport",
|
||||
category: "structural",
|
||||
field: "execution.transport",
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion:
|
||||
"Define how this action should be executed (rest, ros2, etc.)",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Parameter Validation */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function validateParameters(
|
||||
steps: ExperimentStep[],
|
||||
context: ValidationContext,
|
||||
): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
const { actionDefinitions } = context;
|
||||
|
||||
steps.forEach((step) => {
|
||||
step.actions.forEach((action) => {
|
||||
const stepId = step.id;
|
||||
const actionId = action.id;
|
||||
|
||||
// Find action definition
|
||||
const definition = actionDefinitions.find(
|
||||
(def) => def.type === action.type,
|
||||
);
|
||||
|
||||
if (!definition) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Action definition not found for type: ${action.type}`,
|
||||
category: "parameter",
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Check if the required plugin is installed and loaded",
|
||||
});
|
||||
return; // Skip parameter validation for missing definitions
|
||||
}
|
||||
|
||||
// Validate each parameter
|
||||
definition.parameters.forEach((paramDef) => {
|
||||
const paramId = paramDef.id;
|
||||
const value = action.parameters[paramId];
|
||||
const field = `parameters.${paramId}`;
|
||||
|
||||
// Required parameter check
|
||||
if (paramDef.required) {
|
||||
const isEmpty =
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === "string" && value.trim() === "");
|
||||
|
||||
if (isEmpty) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Required parameter '${paramDef.name}' is missing`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Provide a value for this required parameter",
|
||||
});
|
||||
return; // Skip type validation for missing required params
|
||||
}
|
||||
}
|
||||
|
||||
// Skip validation for optional empty parameters
|
||||
if (value === undefined || value === null) return;
|
||||
|
||||
// Type validation
|
||||
switch (paramDef.type) {
|
||||
case "text":
|
||||
if (typeof value !== "string") {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Parameter '${paramDef.name}' must be text`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Enter a text value",
|
||||
});
|
||||
// Note: maxLength validation removed as it's not in the ActionParameter type
|
||||
}
|
||||
break;
|
||||
|
||||
case "number":
|
||||
if (typeof value !== "number" || isNaN(value)) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Parameter '${paramDef.name}' must be a valid number`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Enter a numeric value",
|
||||
});
|
||||
} else {
|
||||
// Range validation
|
||||
if (paramDef.min !== undefined && value < paramDef.min) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Parameter '${paramDef.name}' must be at least ${paramDef.min}`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: `Enter a value >= ${paramDef.min}`,
|
||||
});
|
||||
}
|
||||
if (paramDef.max !== undefined && value > paramDef.max) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Parameter '${paramDef.name}' must be at most ${paramDef.max}`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: `Enter a value <= ${paramDef.max}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "boolean":
|
||||
if (typeof value !== "boolean") {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Parameter '${paramDef.name}' must be true or false`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Use the toggle switch to set this value",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "select":
|
||||
if (
|
||||
paramDef.options &&
|
||||
!paramDef.options.includes(value as string)
|
||||
) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Parameter '${paramDef.name}' has invalid value`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: `Choose from: ${paramDef.options.join(", ")}`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown parameter type
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message: `Unknown parameter type '${paramDef.type}' for '${paramDef.name}'`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Check action definition for correct parameter types",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check for unexpected parameters
|
||||
Object.keys(action.parameters).forEach((paramId) => {
|
||||
const isDefinedParam = definition.parameters.some(
|
||||
(def) => def.id === paramId,
|
||||
);
|
||||
if (!isDefinedParam) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message: `Unexpected parameter '${paramId}' - not defined in action schema`,
|
||||
category: "parameter",
|
||||
field: `parameters.${paramId}`,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion:
|
||||
"Remove this parameter or check if action definition is outdated",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Semantic Validation */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function validateSemantic(
|
||||
steps: ExperimentStep[],
|
||||
context: ValidationContext,
|
||||
): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
// Check for duplicate step IDs
|
||||
const stepIds = new Set<string>();
|
||||
const duplicateStepIds = new Set<string>();
|
||||
|
||||
steps.forEach((step) => {
|
||||
if (stepIds.has(step.id)) {
|
||||
duplicateStepIds.add(step.id);
|
||||
}
|
||||
stepIds.add(step.id);
|
||||
});
|
||||
|
||||
duplicateStepIds.forEach((stepId) => {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Duplicate step ID: ${stepId}`,
|
||||
category: "semantic",
|
||||
stepId,
|
||||
suggestion: "Step IDs must be unique throughout the experiment",
|
||||
});
|
||||
});
|
||||
|
||||
// Check for duplicate action IDs globally
|
||||
const actionIds = new Set<string>();
|
||||
const duplicateActionIds = new Set<string>();
|
||||
|
||||
steps.forEach((step) => {
|
||||
step.actions.forEach((action) => {
|
||||
if (actionIds.has(action.id)) {
|
||||
duplicateActionIds.add(action.id);
|
||||
}
|
||||
actionIds.add(action.id);
|
||||
});
|
||||
});
|
||||
|
||||
duplicateActionIds.forEach((actionId) => {
|
||||
const containingSteps = steps.filter((s) =>
|
||||
s.actions.some((a) => a.id === actionId),
|
||||
);
|
||||
|
||||
containingSteps.forEach((step) => {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Duplicate action ID: ${actionId}`,
|
||||
category: "semantic",
|
||||
stepId: step.id,
|
||||
actionId,
|
||||
suggestion: "Action IDs must be unique throughout the experiment",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Check for empty steps
|
||||
steps.forEach((step) => {
|
||||
if (step.actions.length === 0) {
|
||||
const severity = step.type === "parallel" ? "error" : "warning";
|
||||
issues.push({
|
||||
severity,
|
||||
message: `${step.type} step has no actions`,
|
||||
category: "semantic",
|
||||
stepId: step.id,
|
||||
suggestion: "Add actions to this step or remove it",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Documentation suggestions
|
||||
steps.forEach((step) => {
|
||||
// Missing step descriptions
|
||||
if (!step.description?.trim()) {
|
||||
issues.push({
|
||||
severity: "info",
|
||||
message: "Consider adding a description to document step purpose",
|
||||
category: "semantic",
|
||||
field: "description",
|
||||
stepId: step.id,
|
||||
suggestion:
|
||||
"Descriptions improve experiment documentation and reproducibility",
|
||||
});
|
||||
}
|
||||
|
||||
// Actions without meaningful names
|
||||
step.actions.forEach((action) => {
|
||||
if (
|
||||
action.name === action.type ||
|
||||
action.name.toLowerCase().includes("untitled")
|
||||
) {
|
||||
issues.push({
|
||||
severity: "info",
|
||||
message: "Consider providing a more descriptive action name",
|
||||
category: "semantic",
|
||||
field: "name",
|
||||
stepId: step.id,
|
||||
actionId: action.id,
|
||||
suggestion:
|
||||
"Descriptive names help with experiment understanding and debugging",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Workflow logic suggestions
|
||||
steps.forEach((step, index) => {
|
||||
// First step should typically use trial_start trigger
|
||||
if (index === 0 && step.trigger.type !== "trial_start") {
|
||||
issues.push({
|
||||
severity: "info",
|
||||
message: "First step typically uses trial_start trigger",
|
||||
category: "semantic",
|
||||
field: "trigger.type",
|
||||
stepId: step.id,
|
||||
suggestion: "Consider using trial_start trigger for the initial step",
|
||||
});
|
||||
}
|
||||
|
||||
// Timer triggers without reasonable durations
|
||||
if (step.trigger.type === "timer") {
|
||||
const duration = step.trigger.conditions?.duration;
|
||||
if (typeof duration === "number") {
|
||||
if (duration < 100) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message: "Very short timer duration may cause timing issues",
|
||||
category: "semantic",
|
||||
field: "trigger.conditions.duration",
|
||||
stepId: step.id,
|
||||
suggestion: "Consider using at least 100ms for reliable timing",
|
||||
});
|
||||
}
|
||||
if (duration > 300000) {
|
||||
// 5 minutes
|
||||
issues.push({
|
||||
severity: "info",
|
||||
message: "Long timer duration - ensure this is intentional",
|
||||
category: "semantic",
|
||||
field: "trigger.conditions.duration",
|
||||
stepId: step.id,
|
||||
suggestion:
|
||||
"Verify the timer duration is correct for your use case",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Cross-Step Execution Validation */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function validateExecution(
|
||||
steps: ExperimentStep[],
|
||||
context: ValidationContext,
|
||||
): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
// Check for unreachable steps (basic heuristic)
|
||||
if (steps.length > 1) {
|
||||
const trialStartSteps = steps.filter(
|
||||
(s) => s.trigger.type === "trial_start",
|
||||
);
|
||||
if (trialStartSteps.length > 1) {
|
||||
trialStartSteps.slice(1).forEach((step) => {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message:
|
||||
"Multiple steps with trial_start trigger may cause execution conflicts",
|
||||
category: "execution",
|
||||
field: "trigger.type",
|
||||
stepId: step.id,
|
||||
suggestion: "Consider using sequential triggers for subsequent steps",
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing robot dependencies
|
||||
const robotActions = steps.flatMap((step) =>
|
||||
step.actions.filter(
|
||||
(action) =>
|
||||
action.execution.transport === "ros2" ||
|
||||
action.execution.transport === "rest",
|
||||
),
|
||||
);
|
||||
|
||||
if (robotActions.length > 0) {
|
||||
// This would need robot registry integration in full implementation
|
||||
issues.push({
|
||||
severity: "info",
|
||||
message:
|
||||
"Experiment contains robot actions - ensure robot connections are configured",
|
||||
category: "execution",
|
||||
suggestion:
|
||||
"Verify robot plugins are installed and robots are accessible",
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Main Validation Function */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function validateExperimentDesign(
|
||||
steps: ExperimentStep[],
|
||||
context: ValidationContext,
|
||||
): ValidationResult {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
// Run all validation rule sets
|
||||
issues.push(...validateStructural(steps, context));
|
||||
issues.push(...validateParameters(steps, context));
|
||||
issues.push(...validateSemantic(steps, context));
|
||||
issues.push(...validateExecution(steps, context));
|
||||
|
||||
// Count issues by severity
|
||||
const errorCount = issues.filter((i) => i.severity === "error").length;
|
||||
const warningCount = issues.filter((i) => i.severity === "warning").length;
|
||||
const infoCount = issues.filter((i) => i.severity === "info").length;
|
||||
|
||||
// Experiment is valid if no errors (warnings and info are allowed)
|
||||
const valid = errorCount === 0;
|
||||
|
||||
return {
|
||||
valid,
|
||||
issues,
|
||||
errorCount,
|
||||
warningCount,
|
||||
infoCount,
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Issue Grouping Utilities */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function groupIssuesByEntity(
|
||||
issues: ValidationIssue[],
|
||||
): Record<string, ValidationIssue[]> {
|
||||
const grouped: Record<string, ValidationIssue[]> = {};
|
||||
|
||||
issues.forEach((issue) => {
|
||||
const entityId = issue.actionId || issue.stepId || "experiment";
|
||||
if (!grouped[entityId]) {
|
||||
grouped[entityId] = [];
|
||||
}
|
||||
grouped[entityId].push(issue);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function getIssuesByStep(
|
||||
issues: ValidationIssue[],
|
||||
stepId: string,
|
||||
): ValidationIssue[] {
|
||||
return issues.filter((issue) => issue.stepId === stepId);
|
||||
}
|
||||
|
||||
export function getIssuesByAction(
|
||||
issues: ValidationIssue[],
|
||||
actionId: string,
|
||||
): ValidationIssue[] {
|
||||
return issues.filter((issue) => issue.actionId === actionId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Exports */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const Validators = {
|
||||
validateStructural,
|
||||
validateParameters,
|
||||
validateSemantic,
|
||||
validateExecution,
|
||||
validateExperimentDesign,
|
||||
groupIssuesByEntity,
|
||||
getIssuesByStep,
|
||||
getIssuesByAction,
|
||||
};
|
||||
|
||||
export default Validators;
|
||||
Reference in New Issue
Block a user