- Rewrite DebugMermaidDiagram edge matching to use BFS graph traversal
- Build graph from SVG edges (L_FROM_TO_INDEX format) for path finding
- Handle phase boundary disconnections with bidirectional BFS:
- Forward BFS finds all nodes reachable from start
- Backward BFS finds all nodes that can reach end
- Combines both to highlight intermediate nodes across phase gaps
- Remove complex pattern matching in favor of graph-based approach
- Auto-compute edge IDs as {nodeId}_{optionValue} in loader.ts
- Add computeEdgeId() helper to schema.ts for consistent edge ID generation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1247 lines
39 KiB
TypeScript
1247 lines
39 KiB
TypeScript
/**
|
|
* Flowchart Loader
|
|
*
|
|
* Combines JSON definitions (`.flow.json`) with Mermaid content (`.mmd` or embedded)
|
|
* to create executable flowcharts, and manages runtime state as users walk through them.
|
|
*
|
|
* ## Key Functions
|
|
*
|
|
* - {@link loadFlowchart} - Merge JSON definition + Mermaid into ExecutableFlowchart
|
|
* - {@link initializeState} - Create initial runtime state from problem input
|
|
* - {@link advanceState} - Move to next node in the flowchart
|
|
* - {@link validateCheckpoint} - Check if user's answer is correct
|
|
* - {@link isDecisionCorrect} - Check if user chose the correct option
|
|
* - {@link formatProblemDisplay} - Format problem values for display
|
|
*
|
|
* ## Data Flow
|
|
*
|
|
* ```
|
|
* FlowchartDefinition + Mermaid content
|
|
* ↓
|
|
* loadFlowchart()
|
|
* ↓
|
|
* ExecutableFlowchart
|
|
* ↓
|
|
* initializeState(flowchart, problemInput)
|
|
* ↓
|
|
* FlowchartState
|
|
* ↓
|
|
* advanceState(), validateCheckpoint(), etc.
|
|
* ```
|
|
*
|
|
* ## Where to Find Mermaid Content
|
|
*
|
|
* **IMPORTANT**: Mermaid content is NOT always in separate `.mmd` files!
|
|
* Check `definitions/index.ts` first - many flowcharts embed their mermaid as constants.
|
|
*
|
|
* @see {@link ./README.md} for complete system documentation
|
|
* @module flowcharts/loader
|
|
*/
|
|
|
|
import type {
|
|
FlowchartDefinition,
|
|
ExecutableFlowchart,
|
|
ExecutableNode,
|
|
FlowchartState,
|
|
ProblemValue,
|
|
WorkingProblemHistoryEntry,
|
|
InstructionNode,
|
|
CheckpointNode,
|
|
TransformExpression,
|
|
StateSnapshot,
|
|
AnswerDefinition,
|
|
} from './schema'
|
|
import { computeEdgeId } from './schema'
|
|
import { parseMermaidFile, parseNodeContent } from './parser'
|
|
import { evaluate, type EvalContext } from './evaluator'
|
|
import { formatProblemDisplay, interpolateTemplate } from './formatting'
|
|
|
|
// =============================================================================
|
|
// Helpers
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Escape special regex characters in a string.
|
|
*/
|
|
function escapeRegex(str: string): string {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
}
|
|
|
|
// =============================================================================
|
|
// Re-exports for backwards compatibility
|
|
// =============================================================================
|
|
|
|
// Path Analysis
|
|
export type {
|
|
PathConstraint,
|
|
FlowchartPath,
|
|
FlowchartAnalysis,
|
|
} from './path-analysis'
|
|
export { enumerateAllPaths, analyzeFlowchart } from './path-analysis'
|
|
|
|
// Example Generation
|
|
export type {
|
|
GenerationConstraints,
|
|
GeneratedExample,
|
|
GenerationDiagnostics,
|
|
GenerationResult,
|
|
} from './example-generator'
|
|
export {
|
|
DEFAULT_CONSTRAINTS,
|
|
createSeededRandom,
|
|
generateDiverseExamples,
|
|
generateDiverseExamplesWithDiagnostics,
|
|
generateExamplesForPaths,
|
|
mergeAndFinalizeExamples,
|
|
} from './example-generator'
|
|
|
|
// Grid Dimensions
|
|
export type { GridDimensions } from './grid-dimensions'
|
|
export {
|
|
generatePathDescriptorFromPath,
|
|
inferGridDimensions,
|
|
inferGridDimensionsFromExamples,
|
|
} from './grid-dimensions'
|
|
|
|
// Formatting
|
|
export {
|
|
createMixedNumber,
|
|
formatMixedNumber,
|
|
formatProblemDisplay,
|
|
interpolateTemplate,
|
|
} from './formatting'
|
|
|
|
// =============================================================================
|
|
// Flowchart Loading
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Load and merge a flowchart definition with its Mermaid content.
|
|
*
|
|
* This is the main entry point for creating an executable flowchart.
|
|
* It combines:
|
|
* - **JSON definition** (`.flow.json`): Node types, validation logic, variables
|
|
* - **Mermaid content**: Node display content, phases, visual structure
|
|
*
|
|
* ## Node Merging
|
|
*
|
|
* For each node ID:
|
|
* 1. If in JSON definition: uses that node type/behavior
|
|
* 2. If only in Mermaid: creates default `instruction` node
|
|
* 3. Content always comes from Mermaid (parsed via `parseNodeContent`)
|
|
*
|
|
* ## Common Usage
|
|
*
|
|
* ```typescript
|
|
* import { getFlowchart } from './definitions'
|
|
* import { loadFlowchart } from './loader'
|
|
*
|
|
* const data = getFlowchart('fraction-add-sub')
|
|
* const flowchart = await loadFlowchart(data.definition, data.mermaid)
|
|
* ```
|
|
*
|
|
* @param definition - The JSON definition from `.flow.json`
|
|
* @param mermaidContent - The Mermaid content (from `.mmd` file or embedded string)
|
|
* @returns Promise resolving to executable flowchart ready for FlowchartWalker
|
|
*/
|
|
export async function loadFlowchart(
|
|
definition: FlowchartDefinition,
|
|
mermaidContent: string
|
|
): Promise<ExecutableFlowchart> {
|
|
// Parse the Mermaid file
|
|
const mermaid = parseMermaidFile(mermaidContent)
|
|
|
|
// Track edge ID infills so we can inject them into the raw mermaid string
|
|
const edgeIdInfills: Array<{ from: string; to: string; label?: string; edgeId: string }> = []
|
|
|
|
// Infill missing edge IDs for decision edges
|
|
// For each decision node, if the edge doesn't have an explicit ID (starts with "edge_"),
|
|
// assign the computed ID based on {nodeId}_{optionValue}
|
|
for (const [nodeId, nodeDef] of Object.entries(definition.nodes)) {
|
|
if (nodeDef.type !== 'decision') continue
|
|
|
|
for (const option of nodeDef.options) {
|
|
const expectedEdgeId = computeEdgeId(nodeId, option.value)
|
|
|
|
// Find the edge from this node to the option's next node
|
|
const edge = mermaid.edges.find((e) => e.from === nodeId && e.to === option.next)
|
|
|
|
if (edge && edge.id.startsWith('edge_')) {
|
|
// Edge exists but has auto-generated ID - replace with computed ID
|
|
edge.id = expectedEdgeId
|
|
// Track for injection into raw mermaid
|
|
edgeIdInfills.push({
|
|
from: nodeId,
|
|
to: option.next,
|
|
label: edge.label,
|
|
edgeId: expectedEdgeId,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Inject edge IDs into the raw mermaid string so SVG elements will have them
|
|
let modifiedMermaid = mermaidContent
|
|
for (const infill of edgeIdInfills) {
|
|
// Build regex to match this specific edge
|
|
// Pattern: FROM -->|"LABEL"| TO or FROM --> TO
|
|
const labelPart = infill.label
|
|
? `\\s*\\|"${escapeRegex(infill.label)}"\\|\\s*`
|
|
: '\\s*'
|
|
const edgeRegex = new RegExp(
|
|
`(${escapeRegex(infill.from)})\\s+-->${labelPart}(${escapeRegex(infill.to)})`,
|
|
'g'
|
|
)
|
|
// Replace with: FROM EDGEID@-->|"LABEL"| TO
|
|
const replacement = infill.label
|
|
? `$1 ${infill.edgeId}@-->|"${infill.label}"| $2`
|
|
: `$1 ${infill.edgeId}@--> $2`
|
|
modifiedMermaid = modifiedMermaid.replace(edgeRegex, replacement)
|
|
}
|
|
|
|
// Build executable nodes by merging definition with parsed content
|
|
const nodes: Record<string, ExecutableNode> = {}
|
|
|
|
// Track missing nodes to detect fundamental mismatches
|
|
const missingNodes: string[] = []
|
|
|
|
for (const [nodeId, nodeDef] of Object.entries(definition.nodes)) {
|
|
const rawContent = mermaid.nodes[nodeId]
|
|
if (!rawContent) {
|
|
missingNodes.push(nodeId)
|
|
console.warn(`Node ${nodeId} defined in .flow.json but not found in .mmd file`)
|
|
}
|
|
|
|
nodes[nodeId] = {
|
|
id: nodeId,
|
|
definition: nodeDef,
|
|
content: rawContent
|
|
? parseNodeContent(rawContent)
|
|
: {
|
|
title: nodeId,
|
|
body: [],
|
|
raw: nodeId,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Check for critical mismatches that will break rendering
|
|
const jsonNodeCount = Object.keys(definition.nodes).length
|
|
const missingRatio = missingNodes.length / jsonNodeCount
|
|
|
|
if (missingNodes.includes(definition.entryNode)) {
|
|
throw new Error(
|
|
`Entry node "${definition.entryNode}" is not defined in mermaid content. ` +
|
|
`The JSON and mermaid node IDs don't match. Mermaid has: ${Object.keys(mermaid.nodes).slice(0, 5).join(', ')}...`
|
|
)
|
|
}
|
|
|
|
if (missingRatio > 0.5) {
|
|
throw new Error(
|
|
`${missingNodes.length} of ${jsonNodeCount} nodes from JSON are missing in mermaid. ` +
|
|
`The JSON and mermaid node IDs don't match. ` +
|
|
`JSON expects: ${missingNodes.slice(0, 5).join(', ')}... ` +
|
|
`Mermaid has: ${Object.keys(mermaid.nodes).slice(0, 5).join(', ')}...`
|
|
)
|
|
}
|
|
|
|
// Also include nodes from Mermaid that aren't in the definition
|
|
// (they'll be treated as instruction nodes by default)
|
|
for (const [nodeId, rawContent] of Object.entries(mermaid.nodes)) {
|
|
if (!nodes[nodeId]) {
|
|
nodes[nodeId] = {
|
|
id: nodeId,
|
|
definition: { type: 'instruction', advance: 'tap' },
|
|
content: parseNodeContent(rawContent),
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
definition,
|
|
mermaid,
|
|
rawMermaid: modifiedMermaid,
|
|
nodes,
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// State Initialization
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Initialize flowchart state from problem input.
|
|
*
|
|
* In the new transform model:
|
|
* - `values` starts with problem input and accumulates transform results
|
|
* - `computed` is populated from legacy `variables` section (for backwards compatibility)
|
|
* - `snapshots` tracks state at each node for trace visualization
|
|
*
|
|
* @param flowchart - The executable flowchart
|
|
* @param problemInput - User's problem input values
|
|
* @returns Initial state ready for walking
|
|
*/
|
|
export function initializeState(
|
|
flowchart: ExecutableFlowchart,
|
|
problemInput: Record<string, ProblemValue>
|
|
): FlowchartState {
|
|
// Start values with problem input (new transform model)
|
|
const values: Record<string, ProblemValue> = { ...problemInput }
|
|
|
|
// Create evaluation context with problem values
|
|
const context: EvalContext = {
|
|
problem: problemInput,
|
|
computed: {},
|
|
userState: {},
|
|
}
|
|
|
|
// LEGACY: Evaluate all variable init expressions for backwards compatibility
|
|
// This will be removed when all flowcharts migrate to transforms
|
|
const computed: Record<string, ProblemValue> = {}
|
|
const variables = flowchart.definition.variables || {}
|
|
for (const [varName, varDef] of Object.entries(variables)) {
|
|
try {
|
|
computed[varName] = evaluate(varDef.init, context)
|
|
// Update context so subsequent variables can reference earlier ones
|
|
context.computed[varName] = computed[varName]
|
|
// Also add to values for hybrid mode
|
|
values[varName] = computed[varName]
|
|
} catch (error) {
|
|
console.error(`Error evaluating init for variable ${varName}:`, error)
|
|
computed[varName] = null as unknown as ProblemValue
|
|
}
|
|
}
|
|
|
|
// Initialize working problem if configured
|
|
let workingProblem: string | undefined
|
|
const workingProblemHistory: WorkingProblemHistoryEntry[] = []
|
|
|
|
if (flowchart.definition.workingProblem) {
|
|
const wpContext: EvalContext = {
|
|
problem: problemInput,
|
|
computed,
|
|
userState: {},
|
|
}
|
|
try {
|
|
workingProblem = String(evaluate(flowchart.definition.workingProblem.initial, wpContext))
|
|
// Add initial state to history
|
|
workingProblemHistory.push({
|
|
value: workingProblem,
|
|
label: 'Start',
|
|
nodeId: 'initial',
|
|
})
|
|
} catch (error) {
|
|
console.error('Error evaluating initial working problem:', error)
|
|
}
|
|
}
|
|
|
|
// Create initial snapshot
|
|
const initialSnapshot: StateSnapshot = {
|
|
nodeId: 'initial',
|
|
nodeTitle: 'Start',
|
|
values: { ...values },
|
|
transforms: [],
|
|
workingProblem,
|
|
timestamp: Date.now(),
|
|
}
|
|
|
|
return {
|
|
problem: problemInput,
|
|
computed, // Legacy - for backwards compatibility
|
|
values, // New - accumulates transform results
|
|
userState: {},
|
|
currentNode: flowchart.definition.entryNode,
|
|
history: [],
|
|
startTime: Date.now(),
|
|
mistakes: 0,
|
|
workingProblem,
|
|
workingProblemHistory,
|
|
snapshots: [initialSnapshot],
|
|
hasError: false,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create evaluation context from current state.
|
|
*
|
|
* Uses `values` (accumulated transforms) as `computed` for the evaluator.
|
|
* Falls back to legacy `computed` for backwards compatibility.
|
|
*/
|
|
export function createContextFromState(state: FlowchartState): EvalContext {
|
|
// Prefer values (new model) over computed (legacy)
|
|
// Merge both so expressions can reference either
|
|
const computed = { ...state.computed, ...state.values }
|
|
return {
|
|
problem: state.problem,
|
|
computed,
|
|
userState: state.userState,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create evaluation context from accumulated values (for transforms).
|
|
*/
|
|
export function createContextFromValues(
|
|
problem: Record<string, ProblemValue>,
|
|
values: Record<string, ProblemValue>,
|
|
userState: Record<string, ProblemValue>
|
|
): EvalContext {
|
|
return {
|
|
problem,
|
|
computed: values, // In transform model, accumulated values are the "computed"
|
|
userState,
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Transform System (new unified computation model)
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Apply node transforms to state.
|
|
*
|
|
* Transforms execute in order - later transforms can reference earlier ones.
|
|
* Results accumulate in state.values. Errors are logged but don't stop the walk.
|
|
*
|
|
* @param state - Current flowchart state
|
|
* @param nodeId - ID of the node whose transforms to apply
|
|
* @param flowchart - The executable flowchart
|
|
* @returns Updated state with transforms applied and new snapshot added
|
|
*/
|
|
export function applyTransforms(
|
|
state: FlowchartState,
|
|
nodeId: string,
|
|
flowchart: ExecutableFlowchart
|
|
): FlowchartState {
|
|
const node = flowchart.nodes[nodeId]
|
|
if (!node) return state
|
|
|
|
const transforms = node.definition.transform || []
|
|
|
|
// Apply transforms in order
|
|
const newValues = { ...state.values }
|
|
let hasError = state.hasError
|
|
const appliedTransforms: TransformExpression[] = []
|
|
|
|
for (const transform of transforms) {
|
|
try {
|
|
const context = createContextFromValues(state.problem, newValues, state.userState)
|
|
const result = evaluate(transform.expr, context)
|
|
newValues[transform.key] = result
|
|
appliedTransforms.push(transform)
|
|
} catch (error) {
|
|
console.error(`Transform error at ${nodeId}.${transform.key}:`, error)
|
|
newValues[transform.key] = null as unknown as ProblemValue
|
|
hasError = true
|
|
}
|
|
}
|
|
|
|
// Check for workingProblemUpdate on this node
|
|
let newWorkingProblem = state.workingProblem
|
|
let newWorkingProblemHistory = state.workingProblemHistory
|
|
const def = node.definition
|
|
|
|
let workingProblemUpdate: { result: string; label: string } | undefined
|
|
if (def.type === 'checkpoint') {
|
|
workingProblemUpdate = (def as CheckpointNode).workingProblemUpdate
|
|
} else if (def.type === 'instruction') {
|
|
workingProblemUpdate = (def as InstructionNode).workingProblemUpdate
|
|
}
|
|
|
|
if (workingProblemUpdate) {
|
|
try {
|
|
const context = createContextFromValues(state.problem, newValues, state.userState)
|
|
newWorkingProblem = String(evaluate(workingProblemUpdate.result, context))
|
|
newWorkingProblemHistory = [
|
|
...state.workingProblemHistory,
|
|
{
|
|
value: newWorkingProblem,
|
|
label: workingProblemUpdate.label,
|
|
nodeId,
|
|
},
|
|
]
|
|
} catch (error) {
|
|
console.error(`Working problem update error at ${nodeId}:`, error)
|
|
}
|
|
}
|
|
|
|
// Create snapshot after applying transforms (with updated working problem)
|
|
const snapshot: StateSnapshot = {
|
|
nodeId,
|
|
nodeTitle: node.content?.title || nodeId,
|
|
values: { ...newValues },
|
|
transforms: appliedTransforms,
|
|
workingProblem: newWorkingProblem,
|
|
timestamp: Date.now(),
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
values: newValues,
|
|
computed: { ...state.computed, ...newValues }, // Keep computed in sync for backwards compat
|
|
hasError,
|
|
workingProblem: newWorkingProblem,
|
|
workingProblemHistory: newWorkingProblemHistory,
|
|
snapshots: [...state.snapshots, snapshot],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract the final answer from terminal state.
|
|
*
|
|
* Uses the flowchart's `answer` definition to:
|
|
* 1. Extract answer component values from accumulated state
|
|
* 2. Interpolate display templates (text, web, typst)
|
|
*
|
|
* Falls back to legacy `display.answer` if no `answer` definition exists.
|
|
*
|
|
* @param flowchart - The executable flowchart
|
|
* @param state - Terminal state with all transforms applied
|
|
* @returns Extracted answer values and formatted display strings
|
|
*/
|
|
export function extractAnswer(
|
|
flowchart: ExecutableFlowchart,
|
|
state: FlowchartState
|
|
): {
|
|
values: Record<string, ProblemValue>
|
|
display: { text: string; web: string; typst: string }
|
|
} {
|
|
const answerDef = flowchart.definition.answer
|
|
|
|
// If we have a new-style answer definition, use it
|
|
if (answerDef) {
|
|
const values: Record<string, ProblemValue> = {}
|
|
|
|
// Extract each answer component from accumulated state
|
|
for (const [name, ref] of Object.entries(answerDef.values)) {
|
|
values[name] = state.values[ref] ?? null
|
|
}
|
|
|
|
// Interpolate display templates
|
|
const allValues = { ...state.values, ...values }
|
|
return {
|
|
values,
|
|
display: {
|
|
text: interpolateTemplate(answerDef.display.text, allValues),
|
|
web: interpolateTemplate(answerDef.display.web || answerDef.display.text, allValues),
|
|
typst: interpolateTemplate(answerDef.display.typst || answerDef.display.text, allValues),
|
|
},
|
|
}
|
|
}
|
|
|
|
// LEGACY: Fall back to display.answer expression
|
|
if (flowchart.definition.display?.answer) {
|
|
try {
|
|
const context = createContextFromState(state)
|
|
const result = evaluate(flowchart.definition.display.answer, context)
|
|
const displayStr = String(result)
|
|
return {
|
|
values: { answer: result },
|
|
display: { text: displayStr, web: displayStr, typst: displayStr },
|
|
}
|
|
} catch (error) {
|
|
console.error('Error evaluating legacy display.answer:', error)
|
|
}
|
|
}
|
|
|
|
// No answer definition - return empty
|
|
return {
|
|
values: {},
|
|
display: { text: '', web: '', typst: '' },
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Simulate walking through a flowchart to terminal state.
|
|
*
|
|
* This is THE canonical function for computing answers. All other code paths
|
|
* (worksheets, tests, example generation) should use this function.
|
|
*
|
|
* The walk:
|
|
* 1. Starts with problem input in state.values
|
|
* 2. Visits each node, applying transforms
|
|
* 3. Makes decisions based on accumulated state
|
|
* 4. Ends at terminal node with final answer in state.values
|
|
*
|
|
* @param flowchart - The executable flowchart
|
|
* @param input - Problem input values
|
|
* @returns Terminal state with all transforms applied and full snapshot history
|
|
*/
|
|
export function simulateWalk(
|
|
flowchart: ExecutableFlowchart,
|
|
input: Record<string, ProblemValue>
|
|
): FlowchartState {
|
|
let state = initializeState(flowchart, input)
|
|
const maxSteps = 100 // Safety limit to prevent infinite loops
|
|
const visited = new Set<string>()
|
|
|
|
for (let step = 0; step < maxSteps; step++) {
|
|
const nodeId = state.currentNode
|
|
if (!nodeId || visited.has(nodeId)) break
|
|
visited.add(nodeId)
|
|
|
|
const node = flowchart.nodes[nodeId]
|
|
if (!node) break
|
|
|
|
// Apply transforms for this node
|
|
state = applyTransforms(state, nodeId, flowchart)
|
|
|
|
// Check if terminal
|
|
if (node.definition.type === 'terminal') break
|
|
|
|
// Determine next node based on node type and accumulated state
|
|
const { nextNodeId, selectedOptionValue, edgeId, edgeIndex } = getNextNodeForSimulation(flowchart, state, nodeId)
|
|
if (!nextNodeId) break
|
|
|
|
// Update the last snapshot with decision/transition info
|
|
if (state.snapshots.length > 0) {
|
|
const lastSnapshot = state.snapshots[state.snapshots.length - 1]
|
|
const updatedSnapshot = {
|
|
...lastSnapshot,
|
|
// Include decision info if this was a decision node
|
|
...(selectedOptionValue ? { selectedOptionValue } : {}),
|
|
nextNodeId,
|
|
// Include both edge ID and index for reliable matching
|
|
edgeId,
|
|
edgeIndex,
|
|
}
|
|
state = {
|
|
...state,
|
|
snapshots: [...state.snapshots.slice(0, -1), updatedSnapshot],
|
|
}
|
|
}
|
|
|
|
state = { ...state, currentNode: nextNodeId }
|
|
}
|
|
|
|
return state
|
|
}
|
|
|
|
/**
|
|
* Result of determining next node during simulation.
|
|
*/
|
|
interface SimulationNextResult {
|
|
nextNodeId: string | null
|
|
/** For decision nodes: the option value that was selected (e.g., "MD" for multiply/divide) */
|
|
selectedOptionValue?: string
|
|
/** The unique ID of the edge taken (from ParsedEdge.id) for reliable edge matching */
|
|
edgeId?: string
|
|
/** The index of the edge in parse order (from ParsedEdge.index), for fallback matching */
|
|
edgeIndex?: number
|
|
}
|
|
|
|
/**
|
|
* Get next node during simulation (without user input).
|
|
* Used by simulateWalk to determine path based on expressions.
|
|
*/
|
|
function getNextNodeForSimulation(
|
|
flowchart: ExecutableFlowchart,
|
|
state: FlowchartState,
|
|
nodeId: string
|
|
): SimulationNextResult {
|
|
const node = flowchart.nodes[nodeId]
|
|
if (!node) return { nextNodeId: null }
|
|
|
|
const def = node.definition
|
|
const context = createContextFromState(state)
|
|
|
|
/**
|
|
* Find an edge by ID (preferred) or by from/to/label (fallback).
|
|
*
|
|
* Priority:
|
|
* 1. If edgeId is provided, match by ID directly (canonical method)
|
|
* 2. If from/to match a single edge, use that
|
|
* 3. If multiple edges exist between from/to, try to match by label
|
|
* 4. Fallback to first matching edge
|
|
*/
|
|
const findEdge = (from: string, to: string, edgeId?: string, edgeLabel?: string) => {
|
|
// Priority 1: Match by edge ID (canonical)
|
|
if (edgeId) {
|
|
const edgeById = flowchart.mermaid.edges.find((e) => e.id === edgeId)
|
|
if (edgeById) return edgeById
|
|
// If specified edgeId doesn't exist, fall through to from/to matching
|
|
}
|
|
|
|
// Priority 2+: Match by from/to
|
|
const matchingEdges = flowchart.mermaid.edges.filter((e) => e.from === from && e.to === to)
|
|
if (matchingEdges.length <= 1) return matchingEdges[0]
|
|
|
|
// Priority 3: Multiple edges - try to match by label
|
|
if (edgeLabel) {
|
|
const labelMatch = matchingEdges.find((e) => e.label === edgeLabel)
|
|
if (labelMatch) return labelMatch
|
|
}
|
|
|
|
// Priority 4: Fallback to first edge
|
|
return matchingEdges[0]
|
|
}
|
|
|
|
// Helper to build result with edge info
|
|
const buildResult = (
|
|
nextNodeId: string | null,
|
|
selectedOptionValue?: string,
|
|
explicitEdgeId?: string
|
|
): SimulationNextResult => {
|
|
if (!nextNodeId) return { nextNodeId: null }
|
|
const edge = findEdge(nodeId, nextNodeId, explicitEdgeId, selectedOptionValue)
|
|
return {
|
|
nextNodeId,
|
|
selectedOptionValue,
|
|
edgeId: edge?.id,
|
|
edgeIndex: edge?.index,
|
|
}
|
|
}
|
|
|
|
switch (def.type) {
|
|
case 'terminal':
|
|
return { nextNodeId: null }
|
|
|
|
case 'decision': {
|
|
// Check for skip condition
|
|
if (def.skipIf && def.skipTo) {
|
|
try {
|
|
if (evaluate(def.skipIf, context)) {
|
|
return buildResult(def.skipTo, '__skip__')
|
|
}
|
|
} catch {
|
|
// Continue to normal decision handling
|
|
}
|
|
}
|
|
|
|
// Determine path based on correctAnswer expression
|
|
// Use pathLabel for edge label matching (fallback), value for edge ID computation
|
|
const getEdgeLabel = (opt: typeof def.options[0]) => opt.pathLabel || opt.value
|
|
|
|
// Helper to build result from an option
|
|
// Edge ID is computed as {nodeId}_{optionValue} - mermaid must use this pattern
|
|
const buildFromOption = (opt: typeof def.options[0] | undefined) => {
|
|
if (!opt) return buildResult(null)
|
|
const edgeId = computeEdgeId(nodeId, opt.value)
|
|
return buildResult(opt.next, getEdgeLabel(opt), edgeId)
|
|
}
|
|
|
|
if (def.correctAnswer) {
|
|
try {
|
|
const correct = evaluate(def.correctAnswer, context)
|
|
if (typeof correct === 'boolean') {
|
|
const yesOption = def.options.find(
|
|
(o) => o.value === 'yes' || o.value.toLowerCase().includes('yes')
|
|
)
|
|
const noOption = def.options.find(
|
|
(o) => o.value === 'no' || o.value.toLowerCase().includes('no')
|
|
)
|
|
if (correct && yesOption) return buildFromOption(yesOption)
|
|
if (!correct && noOption) return buildFromOption(noOption)
|
|
// Fallback to index-based
|
|
return buildFromOption(correct ? def.options[0] : def.options[1])
|
|
}
|
|
// String match
|
|
const option = def.options.find((o) => o.value === String(correct))
|
|
if (option) return buildFromOption(option)
|
|
return buildFromOption(def.options[0])
|
|
} catch {
|
|
return buildFromOption(def.options[0])
|
|
}
|
|
}
|
|
return buildFromOption(def.options[0])
|
|
}
|
|
|
|
case 'checkpoint': {
|
|
// Check for skip condition
|
|
if (def.skipIf && def.skipTo) {
|
|
try {
|
|
if (evaluate(def.skipIf, context)) {
|
|
return buildResult(def.skipTo)
|
|
}
|
|
} catch {
|
|
// Continue to normal handling
|
|
}
|
|
}
|
|
// Fall through to instruction logic
|
|
}
|
|
// biome-ignore lint/suspicious/noFallthroughSwitchClause: Intentional fallthrough
|
|
case 'instruction': {
|
|
if (def.next) return buildResult(def.next)
|
|
const edges = flowchart.definition.edges?.[nodeId]
|
|
if (edges && edges.length > 0) return buildResult(edges[0])
|
|
const mermaidEdges = flowchart.mermaid.edges.filter((e) => e.from === nodeId)
|
|
return buildResult(mermaidEdges[0]?.to ?? null)
|
|
}
|
|
|
|
case 'milestone':
|
|
case 'embellishment':
|
|
return buildResult(def.next)
|
|
|
|
default:
|
|
return { nextNodeId: null }
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Navigation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Get the next node ID based on current state and user action
|
|
*/
|
|
export function getNextNode(
|
|
flowchart: ExecutableFlowchart,
|
|
state: FlowchartState,
|
|
userChoice?: string
|
|
): string | null {
|
|
const currentNode = flowchart.nodes[state.currentNode]
|
|
if (!currentNode) return null
|
|
|
|
const def = currentNode.definition
|
|
|
|
// Handle based on node type
|
|
switch (def.type) {
|
|
case 'instruction': {
|
|
// Check for explicit next in definition
|
|
if (def.next) return def.next
|
|
// Fall back to edges from Mermaid
|
|
const edges = flowchart.definition.edges?.[state.currentNode]
|
|
if (edges && edges.length > 0) return edges[0]
|
|
// Or from parsed Mermaid
|
|
const mermaidEdges = flowchart.mermaid.edges.filter((e) => e.from === state.currentNode)
|
|
if (mermaidEdges.length > 0) return mermaidEdges[0].to
|
|
return null
|
|
}
|
|
|
|
case 'decision': {
|
|
// Check for skip condition
|
|
if (def.skipIf && def.skipTo) {
|
|
const context = createContextFromState(state)
|
|
try {
|
|
if (evaluate(def.skipIf, context)) {
|
|
return def.skipTo
|
|
}
|
|
} catch (error) {
|
|
console.error('Error evaluating skipIf:', error)
|
|
}
|
|
}
|
|
|
|
// User must have made a choice
|
|
if (!userChoice) return null
|
|
const option = def.options.find((o) => o.value === userChoice)
|
|
return option?.next ?? null
|
|
}
|
|
|
|
case 'checkpoint': {
|
|
// Check for explicit next
|
|
if (def.next) return def.next
|
|
// Fall back to edges
|
|
const edges = flowchart.definition.edges?.[state.currentNode]
|
|
if (edges && edges.length > 0) return edges[0]
|
|
const mermaidEdges = flowchart.mermaid.edges.filter((e) => e.from === state.currentNode)
|
|
if (mermaidEdges.length > 0) return mermaidEdges[0].to
|
|
return null
|
|
}
|
|
|
|
case 'milestone': {
|
|
return def.next
|
|
}
|
|
|
|
case 'embellishment': {
|
|
return def.next
|
|
}
|
|
|
|
case 'terminal': {
|
|
return null // End of flowchart
|
|
}
|
|
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a decision answer is correct
|
|
*/
|
|
export function isDecisionCorrect(
|
|
flowchart: ExecutableFlowchart,
|
|
state: FlowchartState,
|
|
nodeId: string,
|
|
userChoice: string
|
|
): boolean | null {
|
|
const node = flowchart.nodes[nodeId]
|
|
if (!node || node.definition.type !== 'decision') return null
|
|
|
|
const def = node.definition
|
|
if (!def.correctAnswer) return null // No validation defined
|
|
|
|
const context = createContextFromState(state)
|
|
try {
|
|
const correct = evaluate(def.correctAnswer, context)
|
|
// correctAnswer is an expression that evaluates to true/false
|
|
// If true, the "yes" option (or first option) is correct
|
|
// If false, the "no" option (or second option) is correct
|
|
if (typeof correct === 'boolean') {
|
|
// Find which option corresponds to the correct answer
|
|
const yesOption = def.options.find(
|
|
(o) => o.value === 'yes' || o.value.toLowerCase().includes('yes')
|
|
)
|
|
const noOption = def.options.find(
|
|
(o) => o.value === 'no' || o.value.toLowerCase().includes('no')
|
|
)
|
|
|
|
// If we found yes/no options, use them
|
|
if (yesOption || noOption) {
|
|
if (correct) {
|
|
return userChoice === yesOption?.value
|
|
} else {
|
|
return userChoice === noOption?.value
|
|
}
|
|
}
|
|
|
|
// Fallback: true = first option, false = second option
|
|
const correctOption = correct ? def.options[0] : def.options[1]
|
|
return userChoice === correctOption?.value
|
|
}
|
|
// If correctAnswer evaluates to a string, compare directly
|
|
return String(correct) === userChoice
|
|
} catch (error) {
|
|
console.error('Error evaluating correctAnswer:', error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate a checkpoint answer
|
|
*/
|
|
export function validateCheckpoint(
|
|
flowchart: ExecutableFlowchart,
|
|
state: FlowchartState,
|
|
nodeId: string,
|
|
userInput: number | string | [number, number]
|
|
): { correct: boolean; expected: ProblemValue | [number, number] } | null {
|
|
const node = flowchart.nodes[nodeId]
|
|
if (!node || node.definition.type !== 'checkpoint') return null
|
|
|
|
const def = node.definition
|
|
|
|
try {
|
|
// Handle two-numbers input type with array of expected expressions
|
|
if (def.inputType === 'two-numbers' && Array.isArray(def.expected)) {
|
|
// For two-numbers, we don't use `input` in expressions - we validate against problem values
|
|
const context: EvalContext = createContextFromState(state)
|
|
const expected1 = evaluate(def.expected[0], context) as number
|
|
const expected2 = evaluate(def.expected[1], context) as number
|
|
const expectedArray: [number, number] = [expected1, expected2]
|
|
|
|
if (!Array.isArray(userInput)) {
|
|
return { correct: false, expected: expectedArray }
|
|
}
|
|
|
|
const tolerance = def.tolerance ?? 0
|
|
const orderMatters = def.orderMatters !== false // default true
|
|
|
|
// Check if input matches expected (in order)
|
|
const matchesInOrder =
|
|
Math.abs(expected1 - userInput[0]) <= tolerance &&
|
|
Math.abs(expected2 - userInput[1]) <= tolerance
|
|
|
|
// If order doesn't matter, also check reversed
|
|
const matchesReversed =
|
|
!orderMatters &&
|
|
Math.abs(expected1 - userInput[1]) <= tolerance &&
|
|
Math.abs(expected2 - userInput[0]) <= tolerance
|
|
|
|
const correct = matchesInOrder || matchesReversed
|
|
|
|
return { correct, expected: expectedArray }
|
|
}
|
|
|
|
// Original single-value validation
|
|
const context: EvalContext = {
|
|
...createContextFromState(state),
|
|
input: userInput as ProblemValue,
|
|
}
|
|
const expected = evaluate(def.expected as string, context)
|
|
const tolerance = def.tolerance ?? 0
|
|
|
|
let correct: boolean
|
|
if (typeof expected === 'number' && typeof userInput === 'number') {
|
|
correct = Math.abs(expected - userInput) <= tolerance
|
|
} else {
|
|
correct = expected === userInput
|
|
}
|
|
|
|
return { correct, expected }
|
|
} catch (error) {
|
|
console.error('Error evaluating checkpoint expected value:', error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply state update after passing a checkpoint
|
|
*/
|
|
export function applyStateUpdate(
|
|
state: FlowchartState,
|
|
nodeId: string,
|
|
flowchart: ExecutableFlowchart,
|
|
userInput: ProblemValue
|
|
): FlowchartState {
|
|
const node = flowchart.nodes[nodeId]
|
|
if (!node || node.definition.type !== 'checkpoint') return state
|
|
|
|
const def = node.definition
|
|
if (!def.stateUpdate) return state
|
|
|
|
const newUserState = { ...state.userState }
|
|
const context: EvalContext = {
|
|
...createContextFromState(state),
|
|
input: userInput,
|
|
}
|
|
|
|
for (const [varName, expr] of Object.entries(def.stateUpdate)) {
|
|
try {
|
|
newUserState[varName] = evaluate(expr, context)
|
|
} catch (error) {
|
|
console.error(`Error evaluating stateUpdate for ${varName}:`, error)
|
|
}
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
userState: newUserState,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply working problem transform after successfully completing a node.
|
|
* Returns updated state with new working problem value and history entry.
|
|
*/
|
|
export function applyWorkingProblemUpdate(
|
|
state: FlowchartState,
|
|
nodeId: string,
|
|
flowchart: ExecutableFlowchart,
|
|
userInput?: ProblemValue
|
|
): FlowchartState {
|
|
const node = flowchart.nodes[nodeId]
|
|
if (!node) return state
|
|
|
|
// Check if this node has a working problem update
|
|
const def = node.definition
|
|
let workingProblemUpdate: { result: string; label: string } | undefined
|
|
|
|
if (def.type === 'checkpoint') {
|
|
workingProblemUpdate = (def as CheckpointNode).workingProblemUpdate
|
|
} else if (def.type === 'instruction') {
|
|
workingProblemUpdate = (def as InstructionNode).workingProblemUpdate
|
|
}
|
|
|
|
if (!workingProblemUpdate) return state
|
|
|
|
const context: EvalContext = {
|
|
...createContextFromState(state),
|
|
input: userInput,
|
|
}
|
|
|
|
try {
|
|
const newWorkingProblem = String(evaluate(workingProblemUpdate.result, context))
|
|
return {
|
|
...state,
|
|
workingProblem: newWorkingProblem,
|
|
workingProblemHistory: [
|
|
...state.workingProblemHistory,
|
|
{
|
|
value: newWorkingProblem,
|
|
label: workingProblemUpdate.label,
|
|
nodeId,
|
|
},
|
|
],
|
|
}
|
|
} catch (error) {
|
|
console.error('Error applying working problem update:', error)
|
|
return state
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Progress Tracking
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Advance to the next node and record history
|
|
*/
|
|
export function advanceState(
|
|
state: FlowchartState,
|
|
nextNode: string,
|
|
action: 'advance' | 'decision' | 'checkpoint' | 'skip',
|
|
userInput?: ProblemValue,
|
|
correct?: boolean
|
|
): FlowchartState {
|
|
return {
|
|
...state,
|
|
currentNode: nextNode,
|
|
history: [
|
|
...state.history,
|
|
{
|
|
node: state.currentNode,
|
|
action,
|
|
timestamp: Date.now(),
|
|
userInput,
|
|
correct,
|
|
},
|
|
],
|
|
mistakes: correct === false ? state.mistakes + 1 : state.mistakes,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the current node is a terminal node
|
|
*/
|
|
export function isTerminal(flowchart: ExecutableFlowchart, nodeId: string): boolean {
|
|
const node = flowchart.nodes[nodeId]
|
|
return node?.definition.type === 'terminal'
|
|
}
|
|
|
|
// =============================================================================
|
|
// Path Complexity Analysis
|
|
// =============================================================================
|
|
|
|
export interface PathComplexity {
|
|
/** Total number of nodes in the path */
|
|
pathLength: number
|
|
/** Number of decision points in the path */
|
|
decisions: number
|
|
/** Number of checkpoints (user input required) */
|
|
checkpoints: number
|
|
/** List of node IDs in the path */
|
|
path: string[]
|
|
}
|
|
|
|
/**
|
|
* Simulate walking through a flowchart with given problem values to calculate path complexity.
|
|
* Returns the path length, number of decisions, and checkpoints.
|
|
*/
|
|
export function calculatePathComplexity(
|
|
flowchart: ExecutableFlowchart,
|
|
problemInput: Record<string, ProblemValue>
|
|
): PathComplexity {
|
|
// Initialize a temporary state to simulate the walk
|
|
const state = initializeState(flowchart, problemInput)
|
|
const context = createContextFromState(state)
|
|
|
|
const path: string[] = []
|
|
let decisions = 0
|
|
let checkpoints = 0
|
|
let currentNodeId = flowchart.definition.entryNode
|
|
const visited = new Set<string>()
|
|
|
|
// Walk the flowchart until we hit a terminal or loop
|
|
while (currentNodeId && !visited.has(currentNodeId)) {
|
|
visited.add(currentNodeId)
|
|
path.push(currentNodeId)
|
|
|
|
const node = flowchart.nodes[currentNodeId]
|
|
if (!node) break
|
|
|
|
const def = node.definition
|
|
|
|
switch (def.type) {
|
|
case 'terminal':
|
|
// End of path
|
|
return { pathLength: path.length, decisions, checkpoints, path }
|
|
|
|
case 'decision': {
|
|
// Check for skip condition first
|
|
if (def.skipIf && def.skipTo) {
|
|
try {
|
|
const shouldSkip = evaluate(def.skipIf, context)
|
|
if (shouldSkip) {
|
|
// Skip this decision entirely - don't count it
|
|
currentNodeId = def.skipTo
|
|
break
|
|
}
|
|
} catch {
|
|
// If skipIf evaluation fails, proceed to normal decision handling
|
|
}
|
|
}
|
|
|
|
decisions++
|
|
// Determine which path would be taken based on correctAnswer
|
|
if (def.correctAnswer) {
|
|
try {
|
|
const correct = evaluate(def.correctAnswer, context)
|
|
// Find the correct option
|
|
if (typeof correct === 'boolean') {
|
|
const yesOption = def.options.find(
|
|
(o) => o.value === 'yes' || o.value.toLowerCase().includes('yes')
|
|
)
|
|
const noOption = def.options.find(
|
|
(o) => o.value === 'no' || o.value.toLowerCase().includes('no')
|
|
)
|
|
|
|
if (correct && yesOption) {
|
|
currentNodeId = yesOption.next
|
|
} else if (!correct && noOption) {
|
|
currentNodeId = noOption.next
|
|
} else {
|
|
// Fallback to first/second option
|
|
currentNodeId = correct ? def.options[0]?.next : def.options[1]?.next
|
|
}
|
|
} else {
|
|
// correctAnswer is a specific value
|
|
const option = def.options.find((o) => o.value === String(correct))
|
|
currentNodeId = option?.next ?? def.options[0]?.next
|
|
}
|
|
} catch {
|
|
// If evaluation fails, take first option
|
|
currentNodeId = def.options[0]?.next
|
|
}
|
|
} else {
|
|
// No correctAnswer, take first option
|
|
currentNodeId = def.options[0]?.next
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'checkpoint': {
|
|
// Check for skip condition first
|
|
if (def.skipIf && def.skipTo) {
|
|
try {
|
|
const shouldSkip = evaluate(def.skipIf, context)
|
|
if (shouldSkip) {
|
|
// Skip this checkpoint entirely - don't count it
|
|
currentNodeId = def.skipTo
|
|
break
|
|
}
|
|
} catch {
|
|
// If skipIf evaluation fails, proceed to normal checkpoint handling
|
|
}
|
|
}
|
|
checkpoints++
|
|
// Fall through to instruction logic for next node
|
|
}
|
|
// biome-ignore lint/suspicious/noFallthroughSwitchClause: Intentional fallthrough to share instruction logic
|
|
case 'instruction': {
|
|
if (def.next) {
|
|
currentNodeId = def.next
|
|
} else {
|
|
const edges = flowchart.definition.edges?.[currentNodeId]
|
|
if (edges && edges.length > 0) {
|
|
currentNodeId = edges[0]
|
|
} else {
|
|
const mermaidEdges = flowchart.mermaid.edges.filter((e) => e.from === currentNodeId)
|
|
currentNodeId = mermaidEdges[0]?.to
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'milestone':
|
|
case 'embellishment':
|
|
currentNodeId = def.next
|
|
break
|
|
|
|
default:
|
|
currentNodeId = undefined as unknown as string
|
|
}
|
|
}
|
|
|
|
return { pathLength: path.length, decisions, checkpoints, path }
|
|
}
|