From c6a8d5d1d86f3c465e9f837791d8586a1c9496ff Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Fri, 16 Jan 2026 16:29:31 -0600 Subject: [PATCH] Add difficulty tier selection with dynamic grid dimensions - Add Easy/Medium/Hard tier filter tabs to flowchart example picker - Create inferGridDimensionsFromExamples() to dynamically determine grid axes based on which dimensions vary within filtered examples - Grid adapts to show the most meaningful dimensions for each tier - Difficulty scoring uses existing path complexity (decisions + checkpoints) Co-Authored-By: Claude Opus 4.5 --- .../flowchart/FlowchartProblemInput.tsx | 186 ++++++++++++++- apps/web/src/lib/flowcharts/loader.ts | 214 ++++++++++++++++++ 2 files changed, 392 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/flowchart/FlowchartProblemInput.tsx b/apps/web/src/components/flowchart/FlowchartProblemInput.tsx index c036a5ad..958d1927 100644 --- a/apps/web/src/components/flowchart/FlowchartProblemInput.tsx +++ b/apps/web/src/components/flowchart/FlowchartProblemInput.tsx @@ -13,6 +13,7 @@ import { generateDiverseExamples, analyzeFlowchart, inferGridDimensions, + inferGridDimensionsFromExamples, calculatePathComplexity, type GeneratedExample, type GenerationConstraints, @@ -25,6 +26,9 @@ import { css } from '../../../styled-system/css' import { vstack, hstack } from '../../../styled-system/patterns' import { MathDisplay } from './MathDisplay' +/** Difficulty tier for filtering examples */ +type DifficultyTier = 'easy' | 'medium' | 'hard' | 'all' + interface FlowchartProblemInputProps { schema: ProblemInputSchema onSubmit: (values: Record) => void @@ -58,6 +62,8 @@ export function FlowchartProblemInput({ example: GeneratedExample buttonRect: DOMRect } | null>(null) + // Selected difficulty tier for filtering examples + const [selectedTier, setSelectedTier] = useState('all') // Ref to the container for positioning the popover const containerRef = useRef(null) @@ -79,8 +85,8 @@ export function FlowchartProblemInput({ } }, [flowchart]) - // Infer grid dimensions from flowchart decision structure - const gridDimensions = useMemo(() => { + // Infer grid dimensions from flowchart decision structure (for "All" view) + const baseGridDimensions = useMemo(() => { if (!flowchart || !analysis) return null try { return inferGridDimensions(flowchart, analysis.paths) @@ -149,6 +155,54 @@ export function FlowchartProblemInput({ return 3 } + // Get difficulty tier for an example (matches the tier selection) + const getDifficultyTier = (example: GeneratedExample): 'easy' | 'medium' | 'hard' => { + const level = getDifficultyLevel(example) + switch (level) { + case 1: + return 'easy' + case 2: + return 'medium' + case 3: + return 'hard' + } + } + + // Filter examples by selected tier + const filteredExamples = useMemo(() => { + if (selectedTier === 'all') return generatedExamples + return generatedExamples.filter((ex) => getDifficultyTier(ex) === selectedTier) + }, [generatedExamples, selectedTier, difficultyRange]) + + // Count examples by tier for display + const tierCounts = useMemo(() => { + const counts = { easy: 0, medium: 0, hard: 0 } + for (const ex of generatedExamples) { + counts[getDifficultyTier(ex)]++ + } + return counts + }, [generatedExamples, difficultyRange]) + + // Dynamic grid dimensions based on filtered examples + // When a tier is selected, use the dimensions that actually vary within that tier + const gridDimensions = useMemo(() => { + if (!flowchart) return null + + // For "All" view, use the base grid dimensions (from all paths) + if (selectedTier === 'all') { + return baseGridDimensions + } + + // For tier-filtered views, dynamically infer dimensions from filtered examples + try { + return inferGridDimensionsFromExamples(flowchart, filteredExamples) + } catch (e) { + console.error('Error inferring dynamic grid dimensions:', e) + // Fall back to base dimensions if dynamic inference fails + return baseGridDimensions + } + }, [flowchart, selectedTier, filteredExamples, baseGridDimensions]) + // Get border color based on difficulty const getDifficultyBorderColor = (level: 1 | 2 | 3) => { switch (level) { @@ -462,8 +516,111 @@ export function FlowchartProblemInput({ )} - {/* Examples Section */} + {/* Difficulty Tier Selection */} {generatedExamples.length > 0 && ( +
+ + + + +
+ )} + + {/* Examples Section */} + {filteredExamples.length > 0 ? (
{/* Example grid - 1D or 2D layout based on grid dimensions */} {gridDimensions && gridDimensions.cols.length > 0 ? ( @@ -529,7 +686,7 @@ export function FlowchartProblemInput({ // Cells for this row ...gridDimensions.cols.map((col, colIdx) => { // Find example that matches this cell - const example = generatedExamples.find((ex) => { + const example = filteredExamples.find((ex) => { const cell = gridDimensions.cellMap.get(ex.pathDescriptor) return cell && cell[0] === rowIdx && cell[1] === colIdx }) @@ -657,7 +814,7 @@ export function FlowchartProblemInput({ > {gridDimensions.rows.map((group, groupIdx) => { // Find example(s) for this group - const groupExamples = generatedExamples.filter((ex) => { + const groupExamples = filteredExamples.filter((ex) => { const cell = gridDimensions.cellMap.get(ex.pathDescriptor) return cell && cell[0] === groupIdx }) @@ -781,7 +938,7 @@ export function FlowchartProblemInput({ /* Fallback: flat 3-column grid when no grid dimensions */
- {generatedExamples.map((example, idx) => ( + {filteredExamples.map((example, idx) => (
- )} + ) : generatedExamples.length > 0 && selectedTier !== 'all' ? ( + /* No examples in selected tier */ +
+ No {selectedTier} examples available. Try selecting a different difficulty level. +
+ ) : null} {/* Edit Popover - shows when editing an example */} {editingExample && containerRef.current && ( diff --git a/apps/web/src/lib/flowcharts/loader.ts b/apps/web/src/lib/flowcharts/loader.ts index 13223f5d..ec39c065 100644 --- a/apps/web/src/lib/flowcharts/loader.ts +++ b/apps/web/src/lib/flowcharts/loader.ts @@ -1400,6 +1400,220 @@ function inferGridFromDescriptors( } } +/** + * Infer grid dimensions dynamically from a set of examples. + * Unlike inferGridDimensions (which uses all possible paths), this function + * analyzes which dimensions actually VARY within the given examples and + * uses the top 2 varying dimensions as grid axes. + * + * This is useful when filtering by difficulty tier - the grid adapts to show + * the dimensions that are most meaningful for that tier. + */ +export function inferGridDimensionsFromExamples( + flowchart: ExecutableFlowchart, + examples: GeneratedExample[] +): GridDimensions | null { + if (examples.length === 0) return null + + // Step 1: Extract decision choices from each example's pathSignature + // pathSignature is "NODE1→NODE2→NODE3→..." - we trace through to find decisions + const exampleDecisions: Array> = [] + + for (const example of examples) { + const nodeIds = example.pathSignature.split('→') + const decisions = new Map() + + for (let i = 0; i < nodeIds.length - 1; i++) { + const nodeId = nodeIds[i] + const nextNodeId = nodeIds[i + 1] + const node = flowchart.nodes[nodeId] + + if (node?.definition.type === 'decision') { + const decision = node.definition as DecisionNode + const option = decision.options.find(o => o.next === nextNodeId) + if (option?.pathLabel) { + decisions.set(nodeId, { + pathLabel: option.pathLabel, + gridLabel: option.gridLabel, + }) + } + } + } + + exampleDecisions.push(decisions) + } + + // Step 2: Count unique values per decision node + const decisionVariation = new Map + pathLabels: Map // value -> pathLabel + gridLabels: Map // value -> gridLabel + }>() + + for (const decisions of exampleDecisions) { + for (const [nodeId, { pathLabel, gridLabel }] of decisions) { + let info = decisionVariation.get(nodeId) + if (!info) { + info = { + uniqueValues: new Set(), + pathLabels: new Map(), + gridLabels: new Map(), + } + decisionVariation.set(nodeId, info) + } + info.uniqueValues.add(pathLabel) + info.pathLabels.set(pathLabel, pathLabel) + info.gridLabels.set(pathLabel, gridLabel) + } + } + + // Step 3: Rank decisions by variation (number of unique values) + const rankedDecisions = [...decisionVariation.entries()] + .filter(([, info]) => info.uniqueValues.size >= 2) // Only decisions with variation + .sort((a, b) => { + // Primary: more unique values = more important + const diff = b[1].uniqueValues.size - a[1].uniqueValues.size + if (diff !== 0) return diff + // Secondary: more examples that hit this decision + const aCount = exampleDecisions.filter(d => d.has(a[0])).length + const bCount = exampleDecisions.filter(d => d.has(b[0])).length + return bCount - aCount + }) + + if (rankedDecisions.length === 0) { + // No varying dimensions - fall back to single cell or descriptor-based + return inferGridFromDescriptorsFromExamples(examples) + } + + // Step 4: Build grid from top 1 or 2 dimensions + const dim1NodeId = rankedDecisions[0][0] + const dim1Info = rankedDecisions[0][1] + + if (rankedDecisions.length === 1) { + // 1D grid + const rows: string[] = [] + const rowKeys: string[] = [] + + for (const pathLabel of dim1Info.uniqueValues) { + const gridLabel = dim1Info.gridLabels.get(pathLabel) + rows.push(gridLabel !== undefined ? gridLabel : pathLabel) + rowKeys.push(pathLabel) + } + + // Build cell map + const cellMap = new Map() + for (const example of examples) { + const rowIdx = rowKeys.findIndex(k => + example.pathDescriptor === k || + example.pathDescriptor.startsWith(k + ' ') || + example.pathDescriptor.includes(' ' + k + ' ') || + example.pathDescriptor.endsWith(' ' + k) + ) + if (rowIdx !== -1) { + cellMap.set(example.pathDescriptor, [rowIdx, 0]) + } + } + + return { rows, cols: [], rowKeys, colKeys: [], cellMap } + } + + // 2D grid + const dim2NodeId = rankedDecisions[1][0] + const dim2Info = rankedDecisions[1][1] + + // Determine which dimension appears first in paths (use as rows) + let dim1First = true + for (const decisions of exampleDecisions) { + const keys = [...decisions.keys()] + const idx1 = keys.indexOf(dim1NodeId) + const idx2 = keys.indexOf(dim2NodeId) + if (idx1 !== -1 && idx2 !== -1) { + dim1First = idx1 < idx2 + break + } + } + + const rowInfo = dim1First ? dim1Info : dim2Info + const colInfo = dim1First ? dim2Info : dim1Info + + const rows: string[] = [] + const rowKeys: string[] = [] + const cols: string[] = [] + const colKeys: string[] = [] + + for (const pathLabel of rowInfo.uniqueValues) { + const gridLabel = rowInfo.gridLabels.get(pathLabel) + rows.push(gridLabel !== undefined ? gridLabel : pathLabel) + rowKeys.push(pathLabel) + } + + for (const pathLabel of colInfo.uniqueValues) { + const gridLabel = colInfo.gridLabels.get(pathLabel) + cols.push(gridLabel !== undefined ? gridLabel : pathLabel) + colKeys.push(pathLabel) + } + + // Build cell map + const cellMap = new Map() + for (const example of examples) { + // Find row - which rowKey appears in the descriptor? + const rowIdx = rowKeys.findIndex(k => + example.pathDescriptor === k || + example.pathDescriptor.startsWith(k + ' ') || + example.pathDescriptor.includes(' ' + k + ' ') || + example.pathDescriptor.endsWith(' ' + k) + ) + + // Find col - which colKey appears in the descriptor? + const colIdx = colKeys.findIndex(k => + example.pathDescriptor === k || + example.pathDescriptor.startsWith(k + ' ') || + example.pathDescriptor.includes(' ' + k + ' ') || + example.pathDescriptor.endsWith(' ' + k) + ) + + if (rowIdx !== -1 && colIdx !== -1) { + cellMap.set(example.pathDescriptor, [rowIdx, colIdx]) + } + } + + return { rows, cols, rowKeys, colKeys, cellMap } +} + +/** + * Fallback: infer grid from pathDescriptor strings when no varying decisions found + */ +function inferGridFromDescriptorsFromExamples( + examples: GeneratedExample[] +): GridDimensions | null { + const descriptors = [...new Set(examples.map(ex => ex.pathDescriptor))] + + if (descriptors.length < 2) { + // Single cell - all examples in one group + const cellMap = new Map() + for (const ex of examples) { + cellMap.set(ex.pathDescriptor, [0, 0]) + } + return { + rows: [descriptors[0] || 'All'], + cols: [], + rowKeys: [descriptors[0] || 'All'], + colKeys: [], + cellMap + } + } + + // Simple 1D grid with each descriptor as a row + const rows = descriptors + const rowKeys = descriptors + const cellMap = new Map() + for (let i = 0; i < descriptors.length; i++) { + cellMap.set(descriptors[i], [i, 0]) + } + + return { rows, cols: [], rowKeys, colKeys: [], cellMap } +} + // ============================================================================= // Constraint-Guided Generation System // =============================================================================