prolog-trace-viz 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +148 -0
  2. package/dist/analyzer.d.ts +64 -0
  3. package/dist/analyzer.d.ts.map +1 -0
  4. package/dist/analyzer.js +903 -0
  5. package/dist/analyzer.js.map +1 -0
  6. package/dist/build-info.d.ts +15 -0
  7. package/dist/build-info.d.ts.map +1 -0
  8. package/dist/build-info.js +26 -0
  9. package/dist/build-info.js.map +1 -0
  10. package/dist/clauses.d.ts +15 -0
  11. package/dist/clauses.d.ts.map +1 -0
  12. package/dist/clauses.js +80 -0
  13. package/dist/clauses.js.map +1 -0
  14. package/dist/cli.d.ts +34 -0
  15. package/dist/cli.d.ts.map +1 -0
  16. package/dist/cli.js +162 -0
  17. package/dist/cli.js.map +1 -0
  18. package/dist/errors.d.ts +19 -0
  19. package/dist/errors.d.ts.map +1 -0
  20. package/dist/errors.js +66 -0
  21. package/dist/errors.js.map +1 -0
  22. package/dist/executor.d.ts +25 -0
  23. package/dist/executor.d.ts.map +1 -0
  24. package/dist/executor.js +115 -0
  25. package/dist/executor.js.map +1 -0
  26. package/dist/index.d.ts +3 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +128 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/mermaid.d.ts +47 -0
  31. package/dist/mermaid.d.ts.map +1 -0
  32. package/dist/mermaid.js +197 -0
  33. package/dist/mermaid.js.map +1 -0
  34. package/dist/output.d.ts +24 -0
  35. package/dist/output.d.ts.map +1 -0
  36. package/dist/output.js +40 -0
  37. package/dist/output.js.map +1 -0
  38. package/dist/parser.d.ts +84 -0
  39. package/dist/parser.d.ts.map +1 -0
  40. package/dist/parser.js +1007 -0
  41. package/dist/parser.js.map +1 -0
  42. package/dist/prolog-parser.d.ts +2 -0
  43. package/dist/prolog-parser.d.ts.map +1 -0
  44. package/dist/prolog-parser.js +2 -0
  45. package/dist/prolog-parser.js.map +1 -0
  46. package/dist/renderer.d.ts +33 -0
  47. package/dist/renderer.d.ts.map +1 -0
  48. package/dist/renderer.js +131 -0
  49. package/dist/renderer.js.map +1 -0
  50. package/dist/wrapper.d.ts +30 -0
  51. package/dist/wrapper.d.ts.map +1 -0
  52. package/dist/wrapper.js +87 -0
  53. package/dist/wrapper.js.map +1 -0
  54. package/package.json +55 -0
  55. package/scripts/generate-build-info.js +63 -0
  56. package/scripts/release.js +151 -0
  57. package/tracer.pl +202 -0
