From b4d71a21a4aff9c0f9cd13735dd2869d230d1fad Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 31 Jul 2025 08:14:34 -0400 Subject: [PATCH 1/4] feat: support direct experiment directory paths in CLI Allow users to point directly at a single experiment directory instead of requiring a parent directory containing experiments. The parser now checks if the given path itself is a valid experiment directory before recursively searching subdirectories. This enables usage like: uv run align-browser --dev ./experiment-data/icl_test/pipeline_comp_reg_icl_test/ --- align_browser/experiment_parser.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) 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(): From 90b84fd65d5b91e3ebec55ae069b0a2e2186ab70 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 31 Jul 2025 09:06:45 -0400 Subject: [PATCH 2/4] feat: add expandable choice info display to UI Add comprehensive choice_info display functionality with: - Expandable sections for each top-level key with summary and details - Special handling for predicted_kdma_values and icl_example_responses - Proper text processing for escaped characters in ICL prompts - Unique section IDs per run column to prevent cross-column interference - Positioned after justification row for logical grouping - Generic fallback for unknown choice_info structures --- align_browser/static/app.js | 44 ++-- align_browser/static/index.html | 4 + align_browser/static/style.css | 212 +++++++++++++++++++ align_browser/static/table-formatter.js | 262 ++++++++++++++++++++++++ 4 files changed, 494 insertions(+), 28 deletions(-) create mode 100644 align_browser/static/table-formatter.js diff --git a/align_browser/static/app.js b/align_browser/static/app.js index 30cdcbb..f69f603 100644 --- a/align_browser/static/app.js +++ b/align_browser/static/app.js @@ -10,6 +10,11 @@ import { KDMAUtils, } from './state.js'; +import { + formatChoiceInfoValue, + createExpandableContent, +} from './table-formatter.js'; + // Constants const TEXT_PREVIEW_LENGTH = 800; const FLOATING_POINT_TOLERANCE = 0.001; @@ -624,6 +629,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 +674,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; @@ -1093,25 +1104,6 @@ document.addEventListener("DOMContentLoaded", () => { '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) { @@ -1158,6 +1150,7 @@ document.addEventListener("DOMContentLoaded", () => { return html; } + // Format KDMA values object for display function formatKDMAValuesObject(kdmaObject) { const kdmaEntries = Object.entries(kdmaObject); @@ -1204,6 +1197,9 @@ document.addEventListener("DOMContentLoaded", () => { case 'choices': return formatChoicesValue(value); + case 'choice_info': + return formatChoiceInfoValue(value, runId); + case 'kdma_values': return formatKDMAValuesObject(value); @@ -1308,15 +1304,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 +1356,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..842e166 --- /dev/null +++ b/align_browser/static/table-formatter.js @@ -0,0 +1,262 @@ +// Table formatting functions for displaying experiment data + +// HTML Templates +const HTML_NA_SPAN = 'N/A'; + +// 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; \ No newline at end of file From 5b031c916ecd6a2cf367c761a764d3c394a000ac Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 31 Jul 2025 10:04:51 -0400 Subject: [PATCH 3/4] refactor: consolidate table formatting code into dedicated module Move all table formatting functions from app.js to table-formatter.js to create clear separation between business logic and presentation logic. This consolidation reduces app.js from 1500+ lines and creates a comprehensive formatting module. Key changes: - Move choice/KDMA formatting functions to table-formatter.js - Move dropdown generation and KDMA control creation functions - Move core value formatting and comparison utilities - Update function signatures to properly pass dependencies (pinnedRuns, KDMAUtils) - Fix JavaScript initialization order to prevent "Cannot access before initialization" errors - Clean up duplicate code and unused imports All 51 tests pass, confirming functionality is preserved while improving code organization and maintainability. --- align_browser/static/app.js | 581 +----------------------- align_browser/static/table-formatter.js | 567 ++++++++++++++++++++++- 2 files changed, 577 insertions(+), 571 deletions(-) diff --git a/align_browser/static/app.js b/align_browser/static/app.js index f69f603..ea34a49 100644 --- a/align_browser/static/app.js +++ b/align_browser/static/app.js @@ -11,27 +11,16 @@ import { } from './state.js'; import { - formatChoiceInfoValue, - createExpandableContent, + 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 @@ -312,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}`); @@ -419,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 }; @@ -451,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 @@ -610,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, KDMAUtils); row.appendChild(td); @@ -707,557 +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 - }; - // 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 '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()); - } - } - - // 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 = {}) { diff --git a/align_browser/static/table-formatter.js b/align_browser/static/table-formatter.js index 842e166..3bdaf50 100644 --- a/align_browser/static/table-formatter.js +++ b/align_browser/static/table-formatter.js @@ -2,6 +2,9 @@ // 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) { @@ -259,4 +262,566 @@ function toggleChoiceInfoSection(sectionId) { // Make toggle function globally available for onclick handlers -window.toggleChoiceInfoSection = toggleChoiceInfoSection; \ No newline at end of file +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, KDMAUtils = null) { + if (KDMAUtils) { + return KDMAUtils.formatValue(value); + } + return typeof value === 'number' ? value.toFixed(2) : value.toString(); +} + +// 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, KDMAUtils = 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]; + if (paramName === 'kdma_values') { + return handler(runId, value, pinnedRuns, KDMAUtils); + } else { + 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, KDMAUtils) { + 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, KDMAUtils) { + 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, KDMAUtils) { + 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, KDMAUtils) { + const run = pinnedRuns.get(runId); + if (!run) return HTML_NA_SPAN; + + const currentKDMAEntries = Object.entries(currentKDMAs || {}); + const canAddMore = canAddKDMAToRun(runId, currentKDMAs, pinnedRuns, KDMAUtils); + + let html = `
`; + + // Render existing KDMA controls + currentKDMAEntries.forEach(([kdmaType, value], index) => { + html += createSingleKDMAControlForRun(runId, kdmaType, value, index, pinnedRuns, KDMAUtils); + }); + + // 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, index, pinnedRuns, KDMAUtils) { + 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, KDMAUtils); + + 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, KDMAUtils)} + + +
+ `; +} \ No newline at end of file From 16169e5b79a4cff4f4c50019128daff499514c08 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 31 Jul 2025 10:07:48 -0400 Subject: [PATCH 4/4] refactor: import KDMAUtils directly in table-formatter.js Replace awkward parameter passing of KDMAUtils with direct import from state.js. This simplifies function signatures and makes the code more maintainable by removing the need to thread KDMAUtils through multiple function calls. All tests continue to pass, confirming the refactor preserves functionality. --- align_browser/static/app.js | 2 +- align_browser/static/table-formatter.js | 41 +++++++++++-------------- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/align_browser/static/app.js b/align_browser/static/app.js index ea34a49..fee5a83 100644 --- a/align_browser/static/app.js +++ b/align_browser/static/app.js @@ -599,7 +599,7 @@ document.addEventListener("DOMContentLoaded", () => { if (isDifferent) { td.style.borderLeft = '3px solid #007bff'; } - td.innerHTML = formatValue(pinnedValue, paramInfo.type, paramName, runData.id, appState.pinnedRuns, KDMAUtils); + td.innerHTML = formatValue(pinnedValue, paramInfo.type, paramName, runData.id, appState.pinnedRuns); row.appendChild(td); diff --git a/align_browser/static/table-formatter.js b/align_browser/static/table-formatter.js index 3bdaf50..0de6b13 100644 --- a/align_browser/static/table-formatter.js +++ b/align_browser/static/table-formatter.js @@ -1,5 +1,7 @@ // 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'; @@ -269,11 +271,8 @@ const TEXT_PREVIEW_LENGTH = 800; const FLOATING_POINT_TOLERANCE = 0.001; // Format KDMA value consistently across the application -export function formatKDMAValue(value, KDMAUtils = null) { - if (KDMAUtils) { - return KDMAUtils.formatValue(value); - } - return typeof value === 'number' ? value.toFixed(2) : value.toString(); +export function formatKDMAValue(value) { + return KDMAUtils.formatValue(value); } // Format KDMA association bar for choice display @@ -381,7 +380,7 @@ export function compareValues(val1, val2) { } // Main value formatting function for table cells -export function formatValue(value, type, paramName = '', runId = '', pinnedRuns = null, KDMAUtils = null) { +export function formatValue(value, type, paramName = '', runId = '', pinnedRuns = null) { if (value === null || value === undefined || value === 'N/A') { return HTML_NA_SPAN; } @@ -389,11 +388,7 @@ export function formatValue(value, type, paramName = '', runId = '', pinnedRuns // Handle dropdown parameters for pinned runs if (runId !== '' && pinnedRuns && PARAMETER_DROPDOWN_HANDLERS[paramName]) { const handler = PARAMETER_DROPDOWN_HANDLERS[paramName]; - if (paramName === 'kdma_values') { - return handler(runId, value, pinnedRuns, KDMAUtils); - } else { - return handler(runId, value, pinnedRuns); - } + return handler(runId, value, pinnedRuns); } switch (type) { @@ -613,7 +608,7 @@ export function getValidKDMAsForRun(runId, pinnedRuns) { } // Get valid KDMA types that can be selected for a specific run -export function getValidKDMATypesForRun(runId, currentKdmaType, currentKDMAs, pinnedRuns, KDMAUtils) { +export function getValidKDMATypesForRun(runId, currentKdmaType, currentKDMAs, pinnedRuns) { const run = pinnedRuns.get(runId); if (!run?.availableOptions?.kdmas?.validCombinations) { return [currentKdmaType]; // Fallback to just current type @@ -655,7 +650,7 @@ export function getValidKDMATypesForRun(runId, currentKdmaType, currentKDMAs, pi } // Check if a specific KDMA can be removed from a run -export function canRemoveSpecificKDMA(runId, kdmaType, pinnedRuns, KDMAUtils) { +export function canRemoveSpecificKDMA(runId, kdmaType, pinnedRuns) { const run = pinnedRuns.get(runId); if (!run) return false; @@ -692,7 +687,7 @@ export function canRemoveSpecificKDMA(runId, kdmaType, pinnedRuns, KDMAUtils) { } // Check if we can add another KDMA given current KDMA values -export function canAddKDMAToRun(runId, currentKDMAs, pinnedRuns, KDMAUtils) { +export function canAddKDMAToRun(runId, currentKDMAs, pinnedRuns) { const run = pinnedRuns.get(runId); if (!run?.availableOptions?.kdmas?.validCombinations) { return false; @@ -725,18 +720,18 @@ export function canAddKDMAToRun(runId, currentKDMAs, pinnedRuns, KDMAUtils) { } // Create KDMA controls HTML for table cells -export function createKDMAControlsForRun(runId, currentKDMAs, pinnedRuns, KDMAUtils) { +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, KDMAUtils); + const canAddMore = canAddKDMAToRun(runId, currentKDMAs, pinnedRuns); let html = `
`; // Render existing KDMA controls - currentKDMAEntries.forEach(([kdmaType, value], index) => { - html += createSingleKDMAControlForRun(runId, kdmaType, value, index, pinnedRuns, KDMAUtils); + currentKDMAEntries.forEach(([kdmaType, value]) => { + html += createSingleKDMAControlForRun(runId, kdmaType, value, pinnedRuns); }); // Add button - always show but enable/disable based on availability @@ -759,13 +754,13 @@ export function createKDMAControlsForRun(runId, currentKDMAs, pinnedRuns, KDMAUt } // Create individual KDMA control for table cell -export function createSingleKDMAControlForRun(runId, kdmaType, value, index, pinnedRuns, KDMAUtils) { +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, KDMAUtils); + const availableTypes = getValidKDMATypesForRun(runId, kdmaType, currentKDMAs, pinnedRuns); const validValues = Array.from(availableKDMAs[kdmaType] || []); @@ -816,12 +811,12 @@ export function createSingleKDMAControlForRun(runId, kdmaType, value, index, pin min="${minVal}" max="${maxVal}" step="${step}" value="${value}" oninput="handleRunKDMASliderInput('${runId}', '${kdmaType}', this)"> - ${formatKDMAValue(value, KDMAUtils)} + ${formatKDMAValue(value)} + ${!canRemoveSpecificKDMA(runId, kdmaType, pinnedRuns) ? 'disabled' : ''} + title="${!canRemoveSpecificKDMA(runId, kdmaType, pinnedRuns) ? 'No valid experiments exist without this KDMA' : 'Remove KDMA'}">×
`; } \ No newline at end of file