diff --git a/align_browser/experiment_parser.py b/align_browser/experiment_parser.py index 2d6ce32..eaa9acd 100644 --- a/align_browser/experiment_parser.py +++ b/align_browser/experiment_parser.py @@ -161,12 +161,13 @@ def parse_experiments_directory(experiments_root: Path) -> List[ExperimentData]: """ Parse the experiments directory structure and return a list of ExperimentData. - Recursively searches through the directory structure to find all directories + First checks if the given path itself is an experiment directory, then + recursively searches through the directory structure to find all directories that contain the required experiment files (input_output.json, timing.json, and .hydra/config.yaml). scores.json is optional. Args: - experiments_root: Path to the root experiments directory + experiments_root: Path to the root experiments directory or a direct experiment directory Returns: List of successfully parsed ExperimentData objects @@ -177,6 +178,18 @@ def parse_experiments_directory(experiments_root: Path) -> List[ExperimentData]: directories_with_files = 0 directories_processed = 0 + # First check if the root path itself is an experiment directory + if ExperimentData.has_required_files(experiments_root): + directories_found += 1 + directories_with_files += 1 + + try: + directory_experiments = _create_experiments_from_directory(experiments_root) + experiments.extend(directory_experiments) + directories_processed += 1 + except Exception as e: + print(f"Error processing {experiments_root}: {e}") + # Recursively find all directories that have required experiment files for experiment_dir in experiments_root.rglob("*"): if not experiment_dir.is_dir(): diff --git a/align_browser/static/app.js b/align_browser/static/app.js index 30cdcbb..fee5a83 100644 --- a/align_browser/static/app.js +++ b/align_browser/static/app.js @@ -10,23 +10,17 @@ import { KDMAUtils, } from './state.js'; +import { + formatValue, + compareValues, + getMaxKDMAsForRun, + getMinimumRequiredKDMAs, + getValidKDMAsForRun +} from './table-formatter.js'; + // Constants -const TEXT_PREVIEW_LENGTH = 800; -const FLOATING_POINT_TOLERANCE = 0.001; const KDMA_SLIDER_DEBOUNCE_MS = 500; -// CSS Classes -const CSS_TABLE_LLM_SELECT = 'table-llm-select'; -const CSS_TABLE_ADM_SELECT = 'table-adm-select'; -const CSS_TABLE_SCENARIO_SELECT = 'table-scenario-select'; -const CSS_TABLE_RUN_VARIANT_SELECT = 'table-run-variant-select'; - -// HTML Templates -const HTML_NA_SPAN = 'N/A'; -const HTML_NO_OPTIONS_SPAN = 'No options available'; -const HTML_NO_SCENE_SPAN = 'No scene'; -const HTML_NO_KDMAS_SPAN = 'No KDMAs'; - document.addEventListener("DOMContentLoaded", () => { // UI state persistence for expandable content @@ -307,10 +301,10 @@ document.addEventListener("DOMContentLoaded", () => { window.addKDMAToRun = async function(runId) { const run = appState.pinnedRuns.get(runId); - const availableKDMAs = getValidKDMAsForRun(runId); + const availableKDMAs = getValidKDMAsForRun(runId, appState.pinnedRuns); const currentKDMAs = run.kdmaValues || {}; - const maxKDMAs = getMaxKDMAsForRun(runId); - const minimumRequired = getMinimumRequiredKDMAs(runId); + const maxKDMAs = getMaxKDMAsForRun(runId, appState.pinnedRuns); + const minimumRequired = getMinimumRequiredKDMAs(runId, appState.pinnedRuns); if (Object.keys(currentKDMAs).length >= maxKDMAs) { console.warn(`Cannot add KDMA: max limit (${maxKDMAs}) reached for run ${runId}`); @@ -414,7 +408,7 @@ document.addEventListener("DOMContentLoaded", () => { // Handle KDMA type change for pinned run - global for onclick access window.handleRunKDMATypeChange = async function(runId, oldKdmaType, newKdmaType) { - const availableKDMAs = getValidKDMAsForRun(runId); + const availableKDMAs = getValidKDMAsForRun(runId, appState.pinnedRuns); await updateKDMAsForRun(runId, (kdmas) => { const updated = { ...kdmas }; @@ -446,7 +440,7 @@ document.addEventListener("DOMContentLoaded", () => { // Update the display value immediately for responsiveness const valueDisplay = document.getElementById(`kdma-value-${runId}-${kdmaType}`); if (valueDisplay) { - valueDisplay.textContent = formatKDMAValue(normalizedValue); + valueDisplay.textContent = KDMAUtils.formatValue(normalizedValue); } // Update the KDMA values with debouncing @@ -605,7 +599,7 @@ document.addEventListener("DOMContentLoaded", () => { if (isDifferent) { td.style.borderLeft = '3px solid #007bff'; } - td.innerHTML = formatValue(pinnedValue, paramInfo.type, paramName, runData.id); + td.innerHTML = formatValue(pinnedValue, paramInfo.type, paramName, runData.id, appState.pinnedRuns); row.appendChild(td); @@ -624,6 +618,7 @@ document.addEventListener("DOMContentLoaded", () => { parameters.set("scenario", { type: "string", required: true }); parameters.set("scenario_state", { type: "longtext", required: false }); parameters.set("available_choices", { type: "choices", required: false }); + parameters.set("choice_info", { type: "choice_info", required: false }); parameters.set("kdma_values", { type: "kdma_values", required: false }); parameters.set("adm_type", { type: "string", required: true }); parameters.set("llm_backbone", { type: "string", required: true }); @@ -668,6 +663,11 @@ document.addEventListener("DOMContentLoaded", () => { return run.inputOutput.input.choices; } + // Choice info + if (paramName === 'choice_info' && run.inputOutput?.choice_info) { + return run.inputOutput.choice_info; + } + // ADM Decision - proper extraction using Pydantic model structure if (paramName === 'adm_decision' && run.inputOutput?.output && run.inputOutput?.input?.choices) { const choiceIndex = run.inputOutput.output.choice; @@ -696,572 +696,9 @@ document.addEventListener("DOMContentLoaded", () => { return 'N/A'; } - // Generic dropdown creation function - function createDropdownForRun(runId, currentValue, options) { - const { - optionsPath, - cssClass, - onChangeHandler, - noOptionsMessage = null, - preCondition = null - } = options; - - const run = appState.pinnedRuns.get(runId); - if (!run) return escapeHtml(currentValue); - - // Check pre-condition if provided - if (preCondition && !preCondition(run)) { - return noOptionsMessage || HTML_NA_SPAN; - } - - // Get options from the specified path in run.availableOptions - const availableOptions = optionsPath.split('.').reduce((obj, key) => obj?.[key], run.availableOptions); - if (!availableOptions || availableOptions.length === 0) { - return noOptionsMessage || HTML_NO_OPTIONS_SPAN; - } - - const sortedOptions = [...availableOptions].sort(); - - // Always disable dropdowns when there are few options - const isDisabled = availableOptions.length <= 1; - const disabledAttr = isDisabled ? 'disabled' : ''; - - let html = `'; - - return html; - } - - // Dropdown configuration for different parameter types - const DROPDOWN_CONFIGS = { - llm: { - optionsPath: 'llms', - cssClass: CSS_TABLE_LLM_SELECT, - onChangeHandler: 'handleRunLLMChange' - }, - adm: { - optionsPath: 'admTypes', - cssClass: CSS_TABLE_ADM_SELECT, - onChangeHandler: 'handleRunADMChange' - }, - scene: { - optionsPath: 'scenes', - cssClass: CSS_TABLE_SCENARIO_SELECT, - onChangeHandler: 'handleRunSceneChange' - }, - scenario: { - optionsPath: 'scenarios', - cssClass: CSS_TABLE_SCENARIO_SELECT, - onChangeHandler: 'handleRunScenarioChange', - preCondition: (run) => run.scene, - noOptionsMessage: HTML_NO_SCENE_SPAN - } - }; - - // Generic dropdown creation factory - const createDropdownForParameter = (parameterType) => { - return (runId, currentValue) => { - const config = DROPDOWN_CONFIGS[parameterType]; - return createDropdownForRun(runId, currentValue, config); - }; - }; - // Create dropdown functions using the factory - const createLLMDropdownForRun = createDropdownForParameter('llm'); - const createADMDropdownForRun = createDropdownForParameter('adm'); - const createSceneDropdownForRun = createDropdownForParameter('scene'); - const createSpecificScenarioDropdownForRun = createDropdownForParameter('scenario'); - // Create dropdown HTML for run variant selection in table cells - function createRunVariantDropdownForRun(runId, currentValue) { - const run = appState.pinnedRuns.get(runId); - if (!run) return escapeHtml(currentValue); - - // Use the run's actual runVariant to ensure correct selection after parameter updates - const actualCurrentValue = run.runVariant; - - return createDropdownForRun(runId, actualCurrentValue, { - optionsPath: 'runVariants', - cssClass: CSS_TABLE_RUN_VARIANT_SELECT, - onChangeHandler: 'handleRunVariantChange' - }); - } - // Get max KDMAs allowed for a specific run based on its constraints and current selections - function getMaxKDMAsForRun(runId) { - const run = appState.pinnedRuns.get(runId); - if (!run) return 0; - - const kdmaOptions = run.availableOptions?.kdmas; - if (!kdmaOptions || !kdmaOptions.validCombinations) { - return 1; // Default to at least 1 KDMA if no options available - } - - // Find the maximum number of KDMAs in any valid combination - let maxKDMAs = 0; - kdmaOptions.validCombinations.forEach(combination => { - maxKDMAs = Math.max(maxKDMAs, Object.keys(combination).length); - }); - - return Math.max(maxKDMAs, 1); // At least 1 KDMA should be possible - } - - // Get minimum required KDMAs for a run - if all combinations have the same count, return that count - function getMinimumRequiredKDMAs(runId) { - const run = appState.pinnedRuns.get(runId); - if (!run?.availableOptions?.kdmas?.validCombinations) { - return 1; // Default to 1 if no options available - } - - const combinations = run.availableOptions.kdmas.validCombinations; - if (combinations.length === 0) { - return 1; - } - - // Filter out empty combinations (unaligned cases with 0 KDMAs) - const nonEmptyCombinations = combinations.filter(combination => Object.keys(combination).length > 0); - - if (nonEmptyCombinations.length === 0) { - return 1; // Only empty combinations available - } - - // Get the count of KDMAs in each non-empty combination - const kdmaCounts = nonEmptyCombinations.map(combination => Object.keys(combination).length); - - // Check if all non-empty combinations have the same number of KDMAs - const firstCount = kdmaCounts[0]; - const allSameCount = kdmaCounts.every(count => count === firstCount); - - if (allSameCount && firstCount > 1) { - return firstCount; // All non-empty combinations require the same number > 1 - } - return 1; // Either mixed counts or all require 1, use single-add behavior - } - - // Get valid KDMAs for a specific run - function getValidKDMAsForRun(runId) { - const run = appState.pinnedRuns.get(runId); - if (!run?.availableOptions?.kdmas?.validCombinations) { - return {}; - } - - // Extract all available types and values from valid combinations - const availableOptions = {}; - run.availableOptions.kdmas.validCombinations.forEach(combination => { - Object.entries(combination).forEach(([kdmaType, value]) => { - if (!availableOptions[kdmaType]) { - availableOptions[kdmaType] = new Set(); - } - availableOptions[kdmaType].add(value); - }); - }); - - return availableOptions; - } - - // Get valid KDMA types that can be selected for a specific run - function getValidKDMATypesForRun(runId, currentKdmaType, currentKDMAs) { - const run = appState.pinnedRuns.get(runId); - if (!run?.availableOptions?.kdmas?.validCombinations) { - return [currentKdmaType]; // Fallback to just current type - } - - const validTypes = new Set([currentKdmaType]); // Always include current type - - // For each unused KDMA type, check if replacing current type would create valid combination - const availableKDMAs = getValidKDMAsForRun(runId); - Object.keys(availableKDMAs).forEach(kdmaType => { - // Skip if this type is already used (except current one we're replacing) - if (kdmaType !== currentKdmaType && currentKDMAs[kdmaType] !== undefined) { - return; - } - - // Test if this type can be used by checking valid combinations - const testKDMAs = { ...currentKDMAs }; - delete testKDMAs[currentKdmaType]; // Remove current type - - // If we're adding a different type, add it with any valid value - if (kdmaType !== currentKdmaType) { - const validValues = Array.from(availableKDMAs[kdmaType] || []); - if (validValues.length > 0) { - testKDMAs[kdmaType] = validValues[0]; // Use first valid value for testing - } - } - - // Check if this combination exists in validCombinations - const isValidCombination = run.availableOptions.kdmas.validCombinations.some(combination => { - return KDMAUtils.deepEqual(testKDMAs, combination); - }); - - if (isValidCombination) { - validTypes.add(kdmaType); - } - }); - - return Array.from(validTypes).sort(); - } - - // Check if a specific KDMA can be removed from a run - function canRemoveSpecificKDMA(runId, kdmaType) { - const run = appState.pinnedRuns.get(runId); - if (!run) return false; - - const currentKDMAs = run.kdmaValues || {}; - const kdmaOptions = run.availableOptions?.kdmas; - if (!kdmaOptions || !kdmaOptions.validCombinations) { - return false; - } - - // Create a copy of current KDMAs without the one we want to remove - const remainingKDMAs = { ...currentKDMAs }; - delete remainingKDMAs[kdmaType]; - - // Check if the remaining KDMA combination exists in validCombinations - const hasValidRemaining = kdmaOptions.validCombinations.some(combination => { - return KDMAUtils.deepEqual(remainingKDMAs, combination); - }); - - if (hasValidRemaining) { - return true; // Normal case - remaining combination is valid - } - - // Special case: If empty combination {} is valid (unaligned case), - // allow removal of any KDMA (will result in clearing all KDMAs) - const hasEmptyOption = kdmaOptions.validCombinations.some(combination => { - return Object.keys(combination).length === 0; - }); - - if (hasEmptyOption) { - return true; - } - - return false; - } - - // Format KDMA value consistently across the application - function formatKDMAValue(value) { - return KDMAUtils.formatValue(value); - } - - - // Check if we can add another KDMA given current KDMA values - function canAddKDMAToRun(runId, currentKDMAs) { - const run = appState.pinnedRuns.get(runId); - if (!run?.availableOptions?.kdmas?.validCombinations) { - return false; - } - - const currentKDMAEntries = Object.entries(currentKDMAs || {}); - const maxKDMAs = getMaxKDMAsForRun(runId); - - // First check if we're already at max - if (currentKDMAEntries.length >= maxKDMAs) { - return false; - } - - // Check if there are any valid combinations that: - // 1. Include all current KDMAs with their exact values - // 2. Have at least one additional KDMA - return run.availableOptions.kdmas.validCombinations.some(combination => { - - const combinationKeys = Object.keys(combination); - if (combinationKeys.length <= currentKDMAEntries.length) { - return false; - } - - // Check if this combination includes all current KDMAs with matching values - return currentKDMAEntries.every(([kdmaType, value]) => { - return combination.hasOwnProperty(kdmaType) && - Math.abs(combination[kdmaType] - value) < FLOATING_POINT_TOLERANCE; - }); - }); - } - - // Create KDMA controls HTML for table cells - function createKDMAControlsForRun(runId, currentKDMAs) { - const run = appState.pinnedRuns.get(runId); - if (!run) return HTML_NA_SPAN; - - const currentKDMAEntries = Object.entries(currentKDMAs || {}); - const canAddMore = canAddKDMAToRun(runId, currentKDMAs); - - let html = `
`; - - // Render existing KDMA controls - currentKDMAEntries.forEach(([kdmaType, value], index) => { - html += createSingleKDMAControlForRun(runId, kdmaType, value, index); - }); - - // Add button - always show but enable/disable based on availability - const disabledAttr = canAddMore ? '' : 'disabled'; - - // Determine tooltip text for disabled state - let tooltipText = ''; - if (!canAddMore) { - tooltipText = 'title="No valid KDMA combinations available with current values"'; - } - - html += ``; - - html += '
'; - return html; - } - - // Create individual KDMA control for table cell - function createSingleKDMAControlForRun(runId, kdmaType, value) { - const availableKDMAs = getValidKDMAsForRun(runId); - const run = appState.pinnedRuns.get(runId); - const currentKDMAs = run.kdmaValues || {}; - - // Get available types (only those that can form valid combinations) - const availableTypes = getValidKDMATypesForRun(runId, kdmaType, currentKDMAs); - - const validValues = Array.from(availableKDMAs[kdmaType] || []); - - // Ensure current value is in the list (in case of data inconsistencies) - if (value !== undefined && value !== null) { - // Check with tolerance for floating point - const hasValue = validValues.some(v => Math.abs(v - value) < FLOATING_POINT_TOLERANCE); - if (!hasValue) { - // Add current value and sort - validValues.push(value); - validValues.sort((a, b) => a - b); - } - } - - // Sort valid values to ensure proper order - validValues.sort((a, b) => a - b); - - // Calculate slider properties from valid values - const minVal = validValues.length > 0 ? Math.min(...validValues) : 0; - const maxVal = validValues.length > 0 ? Math.max(...validValues) : 1; - - // Calculate step as smallest difference between consecutive values, or 0.1 if only one value - let step = 0.1; - if (validValues.length > 1) { - const diffs = []; - for (let i = 1; i < validValues.length; i++) { - diffs.push(validValues[i] - validValues[i-1]); - } - step = Math.min(...diffs); - } - - // Always disable KDMA type dropdown when there are few options - const isDisabled = availableTypes.length <= 1; - const disabledAttr = isDisabled ? 'disabled' : ''; - - return ` -
- - - - ${formatKDMAValue(value)} - - -
- `; - } - - // Parameter-specific dropdown handlers - const PARAMETER_DROPDOWN_HANDLERS = { - 'run_variant': createRunVariantDropdownForRun, - 'llm_backbone': createLLMDropdownForRun, - 'adm_type': createADMDropdownForRun, - 'scene': createSceneDropdownForRun, - 'scenario': createSpecificScenarioDropdownForRun, - 'kdma_values': createKDMAControlsForRun - }; - - // Create expandable content for long text or objects - function createExpandableContent(value, id, isLongText = false) { - const isExpanded = expandableStates[isLongText ? 'text' : 'objects'].get(id) || false; - const content = isLongText ? value : JSON.stringify(value, null, 2); - const preview = isLongText ? `${value.substring(0, TEXT_PREVIEW_LENGTH)}...` : getObjectPreview(value); - - const shortDisplay = isExpanded ? 'none' : (isLongText ? 'inline' : 'inline'); - const fullDisplay = isExpanded ? (isLongText ? 'inline' : 'block') : 'none'; - const buttonText = isExpanded ? (isLongText ? 'Show Less' : 'Show Preview') : (isLongText ? 'Show More' : 'Show Details'); - const toggleFunction = isLongText ? 'toggleText' : 'toggleObject'; - const shortTag = isLongText ? 'span' : 'span'; - const fullTag = isLongText ? 'span' : 'pre'; - - return `
- <${shortTag} id="${id}_${isLongText ? 'short' : 'preview'}" style="display: ${shortDisplay};">${escapeHtml(preview)} - <${fullTag} id="${id}_full" style="display: ${fullDisplay};">${escapeHtml(content)} - -
`; - } - - // Format KDMA association bar for choice display - function formatKDMAAssociationBar(kdma, val) { - const percentage = Math.round(val * 100); - const color = val >= 0.7 ? '#28a745' : val >= 0.4 ? '#ffc107' : '#dc3545'; - return `
- ${kdma} -
-
-
- ${val.toFixed(2)} -
`; - } - - // Format single choice item with KDMA associations - function formatChoiceItem(choice) { - let html = `
-
${escapeHtml(choice.unstructured || choice.description || 'No description')}
`; - - // Add KDMA associations if available - if (choice.kdma_association) { - html += '
'; - html += '
KDMA Association Truth
'; - Object.entries(choice.kdma_association).forEach(([kdma, val]) => { - html += formatKDMAAssociationBar(kdma, val); - }); - html += '
'; - } - html += '
'; - return html; - } - - // Format choices array for display - function formatChoicesValue(choices) { - if (!Array.isArray(choices)) { - return escapeHtml(choices.toString()); - } - - let html = '
'; - choices.forEach((choice) => { - html += formatChoiceItem(choice); - }); - html += '
'; - return html; - } - - // Format KDMA values object for display - function formatKDMAValuesObject(kdmaObject) { - const kdmaEntries = Object.entries(kdmaObject); - if (kdmaEntries.length === 0) { - return HTML_NO_KDMAS_SPAN; - } - - let html = '
'; - kdmaEntries.forEach(([kdmaName, kdmaValue]) => { - html += `
- ${escapeHtml(kdmaName)}: - ${formatKDMAValue(kdmaValue)} -
`; - }); - html += '
'; - return html; - } - - // Format values for display in table cells - function formatValue(value, type, paramName = '', runId = '') { - if (value === null || value === undefined || value === 'N/A') { - return HTML_NA_SPAN; - } - - // Handle dropdown parameters for pinned runs - if (runId !== '' && PARAMETER_DROPDOWN_HANDLERS[paramName]) { - return PARAMETER_DROPDOWN_HANDLERS[paramName](runId, value); - } - - switch (type) { - case 'number': - return typeof value === 'number' ? value.toFixed(3) : value.toString(); - - case 'longtext': - if (typeof value === 'string' && value.length > TEXT_PREVIEW_LENGTH) { - const id = `text_${paramName}_${runId}_${type}`; - return createExpandableContent(value, id, true); - } - return escapeHtml(value.toString()); - - case 'text': - return escapeHtml(value.toString()); - - case 'choices': - return formatChoicesValue(value); - - case 'kdma_values': - return formatKDMAValuesObject(value); - - case 'object': - const id = `object_${paramName}_${runId}_${type}`; - return createExpandableContent(value, id, false); - - default: - return escapeHtml(value.toString()); - } - } - - // Helper functions - function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - - function compareValues(val1, val2) { - if (val1 === val2) return true; - - // Handle null/undefined cases - if (val1 == null || val2 == null) { - return val1 == val2; - } - - // Handle numeric comparison with floating point tolerance - if (typeof val1 === 'number' && typeof val2 === 'number') { - return Math.abs(val1 - val2) < FLOATING_POINT_TOLERANCE; - } - - // Handle string comparison - if (typeof val1 === 'string' && typeof val2 === 'string') { - return val1 === val2; - } - - // Handle array comparison - if (Array.isArray(val1) && Array.isArray(val2)) { - if (val1.length !== val2.length) return false; - for (let i = 0; i < val1.length; i++) { - if (!compareValues(val1[i], val2[i])) return false; - } - return true; - } - - // Handle object comparison - const keys1 = Object.keys(val1); - const keys2 = Object.keys(val2); - - if (keys1.length !== keys2.length) return false; - - for (const key of keys1) { - if (!keys2.includes(key)) return false; - if (!compareValues(val1[key], val2[key])) return false; - } - return true; - } // Add a column with specific parameters (no appState manipulation) async function addColumn(params, options = {}) { @@ -1308,15 +745,6 @@ document.addEventListener("DOMContentLoaded", () => { return runConfig.id; // Return the ID for reference } - function getObjectPreview(obj) { - if (!obj) return 'N/A'; - const keys = Object.keys(obj); - if (keys.length === 0) return '{}'; - if (keys.length === 1) { - return `${keys[0]}: ${obj[keys[0]]}`; - } - return `{${keys.slice(0, 3).join(', ')}${keys.length > 3 ? '...' : ''}}`; - } // Copy the rightmost column's parameters to create a new column async function copyColumn() { @@ -1369,6 +797,7 @@ document.addEventListener("DOMContentLoaded", () => { expandableStates.text.set(id, newExpanded); }; + window.toggleObject = function(id) { const preview = document.getElementById(`${id}_preview`); const full = document.getElementById(`${id}_full`); diff --git a/align_browser/static/index.html b/align_browser/static/index.html index 6e64d70..01b0604 100644 --- a/align_browser/static/index.html +++ b/align_browser/static/index.html @@ -58,6 +58,9 @@

Align System Experiments

Justification + + Choice Info + Probe Time @@ -69,6 +72,7 @@

Align System Experiments

+ diff --git a/align_browser/static/style.css b/align_browser/static/style.css index 1b57eaf..701a673 100644 --- a/align_browser/static/style.css +++ b/align_browser/static/style.css @@ -663,3 +663,215 @@ footer { color: var(--notification-error); border-color: var(--notification-error-border); } + +/* Choice Info Display Styles */ +.choice-info-display { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 400px; + overflow-y: auto; +} + +.choice-info-section-header { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.choice-info-header { + color: #495057; + font-size: 14px; + font-weight: 600; + margin: 0; +} + +.choice-info-summary { + color: #6c757d; + font-size: 12px; + font-style: italic; + flex: 1; +} + +.choice-info-toggle { + margin-left: auto; +} + +.choice-info-details { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #e9ecef; +} + +.predicted-kdma-section { + padding: 10px; + border-radius: 6px; + border: 1px solid #e9ecef; +} + +.choice-kdma-prediction { + margin-bottom: 10px; + padding: 8px; + background: white; + border-radius: 4px; + border: 1px solid #e9ecef; +} + +.choice-name { + font-weight: 600; + color: #495057; + margin-bottom: 6px; + font-size: 13px; +} + +.kdma-predictions { + display: flex; + flex-direction: column; + gap: 4px; +} + +.kdma-prediction-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; +} + +.kdma-prediction-item .kdma-name { + color: #6c757d; + font-weight: 500; + min-width: 120px; +} + +.kdma-prediction-item .kdma-values { + color: #495057; + font-family: monospace; + background: #f8f9fa; + padding: 2px 6px; + border-radius: 3px; +} + +.icl-examples-section { + padding: 10px; + border-radius: 6px; + border: 1px solid #e9ecef; +} + +.icl-kdma-section { + margin-bottom: 12px; + padding: 8px; + background: white; + border-radius: 4px; + border: 1px solid #e9ecef; +} + +.icl-kdma-name { + color: #495057; + font-size: 13px; + font-weight: 600; + margin: 0 0 8px 0; + padding-bottom: 4px; + border-bottom: 1px solid #f1f3f4; +} + +.icl-example { + margin-bottom: 12px; + padding: 8px; + background: #f8f9fa; + border-radius: 4px; + border: 1px solid #e9ecef; +} + +.icl-example-header { + font-weight: 600; + color: #6c757d; + font-size: 12px; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.icl-prompt { + margin-bottom: 8px; +} + +.icl-prompt .expandable-text { + display: inline-block; + vertical-align: top; + margin-left: 4px; +} + +.icl-prompt .expandable-text span, +.icl-prompt .expandable-text pre { + white-space: pre-wrap; + word-wrap: break-word; +} + +.icl-prompt strong { + color: #495057; + font-size: 12px; +} + +.icl-response { + margin-top: 8px; +} + +.icl-response strong { + color: #495057; + font-size: 12px; +} + +.icl-response-content { + margin-top: 6px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.icl-choice-response { + padding: 6px; + background: white; + border-radius: 3px; + border: 1px solid #e9ecef; +} + +.icl-choice-name { + font-weight: 600; + color: #495057; + font-size: 12px; + margin-bottom: 4px; +} + +.icl-choice-details { + display: flex; + flex-direction: column; + gap: 3px; +} + +.icl-score { + font-size: 11px; + color: #6c757d; + font-family: monospace; +} + +.icl-reasoning { + font-size: 11px; + color: #495057; + line-height: 1.3; +} + +.choice-info-generic-section { + padding: 10px; + border-radius: 6px; + border: 1px solid #e9ecef; +} + +.choice-info-generic-content { + font-size: 13px; + color: #495057; +} + +.parameter-row[data-category="choice_info"] { + background: rgba(40, 167, 69, 0.08); +} diff --git a/align_browser/static/table-formatter.js b/align_browser/static/table-formatter.js new file mode 100644 index 0000000..0de6b13 --- /dev/null +++ b/align_browser/static/table-formatter.js @@ -0,0 +1,822 @@ +// Table formatting functions for displaying experiment data + +import { KDMAUtils } from './state.js'; + +// HTML Templates +const HTML_NA_SPAN = 'N/A'; +const HTML_NO_OPTIONS_SPAN = 'No options available'; +const HTML_NO_SCENE_SPAN = 'No scene'; +const HTML_NO_KDMAS_SPAN = 'No KDMAs'; + +// Utility function to escape HTML +export function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Create expandable content showing only first N lines +export function createExpandableContentWithLines(value, id, maxLines = 3) { + // Check if this is available from the main app context + const expandableStates = window.expandableStates || { text: new Map(), objects: new Map() }; + + const isExpanded = expandableStates.text.get(id) || false; + const lines = value.split('\n'); + const preview = lines.slice(0, maxLines).join('\n'); + const needsExpansion = lines.length > maxLines; + + // If it doesn't need expansion, just return the content with proper formatting + if (!needsExpansion) { + return `${escapeHtml(value)}`; + } + + const shortDisplay = isExpanded ? 'none' : 'inline'; + const fullDisplay = isExpanded ? 'inline' : 'none'; + const buttonText = isExpanded ? 'Show Less' : 'Show More'; + + return `
+ ${escapeHtml(preview)}${needsExpansion ? '...' : ''} + ${escapeHtml(value)} + +
`; +} + +// Create expandable content for long text or objects +export function createExpandableContent(value, id, isLongText = false) { + const TEXT_PREVIEW_LENGTH = 800; + + // Check if this is available from the main app context + const expandableStates = window.expandableStates || { text: new Map(), objects: new Map() }; + + const isExpanded = expandableStates[isLongText ? 'text' : 'objects'].get(id) || false; + const content = isLongText ? value : JSON.stringify(value, null, 2); + const preview = isLongText ? `${value.substring(0, TEXT_PREVIEW_LENGTH)}...` : getObjectPreview(value); + + const shortDisplay = isExpanded ? 'none' : (isLongText ? 'inline' : 'inline'); + const fullDisplay = isExpanded ? (isLongText ? 'inline' : 'block') : 'none'; + const buttonText = isExpanded ? (isLongText ? 'Show Less' : 'Show Preview') : (isLongText ? 'Show More' : 'Show Details'); + const toggleFunction = isLongText ? 'toggleText' : 'toggleObject'; + const shortTag = isLongText ? 'span' : 'span'; + const fullTag = isLongText ? 'span' : 'pre'; + + return `
+ <${shortTag} id="${id}_${isLongText ? 'short' : 'preview'}" style="display: ${shortDisplay};">${escapeHtml(preview)} + <${fullTag} id="${id}_full" style="display: ${fullDisplay};">${escapeHtml(content)} + +
`; +} + +// Helper function to get object preview +export function getObjectPreview(obj) { + if (!obj) return 'N/A'; + const keys = Object.keys(obj); + if (keys.length === 0) return '{}'; + if (keys.length === 1) { + return `${keys[0]}: ${obj[keys[0]]}`; + } + return `{${keys.slice(0, 3).join(', ')}${keys.length > 3 ? '...' : ''}}`; +} + +// Create summary text for choice_info sections +export function createChoiceInfoSummary(key, value) { + switch (key) { + case 'predicted_kdma_values': + const choiceCount = Object.keys(value).length; + const kdmaTypes = new Set(); + Object.values(value).forEach(choiceKdmas => { + Object.keys(choiceKdmas).forEach(kdma => kdmaTypes.add(kdma)); + }); + return `${choiceCount} choices with ${kdmaTypes.size} KDMA type(s)`; + + case 'icl_example_responses': + const kdmaCount = Object.keys(value).length; + let totalExamples = 0; + Object.values(value).forEach(examples => { + if (Array.isArray(examples)) { + totalExamples += examples.length; + } + }); + return `${kdmaCount} KDMA(s) with ${totalExamples} example(s)`; + + default: + if (typeof value === 'object') { + const keys = Object.keys(value); + return `Object with ${keys.length} key(s): ${keys.slice(0, 3).join(', ')}${keys.length > 3 ? '...' : ''}`; + } + return value.toString().substring(0, 50) + (value.toString().length > 50 ? '...' : ''); + } +} + +// Create detailed content for choice_info sections +export function createChoiceInfoDetails(key, value, runId = '') { + let html = ''; + + switch (key) { + case 'predicted_kdma_values': + Object.entries(value).forEach(([choiceName, kdmaValues]) => { + html += `
+
${escapeHtml(choiceName)}
+
`; + + Object.entries(kdmaValues).forEach(([kdmaName, values]) => { + const valueList = Array.isArray(values) ? values : [values]; + html += `
+ ${escapeHtml(kdmaName)}: + [${valueList.map(v => v.toFixed(2)).join(', ')}] +
`; + }); + + html += `
`; + }); + break; + + case 'icl_example_responses': + Object.entries(value).forEach(([kdmaName, examples]) => { + html += `
+
${escapeHtml(kdmaName)}
`; + + if (Array.isArray(examples)) { + examples.forEach((example, index) => { + html += `
+
Example ${index + 1}
`; + + if (example.prompt) { + // Process the prompt text to handle escaped characters and newlines + const processedPrompt = example.prompt + .replace(/\\n/g, '\n') + .replace(/\\"/g, '"') + .replace(/\\'/g, "'") + .replace(/\\\\/g, '\\') + .trim(); // Remove leading/trailing whitespace + + const promptId = `icl_prompt_${runId}_${kdmaName}_${index}`; + html += `
+ Prompt: ${createExpandableContentWithLines(processedPrompt, promptId, 3)} +
`; + } + + if (example.response) { + html += `
+ Response: +
`; + + Object.entries(example.response).forEach(([choiceName, responseData]) => { + html += `
+
${escapeHtml(choiceName)}
+
+
Score: ${responseData.score}
+
${escapeHtml(responseData.reasoning || 'No reasoning provided')}
+
+
`; + }); + + html += `
`; + } + + html += `
`; + }); + } + + html += `
`; + }); + break; + + default: + if (typeof value === 'object') { + const objectId = `choice_info_generic_${runId}_${key}`; + html += createExpandableContent(value, objectId, false); + } else { + html += escapeHtml(value.toString()); + } + } + + return html; +} + +// Format choice_info object for display with expandable sections +export function formatChoiceInfoValue(choiceInfo, runId = '') { + if (!choiceInfo || typeof choiceInfo !== 'object') { + return HTML_NA_SPAN; + } + + const keys = Object.keys(choiceInfo); + if (keys.length === 0) { + return HTML_NA_SPAN; + } + + let html = '
'; + + // Create expandable section for each top-level key + keys.forEach(key => { + const value = choiceInfo[key]; + const summary = createChoiceInfoSummary(key, value); + const details = createChoiceInfoDetails(key, value, runId); + const sectionId = `choice_info_section_${runId}_${key}`; + + // Determine section class based on key type + let sectionClass = 'choice-info-generic-section'; + if (key === 'predicted_kdma_values') { + sectionClass = 'predicted-kdma-section'; + } else if (key === 'icl_example_responses') { + sectionClass = 'icl-examples-section'; + } + + html += `
+
+

${escapeHtml(key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()).replace(/\bIcl\b/g, 'ICL'))}

+ ${summary} + +
+ +
`; + }); + + html += '
'; + return html; +} + +// Toggle function for choice_info sections +function toggleChoiceInfoSection(sectionId) { + const summarySpan = document.getElementById(`${sectionId}_summary`); + const detailsDiv = document.getElementById(`${sectionId}_details`); + const button = document.getElementById(`${sectionId}_button`); + + const isCurrentlyExpanded = detailsDiv.style.display !== 'none'; + const newExpanded = !isCurrentlyExpanded; + + if (newExpanded) { + summarySpan.style.display = 'none'; + detailsDiv.style.display = 'block'; + button.textContent = 'Show Less'; + } else { + summarySpan.style.display = 'inline'; + detailsDiv.style.display = 'none'; + button.textContent = 'Show Details'; + } + + // Save state for persistence (access global expandableStates if available) + if (window.expandableStates && window.expandableStates.objects) { + window.expandableStates.objects.set(sectionId, newExpanded); + } +} + + +// Make toggle function globally available for onclick handlers +window.toggleChoiceInfoSection = toggleChoiceInfoSection; + +// Constants +const TEXT_PREVIEW_LENGTH = 800; +const FLOATING_POINT_TOLERANCE = 0.001; + +// Format KDMA value consistently across the application +export function formatKDMAValue(value) { + return KDMAUtils.formatValue(value); +} + +// Format KDMA association bar for choice display +export function formatKDMAAssociationBar(kdma, val) { + const percentage = Math.round(val * 100); + const color = val >= 0.7 ? '#28a745' : val >= 0.4 ? '#ffc107' : '#dc3545'; + return `
+ ${kdma} +
+
+
+ ${val.toFixed(2)} +
`; +} + +// Format single choice item with KDMA associations +export function formatChoiceItem(choice) { + let html = `
+
${escapeHtml(choice.unstructured || choice.description || 'No description')}
`; + + // Add KDMA associations if available + if (choice.kdma_association) { + html += '
'; + html += '
KDMA Association Truth
'; + Object.entries(choice.kdma_association).forEach(([kdma, val]) => { + html += formatKDMAAssociationBar(kdma, val); + }); + html += '
'; + } + html += '
'; + return html; +} + +// Format choices array for display +export function formatChoicesValue(choices) { + if (!Array.isArray(choices)) { + return escapeHtml(choices.toString()); + } + + let html = '
'; + choices.forEach((choice) => { + html += formatChoiceItem(choice); + }); + html += '
'; + return html; +} + +// Format KDMA values object for display +export function formatKDMAValuesObject(kdmaObject) { + const kdmaEntries = Object.entries(kdmaObject); + if (kdmaEntries.length === 0) { + return HTML_NO_KDMAS_SPAN; + } + + let html = '
'; + kdmaEntries.forEach(([kdmaName, kdmaValue]) => { + html += `
+ ${escapeHtml(kdmaName)}: + ${formatKDMAValue(kdmaValue)} +
`; + }); + html += '
'; + return html; +} + +// Compare values with proper handling for different types +export function compareValues(val1, val2) { + if (val1 === val2) return true; + + // Handle null/undefined cases + if (val1 == null || val2 == null) { + return val1 == val2; + } + + // Handle numeric comparison with floating point tolerance + if (typeof val1 === 'number' && typeof val2 === 'number') { + return Math.abs(val1 - val2) < FLOATING_POINT_TOLERANCE; + } + + // Handle string comparison + if (typeof val1 === 'string' && typeof val2 === 'string') { + return val1 === val2; + } + + // Handle array comparison + if (Array.isArray(val1) && Array.isArray(val2)) { + if (val1.length !== val2.length) return false; + for (let i = 0; i < val1.length; i++) { + if (!compareValues(val1[i], val2[i])) return false; + } + return true; + } + + // Handle object comparison + const keys1 = Object.keys(val1); + const keys2 = Object.keys(val2); + + if (keys1.length !== keys2.length) return false; + + for (const key of keys1) { + if (!keys2.includes(key)) return false; + if (!compareValues(val1[key], val2[key])) return false; + } + return true; +} + +// Main value formatting function for table cells +export function formatValue(value, type, paramName = '', runId = '', pinnedRuns = null) { + if (value === null || value === undefined || value === 'N/A') { + return HTML_NA_SPAN; + } + + // Handle dropdown parameters for pinned runs + if (runId !== '' && pinnedRuns && PARAMETER_DROPDOWN_HANDLERS[paramName]) { + const handler = PARAMETER_DROPDOWN_HANDLERS[paramName]; + return handler(runId, value, pinnedRuns); + } + + switch (type) { + case 'number': + return typeof value === 'number' ? value.toFixed(3) : value.toString(); + + case 'longtext': + if (typeof value === 'string' && value.length > TEXT_PREVIEW_LENGTH) { + const id = `text_${paramName}_${runId}_${type}`; + return createExpandableContent(value, id, true); + } + return escapeHtml(value.toString()); + + case 'text': + return escapeHtml(value.toString()); + + case 'choices': + return formatChoicesValue(value); + + case 'choice_info': + return formatChoiceInfoValue(value, runId); + + case 'kdma_values': + return formatKDMAValuesObject(value); + + case 'object': + const id = `object_${paramName}_${runId}_${type}`; + return createExpandableContent(value, id, false); + + default: + return escapeHtml(value.toString()); + } +} + +// CSS Classes for dropdowns +const CSS_TABLE_LLM_SELECT = 'table-llm-select'; +const CSS_TABLE_ADM_SELECT = 'table-adm-select'; +const CSS_TABLE_SCENARIO_SELECT = 'table-scenario-select'; +const CSS_TABLE_RUN_VARIANT_SELECT = 'table-run-variant-select'; + +// Generic dropdown creation function +export function createDropdownForRun(runId, currentValue, options, pinnedRuns) { + const { + optionsPath, + cssClass, + onChangeHandler, + noOptionsMessage = null, + preCondition = null + } = options; + + const run = pinnedRuns.get(runId); + if (!run) return escapeHtml(currentValue); + + // Check pre-condition if provided + if (preCondition && !preCondition(run)) { + return noOptionsMessage || HTML_NA_SPAN; + } + + // Get options from the specified path in run.availableOptions + const availableOptions = optionsPath.split('.').reduce((obj, key) => obj?.[key], run.availableOptions); + if (!availableOptions || availableOptions.length === 0) { + return noOptionsMessage || HTML_NO_OPTIONS_SPAN; + } + + const sortedOptions = [...availableOptions].sort(); + + // Always disable dropdowns when there are few options + const isDisabled = availableOptions.length <= 1; + const disabledAttr = isDisabled ? 'disabled' : ''; + + let html = `'; + + return html; +} + +// Dropdown configuration for different parameter types +const DROPDOWN_CONFIGS = { + llm: { + optionsPath: 'llms', + cssClass: CSS_TABLE_LLM_SELECT, + onChangeHandler: 'handleRunLLMChange' + }, + adm: { + optionsPath: 'admTypes', + cssClass: CSS_TABLE_ADM_SELECT, + onChangeHandler: 'handleRunADMChange' + }, + scene: { + optionsPath: 'scenes', + cssClass: CSS_TABLE_SCENARIO_SELECT, + onChangeHandler: 'handleRunSceneChange' + }, + scenario: { + optionsPath: 'scenarios', + cssClass: CSS_TABLE_SCENARIO_SELECT, + onChangeHandler: 'handleRunScenarioChange', + preCondition: (run) => run.scene, + noOptionsMessage: HTML_NO_SCENE_SPAN + } +}; + +// Generic dropdown creation factory +export function createDropdownForParameter(parameterType) { + return (runId, currentValue, pinnedRuns) => { + const config = DROPDOWN_CONFIGS[parameterType]; + return createDropdownForRun(runId, currentValue, config, pinnedRuns); + }; +} + +// Create dropdown functions using the factory +export const createLLMDropdownForRun = createDropdownForParameter('llm'); +export const createADMDropdownForRun = createDropdownForParameter('adm'); +export const createSceneDropdownForRun = createDropdownForParameter('scene'); +export const createSpecificScenarioDropdownForRun = createDropdownForParameter('scenario'); + +// Create dropdown HTML for run variant selection in table cells +export function createRunVariantDropdownForRun(runId, currentValue, pinnedRuns) { + const run = pinnedRuns.get(runId); + if (!run) return escapeHtml(currentValue); + + // Use the run's actual runVariant to ensure correct selection after parameter updates + const actualCurrentValue = run.runVariant; + + return createDropdownForRun(runId, actualCurrentValue, { + optionsPath: 'runVariants', + cssClass: CSS_TABLE_RUN_VARIANT_SELECT, + onChangeHandler: 'handleRunVariantChange' + }, pinnedRuns); +} + +// Parameter-specific dropdown handlers +export const PARAMETER_DROPDOWN_HANDLERS = { + 'run_variant': createRunVariantDropdownForRun, + 'llm_backbone': createLLMDropdownForRun, + 'adm_type': createADMDropdownForRun, + 'scene': createSceneDropdownForRun, + 'scenario': createSpecificScenarioDropdownForRun, + 'kdma_values': createKDMAControlsForRun +}; + +// KDMA utility functions - these require access to KDMAUtils for deep comparison +// Get max KDMAs allowed for a specific run based on its constraints and current selections +export function getMaxKDMAsForRun(runId, pinnedRuns) { + const run = pinnedRuns.get(runId); + if (!run) return 0; + + const kdmaOptions = run.availableOptions?.kdmas; + if (!kdmaOptions || !kdmaOptions.validCombinations) { + return 1; // Default to at least 1 KDMA if no options available + } + + // Find the maximum number of KDMAs in any valid combination + let maxKDMAs = 0; + kdmaOptions.validCombinations.forEach(combination => { + maxKDMAs = Math.max(maxKDMAs, Object.keys(combination).length); + }); + + return Math.max(maxKDMAs, 1); // At least 1 KDMA should be possible +} + +// Get minimum required KDMAs for a run - if all combinations have the same count, return that count +export function getMinimumRequiredKDMAs(runId, pinnedRuns) { + const run = pinnedRuns.get(runId); + if (!run?.availableOptions?.kdmas?.validCombinations) { + return 1; // Default to 1 if no options available + } + + const combinations = run.availableOptions.kdmas.validCombinations; + if (combinations.length === 0) { + return 1; + } + + // Filter out empty combinations (unaligned cases with 0 KDMAs) + const nonEmptyCombinations = combinations.filter(combination => Object.keys(combination).length > 0); + + if (nonEmptyCombinations.length === 0) { + return 1; // Only empty combinations available + } + + // Get the count of KDMAs in each non-empty combination + const kdmaCounts = nonEmptyCombinations.map(combination => Object.keys(combination).length); + + // Check if all non-empty combinations have the same number of KDMAs + const firstCount = kdmaCounts[0]; + const allSameCount = kdmaCounts.every(count => count === firstCount); + + if (allSameCount && firstCount > 1) { + return firstCount; // All non-empty combinations require the same number > 1 + } + return 1; // Either mixed counts or all require 1, use single-add behavior +} + +// Get valid KDMAs for a specific run +export function getValidKDMAsForRun(runId, pinnedRuns) { + const run = pinnedRuns.get(runId); + if (!run?.availableOptions?.kdmas?.validCombinations) { + return {}; + } + + // Extract all available types and values from valid combinations + const availableOptions = {}; + run.availableOptions.kdmas.validCombinations.forEach(combination => { + Object.entries(combination).forEach(([kdmaType, value]) => { + if (!availableOptions[kdmaType]) { + availableOptions[kdmaType] = new Set(); + } + availableOptions[kdmaType].add(value); + }); + }); + + return availableOptions; +} + +// Get valid KDMA types that can be selected for a specific run +export function getValidKDMATypesForRun(runId, currentKdmaType, currentKDMAs, pinnedRuns) { + const run = pinnedRuns.get(runId); + if (!run?.availableOptions?.kdmas?.validCombinations) { + return [currentKdmaType]; // Fallback to just current type + } + + const validTypes = new Set([currentKdmaType]); // Always include current type + + // For each unused KDMA type, check if replacing current type would create valid combination + const availableKDMAs = getValidKDMAsForRun(runId, pinnedRuns); + Object.keys(availableKDMAs).forEach(kdmaType => { + // Skip if this type is already used (except current one we're replacing) + if (kdmaType !== currentKdmaType && currentKDMAs[kdmaType] !== undefined) { + return; + } + + // Test if this type can be used by checking valid combinations + const testKDMAs = { ...currentKDMAs }; + delete testKDMAs[currentKdmaType]; // Remove current type + + // If we're adding a different type, add it with any valid value + if (kdmaType !== currentKdmaType) { + const validValues = Array.from(availableKDMAs[kdmaType] || []); + if (validValues.length > 0) { + testKDMAs[kdmaType] = validValues[0]; // Use first valid value for testing + } + } + + // Check if this combination exists in validCombinations + const isValidCombination = run.availableOptions.kdmas.validCombinations.some(combination => { + return KDMAUtils.deepEqual(testKDMAs, combination); + }); + + if (isValidCombination) { + validTypes.add(kdmaType); + } + }); + + return Array.from(validTypes).sort(); +} + +// Check if a specific KDMA can be removed from a run +export function canRemoveSpecificKDMA(runId, kdmaType, pinnedRuns) { + const run = pinnedRuns.get(runId); + if (!run) return false; + + const currentKDMAs = run.kdmaValues || {}; + const kdmaOptions = run.availableOptions?.kdmas; + if (!kdmaOptions || !kdmaOptions.validCombinations) { + return false; + } + + // Create a copy of current KDMAs without the one we want to remove + const remainingKDMAs = { ...currentKDMAs }; + delete remainingKDMAs[kdmaType]; + + // Check if the remaining KDMA combination exists in validCombinations + const hasValidRemaining = kdmaOptions.validCombinations.some(combination => { + return KDMAUtils.deepEqual(remainingKDMAs, combination); + }); + + if (hasValidRemaining) { + return true; // Normal case - remaining combination is valid + } + + // Special case: If empty combination {} is valid (unaligned case), + // allow removal of any KDMA (will result in clearing all KDMAs) + const hasEmptyOption = kdmaOptions.validCombinations.some(combination => { + return Object.keys(combination).length === 0; + }); + + if (hasEmptyOption) { + return true; + } + + return false; +} + +// Check if we can add another KDMA given current KDMA values +export function canAddKDMAToRun(runId, currentKDMAs, pinnedRuns) { + const run = pinnedRuns.get(runId); + if (!run?.availableOptions?.kdmas?.validCombinations) { + return false; + } + + const currentKDMAEntries = Object.entries(currentKDMAs || {}); + const maxKDMAs = getMaxKDMAsForRun(runId, pinnedRuns); + + // First check if we're already at max + if (currentKDMAEntries.length >= maxKDMAs) { + return false; + } + + // Check if there are any valid combinations that: + // 1. Include all current KDMAs with their exact values + // 2. Have at least one additional KDMA + return run.availableOptions.kdmas.validCombinations.some(combination => { + + const combinationKeys = Object.keys(combination); + if (combinationKeys.length <= currentKDMAEntries.length) { + return false; + } + + // Check if this combination includes all current KDMAs with matching values + return currentKDMAEntries.every(([kdmaType, value]) => { + return combination.hasOwnProperty(kdmaType) && + Math.abs(combination[kdmaType] - value) < FLOATING_POINT_TOLERANCE; + }); + }); +} + +// Create KDMA controls HTML for table cells +export function createKDMAControlsForRun(runId, currentKDMAs, pinnedRuns) { + const run = pinnedRuns.get(runId); + if (!run) return HTML_NA_SPAN; + + const currentKDMAEntries = Object.entries(currentKDMAs || {}); + const canAddMore = canAddKDMAToRun(runId, currentKDMAs, pinnedRuns); + + let html = `
`; + + // Render existing KDMA controls + currentKDMAEntries.forEach(([kdmaType, value]) => { + html += createSingleKDMAControlForRun(runId, kdmaType, value, pinnedRuns); + }); + + // Add button - always show but enable/disable based on availability + const disabledAttr = canAddMore ? '' : 'disabled'; + + // Determine tooltip text for disabled state + let tooltipText = ''; + if (!canAddMore) { + tooltipText = 'title="No valid KDMA combinations available with current values"'; + } + + html += ``; + + html += '
'; + return html; +} + +// Create individual KDMA control for table cell +export function createSingleKDMAControlForRun(runId, kdmaType, value, pinnedRuns) { + const availableKDMAs = getValidKDMAsForRun(runId, pinnedRuns); + const run = pinnedRuns.get(runId); + const currentKDMAs = run.kdmaValues || {}; + + // Get available types (only those that can form valid combinations) + const availableTypes = getValidKDMATypesForRun(runId, kdmaType, currentKDMAs, pinnedRuns); + + const validValues = Array.from(availableKDMAs[kdmaType] || []); + + // Ensure current value is in the list (in case of data inconsistencies) + if (value !== undefined && value !== null) { + // Check with tolerance for floating point + const hasValue = validValues.some(v => Math.abs(v - value) < FLOATING_POINT_TOLERANCE); + if (!hasValue) { + // Add current value and sort + validValues.push(value); + validValues.sort((a, b) => a - b); + } + } + + // Sort valid values to ensure proper order + validValues.sort((a, b) => a - b); + + // Calculate slider properties from valid values + const minVal = validValues.length > 0 ? Math.min(...validValues) : 0; + const maxVal = validValues.length > 0 ? Math.max(...validValues) : 1; + + // Calculate step as smallest difference between consecutive values, or 0.1 if only one value + let step = 0.1; + if (validValues.length > 1) { + const diffs = []; + for (let i = 1; i < validValues.length; i++) { + diffs.push(validValues[i] - validValues[i-1]); + } + step = Math.min(...diffs); + } + + // Always disable KDMA type dropdown when there are few options + const isDisabled = availableTypes.length <= 1; + const disabledAttr = isDisabled ? 'disabled' : ''; + + return ` +
+ + + + ${formatKDMAValue(value)} + + +
+ `; +} \ No newline at end of file