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 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-16 16:29:31 -06:00
parent 72362b6a3e
commit c6a8d5d1d8
2 changed files with 392 additions and 8 deletions

View File

@ -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<string, ProblemValue>) => void
@ -58,6 +62,8 @@ export function FlowchartProblemInput({
example: GeneratedExample
buttonRect: DOMRect
} | null>(null)
// Selected difficulty tier for filtering examples
const [selectedTier, setSelectedTier] = useState<DifficultyTier>('all')
// Ref to the container for positioning the popover
const containerRef = useRef<HTMLDivElement>(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({
</h2>
)}
{/* Examples Section */}
{/* Difficulty Tier Selection */}
{generatedExamples.length > 0 && (
<div
data-testid="tier-selection"
className={hstack({
gap: '1',
justifyContent: 'center',
padding: '1',
backgroundColor: { base: 'gray.100', _dark: 'gray.700' },
borderRadius: 'lg',
})}
>
<button
data-tier="all"
data-selected={selectedTier === 'all'}
onClick={() => setSelectedTier('all')}
className={css({
padding: '1.5 3',
fontSize: 'sm',
fontWeight: selectedTier === 'all' ? 'bold' : 'medium',
borderRadius: 'md',
border: 'none',
cursor: 'pointer',
transition: 'all 0.15s',
backgroundColor: selectedTier === 'all' ? { base: 'white', _dark: 'gray.600' } : 'transparent',
color: selectedTier === 'all' ? { base: 'gray.900', _dark: 'white' } : { base: 'gray.500', _dark: 'gray.400' },
boxShadow: selectedTier === 'all' ? 'sm' : 'none',
_hover: {
backgroundColor: selectedTier === 'all' ? { base: 'white', _dark: 'gray.600' } : { base: 'gray.200', _dark: 'gray.600' },
},
})}
>
All ({generatedExamples.length})
</button>
<button
data-tier="easy"
data-selected={selectedTier === 'easy'}
onClick={() => setSelectedTier('easy')}
className={css({
padding: '1.5 3',
fontSize: 'sm',
fontWeight: selectedTier === 'easy' ? 'bold' : 'medium',
borderRadius: 'md',
border: 'none',
cursor: 'pointer',
transition: 'all 0.15s',
backgroundColor: selectedTier === 'easy' ? { base: 'green.100', _dark: 'green.800' } : 'transparent',
color: selectedTier === 'easy' ? { base: 'green.700', _dark: 'green.200' } : { base: 'gray.500', _dark: 'gray.400' },
boxShadow: selectedTier === 'easy' ? 'sm' : 'none',
_hover: {
backgroundColor: selectedTier === 'easy' ? { base: 'green.100', _dark: 'green.800' } : { base: 'gray.200', _dark: 'gray.600' },
},
})}
>
Easy ({tierCounts.easy})
</button>
<button
data-tier="medium"
data-selected={selectedTier === 'medium'}
onClick={() => setSelectedTier('medium')}
className={css({
padding: '1.5 3',
fontSize: 'sm',
fontWeight: selectedTier === 'medium' ? 'bold' : 'medium',
borderRadius: 'md',
border: 'none',
cursor: 'pointer',
transition: 'all 0.15s',
backgroundColor: selectedTier === 'medium' ? { base: 'orange.100', _dark: 'orange.800' } : 'transparent',
color: selectedTier === 'medium' ? { base: 'orange.700', _dark: 'orange.200' } : { base: 'gray.500', _dark: 'gray.400' },
boxShadow: selectedTier === 'medium' ? 'sm' : 'none',
_hover: {
backgroundColor: selectedTier === 'medium' ? { base: 'orange.100', _dark: 'orange.800' } : { base: 'gray.200', _dark: 'gray.600' },
},
})}
>
Medium ({tierCounts.medium})
</button>
<button
data-tier="hard"
data-selected={selectedTier === 'hard'}
onClick={() => setSelectedTier('hard')}
className={css({
padding: '1.5 3',
fontSize: 'sm',
fontWeight: selectedTier === 'hard' ? 'bold' : 'medium',
borderRadius: 'md',
border: 'none',
cursor: 'pointer',
transition: 'all 0.15s',
backgroundColor: selectedTier === 'hard' ? { base: 'red.100', _dark: 'red.800' } : 'transparent',
color: selectedTier === 'hard' ? { base: 'red.700', _dark: 'red.200' } : { base: 'gray.500', _dark: 'gray.400' },
boxShadow: selectedTier === 'hard' ? 'sm' : 'none',
_hover: {
backgroundColor: selectedTier === 'hard' ? { base: 'red.100', _dark: 'red.800' } : { base: 'gray.200', _dark: 'gray.600' },
},
})}
>
Hard ({tierCounts.hard})
</button>
</div>
)}
{/* Examples Section */}
{filteredExamples.length > 0 ? (
<div data-testid="examples-section" className={vstack({ gap: '3', alignItems: 'stretch' })}>
{/* 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 */
<div
data-grid-type="flat"
data-count={generatedExamples.length}
data-count={filteredExamples.length}
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
@ -789,7 +946,7 @@ export function FlowchartProblemInput({
width: '100%',
})}
>
{generatedExamples.map((example, idx) => (
{filteredExamples.map((example, idx) => (
<button
key={`${example.pathSignature}-${idx}`}
data-testid={`example-button-${idx}`}
@ -891,7 +1048,20 @@ export function FlowchartProblemInput({
</div>
)}
</div>
)}
) : generatedExamples.length > 0 && selectedTier !== 'all' ? (
/* No examples in selected tier */
<div
data-testid="no-tier-examples"
className={css({
padding: '4',
textAlign: 'center',
color: { base: 'gray.500', _dark: 'gray.400' },
fontSize: 'sm',
})}
>
No {selectedTier} examples available. Try selecting a different difficulty level.
</div>
) : null}
{/* Edit Popover - shows when editing an example */}
{editingExample && containerRef.current && (

View File

@ -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<Map<string, { pathLabel: string; gridLabel?: string }>> = []
for (const example of examples) {
const nodeIds = example.pathSignature.split('→')
const decisions = new Map<string, { pathLabel: string; gridLabel?: string }>()
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<string, {
uniqueValues: Set<string>
pathLabels: Map<string, string> // value -> pathLabel
gridLabels: Map<string, string | undefined> // 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<string, [number, number]>()
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<string, [number, number]>()
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<string, [number, number]>()
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<string, [number, number]>()
for (let i = 0; i < descriptors.length; i++) {
cellMap.set(descriptors[i], [i, 0])
}
return { rows, cols: [], rowKeys, colKeys: [], cellMap }
}
// =============================================================================
// Constraint-Guided Generation System
// =============================================================================