@@ -0,0 +1,903 @@
1
+ /**
2
+ * Extracts clause definitions from raw trace events.
3
+ * This uses the actual clauses that SWI-Prolog worked with during execution.
4
+ */
5
+ function extractClausesFromTraceEvents(traceEvents) {
6
+ const clauseMap = new Map();
7
+ traceEvents.forEach(event => {
8
+ if (event.clause && event.port === 'exit') {
9
+ const { head, body, line } = event.clause;
10
+ const clauseKey = `${line}-${head}`;
11
+ if (!clauseMap.has(clauseKey)) {
12
+ const clauseText = body && body !== 'true'
13
+ ? `${head} :- ${body}`
14
+ : head;
15
+ clauseMap.set(clauseKey, {
16
+ number: line,
17
+ head,
18
+ body: body !== 'true' ? body : undefined,
19
+ text: clauseText
20
+ });
21
+ }
22
+ }
23
+ });
24
+ // Convert to array and sort by line number
25
+ return Array.from(clauseMap.values()).sort((a, b) => a.number - b.number);
26
+ }
27
+ const EMOJIS = {
28
+ query: '🎯',
29
+ solving: 'πŸ”„',
30
+ pending: '⏸️',
31
+ solved: 'βœ…',
32
+ success: 'πŸŽ‰',
33
+ 'clause-body': 'πŸ“‹',
34
+ match: 'πŸ“¦',
35
+ };
36
+ /**
37
+ * Extracts unification details by matching a goal against a clause.
38
+ */
39
+ function extractUnifications(goal, clause) {
40
+ const unifications = [];
41
+ // Parse clause head (before :- if it exists)
42
+ const clauseHead = clause.text.includes(':-')
43
+ ? clause.text.split(':-')[0].trim()
44
+ : clause.text.trim();
45
+ // Extract predicate name and arguments from both goal and clause head
46
+ const goalMatch = goal.match(/^([a-z_][a-zA-Z0-9_]*)\((.*)\)$/);
47
+ const clauseMatch = clauseHead.match(/^([a-z_][a-zA-Z0-9_]*)\((.*)\)$/);
48
+ if (!goalMatch || !clauseMatch) {
49
+ return unifications;
50
+ }
51
+ const [, goalPred, goalArgsStr] = goalMatch;
52
+ const [, clausePred, clauseArgsStr] = clauseMatch;
53
+ if (goalPred !== clausePred) {
54
+ return unifications;
55
+ }
56
+ // Parse arguments (simple split by comma, doesn't handle nested structures perfectly)
57
+ const goalArgs = parseArguments(goalArgsStr);
58
+ const clauseArgs = parseArguments(clauseArgsStr);
59
+ if (goalArgs.length !== clauseArgs.length) {
60
+ return unifications;
61
+ }
62
+ // Match each argument pair
63
+ for (let i = 0; i < goalArgs.length; i++) {
64
+ const goalArg = goalArgs[i].trim();
65
+ const clauseArg = clauseArgs[i].trim();
66
+ // If clause arg is a variable (starts with uppercase or _)
67
+ if (/^[A-Z_]/.test(clauseArg)) {
68
+ unifications.push(`${clauseArg} = ${goalArg}`);
69
+ }
70
+ else if (goalArg !== clauseArg) {
71
+ // If they're different, show the unification
72
+ unifications.push(`${goalArg} = ${clauseArg}`);
73
+ }
74
+ }
75
+ return unifications;
76
+ }
77
+ /**
78
+ * Parse arguments from a comma-separated string, respecting nested structures.
79
+ */
80
+ function parseArguments(argsStr) {
81
+ const args = [];
82
+ let current = '';
83
+ let depth = 0;
84
+ for (const char of argsStr) {
85
+ if (char === ',' && depth === 0) {
86
+ args.push(current.trim());
87
+ current = '';
88
+ }
89
+ else {
90
+ if (char === '(' || char === '[')
91
+ depth++;
92
+ if (char === ')' || char === ']')
93
+ depth--;
94
+ current += char;
95
+ }
96
+ }
97
+ if (current.trim()) {
98
+ args.push(current.trim());
99
+ }
100
+ return args;
101
+ }
102
+ /**
103
+ * Splits a compound goal into individual goals, respecting nested structures.
104
+ * E.g., "3>0, N1 is 3-1" -> ["3>0", "N1 is 3-1"]
105
+ */
106
+ function splitCompoundGoal(goal) {
107
+ return parseArguments(goal);
108
+ }
109
+ /**
110
+ * Checks if a goal is a compound goal (contains top-level commas).
111
+ */
112
+ function isCompoundGoal(goal) {
113
+ let depth = 0;
114
+ for (const char of goal) {
115
+ if (char === '(' || char === '[')
116
+ depth++;
117
+ else if (char === ')' || char === ']')
118
+ depth--;
119
+ else if (char === ',' && depth === 0)
120
+ return true;
121
+ }
122
+ return false;
123
+ }
124
+ /**
125
+ * Extracts subgoals from a clause body.
126
+ */
127
+ function extractSubgoals(clause) {
128
+ if (!clause.text.includes(':-')) {
129
+ return [];
130
+ }
131
+ const body = clause.text.split(':-')[1].trim();
132
+ return parseArguments(body);
133
+ }
134
+ /**
135
+ * Analyzes an execution tree and produces visualization data.
136
+ */
137
+ export function analyzeTree(root, clauses = [], options = {}, traceEvents = []) {
138
+ const detailLevel = options.detailLevel || 'standard';
139
+ // Extract clauses from trace events instead of using parsed clauses
140
+ const traceClauses = extractClausesFromTraceEvents(traceEvents);
141
+ const actualClauses = traceClauses.length > 0 ? traceClauses : clauses;
142
+ const nodes = [];
143
+ const edges = [];
144
+ const pendingGoalMap = new Map(); // goal text -> node id
145
+ const executionSteps = [];
146
+ let stepCounter = 1;
147
+ let nodeIdCounter = 0;
148
+ // Generate letter-based node IDs (A, B, ..., Z, AA, AB, ...)
149
+ let currentNodeIndex = 0;
150
+ let lastNonPendingIndex = -1;
151
+ const pendingCounters = new Map(); // Track pending node count per parent
152
+ const indexToId = (index) => {
153
+ if (index < 26) {
154
+ return String.fromCharCode(65 + index); // A-Z
155
+ }
156
+ // For index >= 26, use AA, AB, AC, ..., AZ, BA, BB, ...
157
+ const firstLetter = String.fromCharCode(65 + Math.floor(index / 26) - 1);
158
+ const secondLetter = String.fromCharCode(65 + (index % 26));
159
+ return firstLetter + secondLetter;
160
+ };
161
+ const nextNodeId = (isPending = false) => {
162
+ if (isPending) {
163
+ // Pending nodes use the last non-pending index with incrementing suffixes
164
+ const count = pendingCounters.get(lastNonPendingIndex) || 0;
165
+ pendingCounters.set(lastNonPendingIndex, count + 1);
166
+ return `${indexToId(lastNonPendingIndex)}${count + 2}`; // Start at 2 (B2, B3, B4...)
167
+ }
168
+ else {
169
+ // Regular nodes advance the index
170
+ const id = indexToId(currentNodeIndex);
171
+ lastNonPendingIndex = currentNodeIndex;
172
+ currentNodeIndex++;
173
+ return id;
174
+ }
175
+ };
176
+ // Extract final answer first so we can show it in the success node
177
+ const finalAnswer = extractFinalAnswer(root);
178
+ // Process the tree
179
+ processTreeNode(root, null, {
180
+ nodes,
181
+ edges,
182
+ pendingGoalMap,
183
+ executionSteps,
184
+ stepCounter: () => stepCounter++,
185
+ nextNodeId,
186
+ clauses: actualClauses,
187
+ detailLevel,
188
+ finalAnswer,
189
+ });
190
+ // Clause usage tracking - since we can't reliably determine which clauses were used,
191
+ // just list all available clauses
192
+ const clausesUsed = actualClauses.map(clause => ({
193
+ clauseNumber: clause.number,
194
+ clauseText: clause.text,
195
+ usageCount: 0,
196
+ usedAtSteps: [],
197
+ }));
198
+ return {
199
+ nodes,
200
+ edges,
201
+ pendingGoals: new Map(),
202
+ executionOrder: nodes.map(n => n.id),
203
+ clausesUsed,
204
+ executionSteps,
205
+ finalAnswer,
206
+ };
207
+ }
208
+ /**
209
+ * Extract goal functor (predicate name) from a goal string.
210
+ */
211
+ function getGoalFunctor(goal) {
212
+ const match = goal.match(/^([a-z_][a-zA-Z0-9_]*)\(/);
213
+ return match ? match[1] : goal;
214
+ }
215
+ /**
216
+ * Check if a goal is recursive by comparing its functor with an ancestor.
217
+ */
218
+ function isRecursiveCall(goal, ancestorGoal) {
219
+ if (!ancestorGoal)
220
+ return false;
221
+ const goalFunctor = getGoalFunctor(goal);
222
+ const ancestorFunctor = getGoalFunctor(ancestorGoal);
223
+ return goalFunctor === ancestorFunctor && goalFunctor !== '';
224
+ }
225
+ /**
226
+ * Check if a goal matches a pending goal pattern.
227
+ * For exact deduplication, use exact match.
228
+ * For activation, check if the second argument (output variable) matches.
229
+ */
230
+ function matchesPendingGoal(goal, pendingGoal, fuzzy = false) {
231
+ if (!fuzzy) {
232
+ // Exact match for deduplication
233
+ return goal.replace(/\s+/g, '') === pendingGoal.replace(/\s+/g, '');
234
+ }
235
+ // Fuzzy match for activation - check if they have the same functor
236
+ const goalFunctor = getGoalFunctor(goal);
237
+ const pendingFunctor = getGoalFunctor(pendingGoal);
238
+ if (goalFunctor !== pendingFunctor) {
239
+ return false;
240
+ }
241
+ // For 'is' operator, match on the first argument (the variable being assigned)
242
+ // Format: Variable is Expression
243
+ if (goalFunctor === 'is') {
244
+ const goalVar = goal.match(/^([A-Z][A-Za-z0-9_]*[\u2080-\u2089]*)\s+is/);
245
+ const pendingVar = pendingGoal.match(/^([A-Z][A-Za-z0-9_]*[\u2080-\u2089]*)\s+is/);
246
+ if (goalVar && pendingVar) {
247
+ return goalVar[1] === pendingVar[1];
248
+ }
249
+ return false;
250
+ }
251
+ // For other predicates, extract the output variable (usually last argument)
252
+ // Try to match the last argument in parentheses
253
+ const goalMatch = goal.match(/,\s*([A-Z][A-Za-z0-9_]*[\u2080-\u2089]*)\s*\)/);
254
+ const pendingMatch = pendingGoal.match(/,\s*([A-Z][A-Za-z0-9_]*[\u2080-\u2089]*)\s*\)/);
255
+ if (goalMatch && pendingMatch) {
256
+ return goalMatch[1] === pendingMatch[1];
257
+ }
258
+ // Fallback: exact match
259
+ return goal.replace(/\s+/g, '') === pendingGoal.replace(/\s+/g, '');
260
+ }
261
+ /**
262
+ * Process a tree node and generate visualization nodes/edges.
263
+ */
264
+ function processTreeNode(node, parentId, ctx) {
265
+ // Get clause number from node FIRST (set by parser)
266
+ const clauseNumber = node.clauseNumber;
267
+ // Debug logging removed
268
+ // Check if this is a compound goal that should be decomposed (at detailed level)
269
+ if (ctx.detailLevel === 'detailed' || ctx.detailLevel === 'full') {
270
+ if (isCompoundGoal(node.goal) && node.type !== 'success' && node.type !== 'failure') {
271
+ console.log(`[DEBUG] Compound goal detected: ${node.goal}`);
272
+ // Split into individual goals and create a chain
273
+ const subgoals = splitCompoundGoal(node.goal);
274
+ console.log(`[DEBUG] Split into subgoals:`, subgoals);
275
+ if (subgoals.length > 1) {
276
+ // Create chain of nodes for each subgoal
277
+ let currentParentId = parentId;
278
+ for (let i = 0; i < subgoals.length; i++) {
279
+ const subgoalNodeId = ctx.nextNodeId();
280
+ const isLast = i === subgoals.length - 1;
281
+ const subgoalNode = {
282
+ id: subgoalNodeId,
283
+ type: 'solving',
284
+ label: `Solve: ${subgoals[i]}`,
285
+ emoji: EMOJIS.solving,
286
+ level: node.level,
287
+ };
288
+ ctx.nodes.push(subgoalNode);
289
+ // Create edge from parent
290
+ if (currentParentId) {
291
+ const edge = {
292
+ id: `edge_${ctx.edges.length}`,
293
+ from: currentParentId,
294
+ to: subgoalNodeId,
295
+ type: 'active',
296
+ label: i === 0 ? '' : 'next',
297
+ stepNumber: ctx.stepCounter(),
298
+ };
299
+ ctx.edges.push(edge);
300
+ }
301
+ currentParentId = subgoalNodeId;
302
+ }
303
+ // Process children from the last subgoal node
304
+ if (node.children.length > 0) {
305
+ for (const child of node.children) {
306
+ processTreeNode(child, currentParentId, ctx);
307
+ }
308
+ }
309
+ return currentParentId;
310
+ }
311
+ }
312
+ }
313
+ // Create node for this goal
314
+ const nodeId = ctx.nextNodeId();
315
+ // Determine node type and label
316
+ let nodeType;
317
+ let label;
318
+ if (node.level === 0 || node.type === 'query') {
319
+ nodeType = 'query';
320
+ // Add space after commas and format with QUERY label and line break
321
+ const formattedGoal = node.goal.replace(/,(?!\s)/g, ', ');
322
+ label = `QUERY<br/>${formattedGoal}`;
323
+ }
324
+ else if (node.type === 'success') {
325
+ nodeType = 'success';
326
+ // Show SUCCESS with the final answer if available
327
+ if (ctx.finalAnswer) {
328
+ label = `SUCCESS<br/>${ctx.finalAnswer}`;
329
+ }
330
+ else {
331
+ label = 'SUCCESS';
332
+ }
333
+ }
334
+ else {
335
+ nodeType = 'solving';
336
+ // Add space after commas in goal
337
+ const formattedGoal = node.goal.replace(/,(?!\s)/g, ', ');
338
+ // Check if it's a compound goal (has commas outside parentheses)
339
+ let depth = 0;
340
+ let hasTopLevelComma = false;
341
+ for (const char of node.goal) {
342
+ if (char === '(' || char === '[')
343
+ depth++;
344
+ else if (char === ')' || char === ']')
345
+ depth--;
346
+ else if (char === ',' && depth === 0) {
347
+ hasTopLevelComma = true;
348
+ break;
349
+ }
350
+ }
351
+ // User predicates: start with lowercase letter followed by (, and no top-level commas
352
+ const isUserPredicate = /^[a-z_][a-zA-Z0-9_]*\(/.test(node.goal) && !hasTopLevelComma;
353
+ // Check if this is a recursive call (only show if detail level >= standard)
354
+ const isRecursive = ctx.detailLevel !== 'minimal' && isRecursiveCall(node.goal, ctx.ancestorGoal || null);
355
+ const recursivePrefix = isRecursive ? 'πŸ” Recurse: ' : 'Solve: ';
356
+ // For minimal level, hide built-in predicates to reduce clutter
357
+ if (ctx.detailLevel === 'minimal') {
358
+ const builtinPredicates = ['>', '<', '>=', '=<', '=:=', '=\\=', 'is', '=', '\\='];
359
+ const goalPredicate = node.goal.match(/^([^(]+)/);
360
+ if (goalPredicate && builtinPredicates.some(bp => goalPredicate[1].includes(bp))) {
361
+ // Skip this node for minimal level
362
+ if (node.children.length > 0) {
363
+ // Process children directly
364
+ for (const child of node.children) {
365
+ processTreeNode(child, parentId, ctx);
366
+ }
367
+ return parentId || '';
368
+ }
369
+ }
370
+ }
371
+ const clauseLabel = (clauseNumber && isUserPredicate) ? ` [clause ${clauseNumber}]` : '';
372
+ label = `${recursivePrefix}${formattedGoal}${clauseLabel}`;
373
+ }
374
+ const vizNode = {
375
+ id: nodeId,
376
+ type: nodeType,
377
+ label,
378
+ emoji: EMOJIS[nodeType],
379
+ level: node.level,
380
+ clauseNumber,
381
+ };
382
+ ctx.nodes.push(vizNode);
383
+ // Create edge from parent if exists
384
+ if (parentId) {
385
+ // Find parent node to determine if we need a match node
386
+ const parentNode = ctx.nodes.find(n => n.id === parentId);
387
+ // Insert match node if we have a clause number and we're at detailed level or higher
388
+ let actualParentId = parentId;
389
+ // Create match nodes for user-defined predicates at detailed/full levels
390
+ // Check if this is a user-defined predicate (not built-in)
391
+ const goalPredicate = node.goal.match(/^([a-z_][a-zA-Z0-9_]*)\(/);
392
+ const isUserPredicate = goalPredicate && ctx.clauses.some(c => c.head.startsWith(goalPredicate[1] + '('));
393
+ if (isUserPredicate && ctx.clauses.length > 0 && (ctx.detailLevel === 'detailed' || ctx.detailLevel === 'full')) {
394
+ let clause;
395
+ const predicateName = goalPredicate[1];
396
+ // Find clauses for this predicate
397
+ const predicateClauses = ctx.clauses.filter(c => c.head.startsWith(predicateName + '('));
398
+ // Use heuristic: if goal looks like base case (factorial(0,...)), use first clause
399
+ // If goal looks like recursive case, use second clause
400
+ if (predicateClauses.length >= 2) {
401
+ const isBaseCase = node.goal.includes('(0,') || node.goal.includes('(0 ,') ||
402
+ node.goal.includes('([],') || node.goal.includes('([] ,');
403
+ clause = isBaseCase ? predicateClauses[0] : predicateClauses[1];
404
+ }
405
+ else if (predicateClauses.length > 0) {
406
+ clause = predicateClauses[0];
407
+ }
408
+ if (clause) {
409
+ // Create match node
410
+ const matchNodeId = ctx.nextNodeId();
411
+ // Use unifications from node if available, otherwise try to extract them
412
+ let unifications;
413
+ if (node.unifications && node.unifications.length > 0) {
414
+ // Use accurate unifications from tracer
415
+ unifications = node.unifications.map(u => `${u.variable} = ${u.value}`);
416
+ }
417
+ else {
418
+ // Fallback to extraction (for backward compatibility)
419
+ unifications = extractUnifications(node.goal, clause);
420
+ }
421
+ const subgoals = extractSubgoals(clause);
422
+ // Build match node label - only show clause HEAD, not body
423
+ const clauseHead = clause.text.includes(':-')
424
+ ? clause.text.split(':-')[0].trim()
425
+ : clause.text.trim();
426
+ // Find the clause from our trace-extracted clauses that matches the tracer's clause info
427
+ const matchingParsedClause = ctx.clauses.find((c) => {
428
+ // If we have clause line info from tracer, match by line number
429
+ if (node.clauseLine) {
430
+ return c.number === node.clauseLine;
431
+ }
432
+ // Fallback: match by predicate name and clause content
433
+ if (clause) {
434
+ return c.head === clause.head || c.text === clause.text;
435
+ }
436
+ // Final fallback: match by predicate name
437
+ const predicateName = node.goal.split('(')[0];
438
+ const clausePredicateName = c.head.split('(')[0];
439
+ return clausePredicateName === predicateName;
440
+ });
441
+ const displayClauseNumber = matchingParsedClause ? matchingParsedClause.number : clauseNumber;
442
+ let matchLabel = `Match Clause ${displayClauseNumber}<br/>${clauseHead}`;
443
+ if (unifications.length > 0) {
444
+ matchLabel += '<br/><br/>Unifications:<br/>' + unifications.map(u => `β€’ ${u}`).join('<br/>');
445
+ }
446
+ if (subgoals.length > 0) {
447
+ matchLabel += '<br/><br/>Subgoals (solve left-to-right):<br/>' + subgoals.map((sg, i) => `${i + 1}. ${sg}`).join('<br/>');
448
+ }
449
+ const matchNode = {
450
+ id: matchNodeId,
451
+ type: 'match',
452
+ label: matchLabel,
453
+ emoji: EMOJIS.match,
454
+ level: node.level,
455
+ clauseNumber,
456
+ };
457
+ ctx.nodes.push(matchNode);
458
+ // Edge from parent to match node
459
+ const toMatchEdge = {
460
+ id: `edge_${ctx.edges.length}`,
461
+ from: parentId,
462
+ to: matchNodeId,
463
+ type: 'active',
464
+ label: 'try',
465
+ stepNumber: ctx.stepCounter(),
466
+ };
467
+ ctx.edges.push(toMatchEdge);
468
+ // Edge from match node to actual node
469
+ const fromMatchEdge = {
470
+ id: `edge_${ctx.edges.length}`,
471
+ from: matchNodeId,
472
+ to: nodeId,
473
+ type: 'active',
474
+ label: '',
475
+ stepNumber: ctx.stepCounter(),
476
+ };
477
+ ctx.edges.push(fromMatchEdge);
478
+ actualParentId = matchNodeId;
479
+ }
480
+ else {
481
+ // No clause found, create edge without match node
482
+ const edge = {
483
+ id: `edge_${ctx.edges.length}`,
484
+ from: parentId,
485
+ to: nodeId,
486
+ type: 'active',
487
+ label: clauseNumber ? `clause ${clauseNumber}` : '',
488
+ stepNumber: ctx.stepCounter(),
489
+ };
490
+ ctx.edges.push(edge);
491
+ }
492
+ }
493
+ else {
494
+ // No match node needed, create direct edge
495
+ let edgeLabel = '';
496
+ if (parentNode) {
497
+ if (parentNode.type === 'query' && vizNode.type === 'solving') {
498
+ edgeLabel = clauseNumber ? `clause ${clauseNumber}` : '';
499
+ }
500
+ else if (parentNode.type === 'query' && vizNode.type === 'success') {
501
+ edgeLabel = clauseNumber ? `clause ${clauseNumber}` : '';
502
+ }
503
+ else if (parentNode.type === 'solved' && vizNode.type === 'solving') {
504
+ edgeLabel = clauseNumber ? `clause ${clauseNumber}` : 'done';
505
+ }
506
+ else if (parentNode.type === 'solving' && vizNode.type === 'success') {
507
+ edgeLabel = clauseNumber ? `clause ${clauseNumber}` : 'success';
508
+ }
509
+ else if (parentNode.type === 'solving' && vizNode.type === 'solving') {
510
+ const existingActiveEdges = ctx.edges.filter(e => e.from === parentId && e.type === 'active' &&
511
+ ctx.nodes.find(n => n.id === e.to && n.type === 'solving')).length;
512
+ if (existingActiveEdges > 0) {
513
+ edgeLabel = 'backtrack';
514
+ }
515
+ else {
516
+ edgeLabel = clauseNumber ? `clause ${clauseNumber}` : '';
517
+ }
518
+ }
519
+ else if (parentNode.type === 'solved' && vizNode.type === 'success') {
520
+ edgeLabel = 'all done';
521
+ }
522
+ }
523
+ const edge = {
524
+ id: `edge_${ctx.edges.length}`,
525
+ from: parentId,
526
+ to: nodeId,
527
+ type: 'active',
528
+ label: edgeLabel,
529
+ stepNumber: ctx.stepCounter(),
530
+ };
531
+ ctx.edges.push(edge);
532
+ }
533
+ ctx.executionSteps.push({
534
+ stepNumber: ctx.edges[ctx.edges.length - 1].stepNumber,
535
+ goal: node.goal,
536
+ description: `Solving ${node.goal}`,
537
+ clauseMatched: node.binding,
538
+ });
539
+ }
540
+ // Check if this node activates a pending goal (use fuzzy matching)
541
+ // Do this AFTER creating the parent edge so activation comes after "done"
542
+ for (const [pendingGoal, pendingId] of ctx.pendingGoalMap.entries()) {
543
+ if (matchesPendingGoal(node.goal, pendingGoal, true)) {
544
+ const activateEdge = {
545
+ id: `edge_${ctx.edges.length}`,
546
+ from: pendingId,
547
+ to: nodeId,
548
+ type: 'activate',
549
+ label: 'activate',
550
+ stepNumber: ctx.stepCounter(),
551
+ };
552
+ ctx.edges.push(activateEdge);
553
+ ctx.pendingGoalMap.delete(pendingGoal);
554
+ break;
555
+ }
556
+ }
557
+ // Track the last node ID for chaining (no clause body nodes - match box shows subgoals)
558
+ let lastNodeId = nodeId;
559
+ // If this node has a binding (and it's not a success node), create a solved node FIRST
560
+ // The solved node goes BETWEEN this solving node and its child
561
+ if (node.binding && node.type !== 'success' && node.level > 0) {
562
+ const solvedId = ctx.nextNodeId();
563
+ // Convert binding from X/value to X = value format
564
+ const formattedBinding = node.binding.replace(/([^/]+)\/(.+)/, '$1 = $2');
565
+ const solvedNode = {
566
+ id: solvedId,
567
+ type: 'solved',
568
+ label: `Solved: ${formattedBinding}`,
569
+ emoji: EMOJIS.solved,
570
+ level: node.level,
571
+ };
572
+ ctx.nodes.push(solvedNode);
573
+ // Edge from solving node to solved node - just show the binding
574
+ const doneEdge = {
575
+ id: `edge_${ctx.edges.length}`,
576
+ from: nodeId,
577
+ to: solvedId,
578
+ type: 'active',
579
+ label: formattedBinding,
580
+ stepNumber: ctx.stepCounter(),
581
+ };
582
+ ctx.edges.push(doneEdge);
583
+ lastNodeId = solvedId;
584
+ }
585
+ // Update ancestor goal for recursion detection
586
+ // For children, the ancestor is the current node's goal (if it's a user predicate)
587
+ const isUserPredicate = /^[a-z_][a-zA-Z0-9_]*\(/.test(node.goal);
588
+ const newAncestor = isUserPredicate ? node.goal : ctx.ancestorGoal;
589
+ const newCtx = { ...ctx, ancestorGoal: newAncestor };
590
+ // Process children
591
+ // If there are multiple children, they represent alternatives (OR branches)
592
+ // The first child is the path taken, subsequent children are backtrack alternatives
593
+ if (node.children.length > 1) {
594
+ // First child is the main execution path
595
+ const mainChildId = processTreeNode(node.children[0], lastNodeId, newCtx);
596
+ // Subsequent children are alternative branches (backtracking)
597
+ for (let i = 1; i < node.children.length; i++) {
598
+ const altChild = node.children[i];
599
+ // Process alternative child
600
+ // Check if this alternative should get a match node
601
+ const altGoalPredicate = altChild.goal.match(/^([a-z_][a-zA-Z0-9_]*)\(/);
602
+ const altIsUserPredicate = altGoalPredicate && newCtx.clauses.some(c => c.head.startsWith(altGoalPredicate[1] + '('));
603
+ let actualParentForAlt = lastNodeId;
604
+ // Create match node for user predicates at detailed/full levels
605
+ if (altIsUserPredicate && newCtx.clauses.length > 0 && (newCtx.detailLevel === 'detailed' || newCtx.detailLevel === 'full')) {
606
+ const predicateName = altGoalPredicate[1];
607
+ const predicateClauses = newCtx.clauses.filter(c => c.head.startsWith(predicateName + '('));
608
+ if (predicateClauses.length > 0) {
609
+ // Create match node for alternative
610
+ const altMatchNodeId = newCtx.nextNodeId();
611
+ // Select appropriate clause
612
+ let altClause;
613
+ if (predicateClauses.length >= 2) {
614
+ const isBaseCase = altChild.goal.includes('(0,') || altChild.goal.includes('(0 ,') ||
615
+ altChild.goal.includes('([],') || altChild.goal.includes('([] ,');
616
+ altClause = isBaseCase ? predicateClauses[0] : predicateClauses[1];
617
+ }
618
+ else {
619
+ altClause = predicateClauses[0];
620
+ }
621
+ // Create match node for this alternative
622
+ // Build match node label
623
+ const clauseHead = altClause.text.includes(':-')
624
+ ? altClause.text.split(':-')[0].trim()
625
+ : altClause.text.trim();
626
+ // Find the clause from our trace-extracted clauses that matches the tracer's clause info
627
+ const matchingParsedClause = ctx.clauses.find((c) => {
628
+ // If we have clause line info from tracer, match by line number
629
+ if (altChild.clauseLine) {
630
+ return c.number === altChild.clauseLine;
631
+ }
632
+ // Fallback: match by predicate name and clause content
633
+ if (altClause) {
634
+ return c.head === altClause.head || c.text === altClause.text;
635
+ }
636
+ // Final fallback: match by predicate name
637
+ const predicateName = altChild.goal.split('(')[0];
638
+ const clausePredicateName = c.head.split('(')[0];
639
+ return clausePredicateName === predicateName;
640
+ });
641
+ const displayClauseNumber = matchingParsedClause ? matchingParsedClause.number : altClause.number;
642
+ let matchLabel = `Match Clause ${displayClauseNumber}<br/>${clauseHead}`;
643
+ const altMatchNode = {
644
+ id: altMatchNodeId,
645
+ type: 'match',
646
+ label: matchLabel,
647
+ emoji: EMOJIS.match,
648
+ level: altChild.level,
649
+ clauseNumber: altClause.number,
650
+ };
651
+ newCtx.nodes.push(altMatchNode);
652
+ // Edge from parent to match node
653
+ const toAltMatchEdge = {
654
+ id: `edge_${newCtx.edges.length}`,
655
+ from: lastNodeId,
656
+ to: altMatchNodeId,
657
+ type: 'active',
658
+ label: 'backtrack',
659
+ stepNumber: newCtx.stepCounter(),
660
+ };
661
+ newCtx.edges.push(toAltMatchEdge);
662
+ actualParentForAlt = altMatchNodeId;
663
+ }
664
+ }
665
+ // Process alternative branch - it connects to the parent (or match node)
666
+ const altNodeId = newCtx.nextNodeId();
667
+ let altLabel;
668
+ let altType;
669
+ // Get clause number from alternative child
670
+ const altClauseNumber = altChild.clauseNumber;
671
+ if (altChild.type === 'success') {
672
+ altType = 'success';
673
+ if (newCtx.finalAnswer) {
674
+ altLabel = `SUCCESS<br/>${newCtx.finalAnswer}`;
675
+ }
676
+ else {
677
+ altLabel = 'SUCCESS';
678
+ }
679
+ }
680
+ else if (altChild.type === 'failure') {
681
+ altType = 'solving';
682
+ altLabel = `Solve: ${altChild.goal.replace(/,(?!\s)/g, ', ')}`;
683
+ }
684
+ else {
685
+ altType = 'solving';
686
+ const formattedGoal = altChild.goal.replace(/,(?!\s)/g, ', ');
687
+ // Check if it's a compound goal
688
+ let depth = 0;
689
+ let hasTopLevelComma = false;
690
+ for (const char of altChild.goal) {
691
+ if (char === '(' || char === '[')
692
+ depth++;
693
+ else if (char === ')' || char === ']')
694
+ depth--;
695
+ else if (char === ',' && depth === 0) {
696
+ hasTopLevelComma = true;
697
+ break;
698
+ }
699
+ }
700
+ const isUserPredicate = /^[a-z_][a-zA-Z0-9_]*\(/.test(altChild.goal) && !hasTopLevelComma;
701
+ const isRecursive = newCtx.detailLevel !== 'minimal' && isRecursiveCall(altChild.goal, newCtx.ancestorGoal || null);
702
+ const recursivePrefix = isRecursive ? 'πŸ” Recurse: ' : 'Solve: ';
703
+ const clauseLabel = (altClauseNumber && isUserPredicate) ? ` [clause ${altClauseNumber}]` : '';
704
+ altLabel = `${recursivePrefix}${formattedGoal}${clauseLabel}`;
705
+ }
706
+ const altVizNode = {
707
+ id: altNodeId,
708
+ type: altType,
709
+ label: altLabel,
710
+ emoji: EMOJIS[altType],
711
+ level: altChild.level,
712
+ clauseNumber: altClauseNumber,
713
+ };
714
+ newCtx.nodes.push(altVizNode);
715
+ // Create edge from parent (or match node) to alternative node
716
+ const backtrackLabel = altClauseNumber ? `clause ${altClauseNumber}` : '';
717
+ const backtrackEdge = {
718
+ id: `edge_${newCtx.edges.length}`,
719
+ from: actualParentForAlt,
720
+ to: altNodeId,
721
+ type: 'active',
722
+ label: backtrackLabel,
723
+ stepNumber: newCtx.stepCounter(),
724
+ };
725
+ newCtx.edges.push(backtrackEdge);
726
+ newCtx.executionSteps.push({
727
+ stepNumber: backtrackEdge.stepNumber,
728
+ goal: altChild.goal,
729
+ description: `Backtracking: ${altChild.goal}`,
730
+ });
731
+ // Recursively process the alternative branch's children
732
+ if (altChild.children.length > 0) {
733
+ processAlternativeBranch(altChild, altNodeId, newCtx);
734
+ }
735
+ }
736
+ return mainChildId;
737
+ }
738
+ else if (node.children.length === 1) {
739
+ // Single child - sequential execution
740
+ const childId = processTreeNode(node.children[0], lastNodeId, newCtx);
741
+ return childId;
742
+ }
743
+ return lastNodeId;
744
+ }
745
+ /**
746
+ * Process an alternative branch (backtracking path).
747
+ * Similar to processTreeNode but doesn't create the root node (already created).
748
+ */
749
+ function processAlternativeBranch(node, nodeId, ctx) {
750
+ // Update ancestor goal for recursion detection
751
+ const newCtx = { ...ctx, ancestorGoal: node.goal };
752
+ // Process children of this alternative branch
753
+ for (let i = 0; i < node.children.length; i++) {
754
+ const child = node.children[i];
755
+ const childId = newCtx.nextNodeId();
756
+ // Get clause number from child
757
+ const childClauseNumber = child.clauseNumber;
758
+ const isBacktrack = i > 0; // First child is main path, rest are backtracks
759
+ let childLabel;
760
+ let childType;
761
+ if (child.type === 'success') {
762
+ childType = 'success';
763
+ if (newCtx.finalAnswer) {
764
+ childLabel = `SUCCESS<br/>${newCtx.finalAnswer}`;
765
+ }
766
+ else {
767
+ childLabel = 'SUCCESS';
768
+ }
769
+ }
770
+ else if (child.type === 'failure') {
771
+ childType = 'solving';
772
+ childLabel = `Solve: ${child.goal.replace(/,(?!\s)/g, ', ')}`;
773
+ }
774
+ else {
775
+ childType = 'solving';
776
+ const isRecursive = newCtx.detailLevel !== 'minimal' && isRecursiveCall(child.goal, newCtx.ancestorGoal || null);
777
+ const recursivePrefix = isRecursive ? 'πŸ” Recurse: ' : 'Solve: ';
778
+ const clauseLabel = childClauseNumber ? ` [clause ${childClauseNumber}]` : '';
779
+ childLabel = `${recursivePrefix}${child.goal.replace(/,(?!\s)/g, ', ')}${clauseLabel}`;
780
+ }
781
+ const childVizNode = {
782
+ id: childId,
783
+ type: childType,
784
+ label: childLabel,
785
+ emoji: EMOJIS[childType],
786
+ level: child.level,
787
+ clauseNumber: childClauseNumber,
788
+ };
789
+ newCtx.nodes.push(childVizNode);
790
+ // Create edge - show backtrack + clause number for alternatives
791
+ let edgeLabel = '';
792
+ if (isBacktrack && childClauseNumber) {
793
+ edgeLabel = `backtrack (clause ${childClauseNumber})`;
794
+ }
795
+ else if (childClauseNumber) {
796
+ edgeLabel = `clause ${childClauseNumber}`;
797
+ }
798
+ else if (isBacktrack) {
799
+ edgeLabel = 'backtrack';
800
+ }
801
+ const edge = {
802
+ id: `edge_${newCtx.edges.length}`,
803
+ from: nodeId,
804
+ to: childId,
805
+ type: 'active',
806
+ label: edgeLabel,
807
+ stepNumber: newCtx.stepCounter(),
808
+ };
809
+ newCtx.edges.push(edge);
810
+ newCtx.executionSteps.push({
811
+ stepNumber: edge.stepNumber,
812
+ goal: child.goal,
813
+ description: `Solving ${child.goal}`,
814
+ });
815
+ // Recursively process this child's children
816
+ if (child.children.length > 0) {
817
+ processAlternativeBranch(child, childId, newCtx);
818
+ }
819
+ }
820
+ }
821
+ /**
822
+ * Assigns level-based variable names to avoid confusion in recursive calls.
823
+ */
824
+ export function assignLevelVariables(node, level) {
825
+ node.level = level;
826
+ // Rename variables in the goal if they exist
827
+ if (node.goal) {
828
+ node.goal = renameVariablesWithLevel(node.goal, level);
829
+ }
830
+ if (node.binding) {
831
+ node.binding = renameVariablesWithLevel(node.binding, level);
832
+ }
833
+ for (const child of node.children) {
834
+ assignLevelVariables(child, level + 1);
835
+ }
836
+ }
837
+ /**
838
+ * Renames variables in a string to include level suffix.
839
+ */
840
+ function renameVariablesWithLevel(text, level) {
841
+ // Match Prolog variables (uppercase letter followed by alphanumerics/underscores)
842
+ return text.replace(/\b([A-Z][A-Za-z0-9_]*)\b/g, (match) => {
843
+ // Don't rename if already has level suffix
844
+ if (/_L\d+$/.test(match))
845
+ return match;
846
+ return `${match}_L${level}`;
847
+ });
848
+ }
849
+ /**
850
+ * Extracts the final answer from a successful execution tree.
851
+ */
852
+ function extractFinalAnswer(root) {
853
+ // Extract the query variable from the root goal
854
+ // Try multiple patterns: member(X, ...), append(..., X), factorial(..., X), etc.
855
+ let queryVar = null;
856
+ // Pattern 1: First argument is a variable (e.g., member(X, [a,b,c]))
857
+ const firstArgMatch = root.goal.match(/^[a-z_][a-zA-Z0-9_]*\(\s*([A-Z][A-Za-z0-9_]*)[\u2080-\u2089]*/);
858
+ if (firstArgMatch) {
859
+ queryVar = firstArgMatch[1];
860
+ }
861
+ // Pattern 2: Last argument is a variable (e.g., factorial(5, X))
862
+ if (!queryVar) {
863
+ const lastArgMatch = root.goal.match(/,\s*([A-Z][A-Za-z0-9_]*)[\u2080-\u2089]*\s*\)/);
864
+ if (lastArgMatch) {
865
+ queryVar = lastArgMatch[1];
866
+ }
867
+ }
868
+ // Find the binding that matches the query variable
869
+ let finalBinding;
870
+ function findQueryBinding(node) {
871
+ if (node.binding && queryVar) {
872
+ // Check if this binding is for the query variable (with or without subscript)
873
+ const bindingVarMatch = node.binding.match(/^([A-Z][A-Za-z0-9_]*)[\u2080-\u2089]*/);
874
+ if (bindingVarMatch && bindingVarMatch[1] === queryVar) {
875
+ finalBinding = node.binding;
876
+ }
877
+ }
878
+ for (const child of node.children) {
879
+ findQueryBinding(child);
880
+ }
881
+ }
882
+ findQueryBinding(root);
883
+ // Convert from X/value to X = value format, and strip subscript from variable name
884
+ if (finalBinding) {
885
+ return finalBinding.replace(/^([A-Z][A-Za-z0-9_]*)[\u2080-\u2089]*\/(.+)/, '$1 = $2');
886
+ }
887
+ return undefined;
888
+ }
889
+ /**
890
+ * Determines the execution order (left-to-right, depth-first).
891
+ */
892
+ export function determineExecutionOrder(root) {
893
+ const order = [];
894
+ function traverse(node) {
895
+ order.push(node.id);
896
+ for (const child of node.children) {
897
+ traverse(child);
898
+ }
899
+ }
900
+ traverse(root);
901
+ return order;
902
+ }
903
+ //# sourceMappingURL=analyzer.js.map