/** * Spec Validator - Validate AtomizerSpec v2.0 before running optimization * * This validator checks the spec for completeness and correctness, * returning structured errors that can be displayed in the ValidationPanel. */ import { AtomizerSpec } from '../../types/atomizer-spec'; import { ValidationError, ValidationData } from '../../hooks/usePanelStore'; // ============================================================================ // Validation Rules // ============================================================================ interface ValidationRule { code: string; check: (spec: AtomizerSpec) => ValidationError | null; } const validationRules: ValidationRule[] = [ // ---- Critical Errors (must fix) ---- { code: 'NO_DESIGN_VARS', check: (spec) => { const enabledDVs = spec.design_variables.filter(dv => dv.enabled !== false); if (enabledDVs.length === 0) { return { code: 'NO_DESIGN_VARS', severity: 'error', path: 'design_variables', message: 'No design variables defined', suggestion: 'Add at least one design variable from the introspection panel or drag from the palette.', }; } return null; }, }, { code: 'NO_OBJECTIVES', check: (spec) => { if (spec.objectives.length === 0) { return { code: 'NO_OBJECTIVES', severity: 'error', path: 'objectives', message: 'No objectives defined', suggestion: 'Add at least one objective to define what to optimize (minimize mass, maximize stiffness, etc.).', }; } return null; }, }, { code: 'NO_EXTRACTORS', check: (spec) => { if (spec.extractors.length === 0) { return { code: 'NO_EXTRACTORS', severity: 'error', path: 'extractors', message: 'No extractors defined', suggestion: 'Add extractors to pull physics values (displacement, stress, frequency) from FEA results.', }; } return null; }, }, { code: 'NO_MODEL', check: (spec) => { if (!spec.model.sim?.path) { return { code: 'NO_MODEL', severity: 'error', path: 'model.sim.path', message: 'No simulation file configured', suggestion: 'Select a .sim file in the study\'s model directory.', }; } return null; }, }, // ---- Design Variable Validation ---- { code: 'DV_INVALID_BOUNDS', check: (spec) => { for (const dv of spec.design_variables) { if (dv.enabled === false) continue; if (dv.bounds.min >= dv.bounds.max) { return { code: 'DV_INVALID_BOUNDS', severity: 'error', path: `design_variables.${dv.id}`, message: `Design variable "${dv.name}" has invalid bounds (min >= max)`, suggestion: `Set min (${dv.bounds.min}) to be less than max (${dv.bounds.max}).`, nodeId: dv.id, }; } } return null; }, }, { code: 'DV_NO_EXPRESSION', check: (spec) => { for (const dv of spec.design_variables) { if (dv.enabled === false) continue; if (!dv.expression_name || dv.expression_name.trim() === '') { return { code: 'DV_NO_EXPRESSION', severity: 'error', path: `design_variables.${dv.id}`, message: `Design variable "${dv.name}" has no NX expression name`, suggestion: 'Set the expression_name to match an NX expression in the model.', nodeId: dv.id, }; } } return null; }, }, // ---- Extractor Validation ---- { code: 'EXTRACTOR_NO_TYPE', check: (spec) => { for (const ext of spec.extractors) { if (!ext.type || ext.type.trim() === '') { return { code: 'EXTRACTOR_NO_TYPE', severity: 'error', path: `extractors.${ext.id}`, message: `Extractor "${ext.name}" has no type selected`, suggestion: 'Select an extractor type (displacement, stress, frequency, etc.).', nodeId: ext.id, }; } } return null; }, }, { code: 'CUSTOM_EXTRACTOR_NO_CODE', check: (spec) => { for (const ext of spec.extractors) { if (ext.type === 'custom_function' && (!ext.function?.source_code || ext.function.source_code.trim() === '')) { return { code: 'CUSTOM_EXTRACTOR_NO_CODE', severity: 'error', path: `extractors.${ext.id}`, message: `Custom extractor "${ext.name}" has no code defined`, suggestion: 'Open the code editor and write the extraction function.', nodeId: ext.id, }; } } return null; }, }, // ---- Objective Validation ---- { code: 'OBJECTIVE_NO_SOURCE', check: (spec) => { for (const obj of spec.objectives) { // Check if objective is connected to an extractor via canvas edges const hasSource = spec.canvas?.edges?.some( edge => edge.target === obj.id && edge.source.startsWith('ext_') ); // Also check if source.extractor_id is set const hasDirectSource = obj.source?.extractor_id && spec.extractors.some(e => e.id === obj.source.extractor_id); if (!hasSource && !hasDirectSource) { return { code: 'OBJECTIVE_NO_SOURCE', severity: 'error', path: `objectives.${obj.id}`, message: `Objective "${obj.name}" has no connected extractor`, suggestion: 'Connect an extractor to this objective or set source_extractor_id.', nodeId: obj.id, }; } } return null; }, }, // ---- Constraint Validation ---- { code: 'CONSTRAINT_NO_THRESHOLD', check: (spec) => { for (const con of spec.constraints || []) { if (con.threshold === undefined || con.threshold === null) { return { code: 'CONSTRAINT_NO_THRESHOLD', severity: 'error', path: `constraints.${con.id}`, message: `Constraint "${con.name}" has no threshold value`, suggestion: 'Set a threshold value for the constraint.', nodeId: con.id, }; } } return null; }, }, // ---- Warnings (can proceed but risky) ---- { code: 'HIGH_TRIAL_COUNT', check: (spec) => { const maxTrials = spec.optimization.budget?.max_trials || 100; if (maxTrials > 500) { return { code: 'HIGH_TRIAL_COUNT', severity: 'warning', path: 'optimization.budget.max_trials', message: `High trial count (${maxTrials}) may take several hours to complete`, suggestion: 'Consider starting with fewer trials (50-100) to validate the setup.', }; } return null; }, }, { code: 'SINGLE_TRIAL', check: (spec) => { const maxTrials = spec.optimization.budget?.max_trials || 100; if (maxTrials === 1) { return { code: 'SINGLE_TRIAL', severity: 'warning', path: 'optimization.budget.max_trials', message: 'Only 1 trial configured - this will just run a single evaluation', suggestion: 'Increase max_trials to explore the design space.', }; } return null; }, }, { code: 'DV_NARROW_BOUNDS', check: (spec) => { for (const dv of spec.design_variables) { if (dv.enabled === false) continue; const range = dv.bounds.max - dv.bounds.min; const baseline = dv.baseline || (dv.bounds.min + dv.bounds.max) / 2; const relativeRange = range / Math.abs(baseline || 1); if (relativeRange < 0.01) { // Less than 1% variation return { code: 'DV_NARROW_BOUNDS', severity: 'warning', path: `design_variables.${dv.id}`, message: `Design variable "${dv.name}" has very narrow bounds (<1% range)`, suggestion: 'Consider widening the bounds for more meaningful exploration.', nodeId: dv.id, }; } } return null; }, }, { code: 'MANY_DESIGN_VARS', check: (spec) => { const enabledDVs = spec.design_variables.filter(dv => dv.enabled !== false); if (enabledDVs.length > 10) { return { code: 'MANY_DESIGN_VARS', severity: 'warning', path: 'design_variables', message: `${enabledDVs.length} design variables - high-dimensional space may need more trials`, suggestion: 'Consider enabling neural surrogate acceleration or increasing trial budget.', }; } return null; }, }, { code: 'MULTI_OBJECTIVE_NO_WEIGHTS', check: (spec) => { if (spec.objectives.length > 1) { const hasWeights = spec.objectives.every(obj => obj.weight !== undefined && obj.weight !== null); if (!hasWeights) { return { code: 'MULTI_OBJECTIVE_NO_WEIGHTS', severity: 'warning', path: 'objectives', message: 'Multi-objective optimization without explicit weights', suggestion: 'Consider setting weights to control the trade-off between objectives.', }; } } return null; }, }, ]; // ============================================================================ // Main Validation Function // ============================================================================ export function validateSpec(spec: AtomizerSpec): ValidationData { const errors: ValidationError[] = []; const warnings: ValidationError[] = []; for (const rule of validationRules) { const result = rule.check(spec); if (result) { if (result.severity === 'error') { errors.push(result); } else { warnings.push(result); } } } return { valid: errors.length === 0, errors, warnings, checkedAt: Date.now(), }; } // ============================================================================ // Quick Validation (just checks if can run) // ============================================================================ export function canRunOptimization(spec: AtomizerSpec): { canRun: boolean; reason?: string } { // Check critical requirements only if (!spec.model.sim?.path) { return { canRun: false, reason: 'No simulation file configured' }; } const enabledDVs = spec.design_variables.filter(dv => dv.enabled !== false); if (enabledDVs.length === 0) { return { canRun: false, reason: 'No design variables defined' }; } if (spec.objectives.length === 0) { return { canRun: false, reason: 'No objectives defined' }; } if (spec.extractors.length === 0) { return { canRun: false, reason: 'No extractors defined' }; } // Check for invalid bounds for (const dv of enabledDVs) { if (dv.bounds.min >= dv.bounds.max) { return { canRun: false, reason: `Invalid bounds for "${dv.name}"` }; } } return { canRun: true }; } // ============================================================================ // Export validation result type for backward compatibility // ============================================================================ export interface LegacyValidationResult { valid: boolean; errors: string[]; warnings: string[]; } export function toLegacyValidationResult(data: ValidationData): LegacyValidationResult { return { valid: data.valid, errors: data.errors.map(e => e.message), warnings: data.warnings.map(w => w.message), }; }