prolog-trace-viz 1.1.3 → 2.1.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 (50) hide show
  1. package/README.md +43 -30
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/analyzer.js +106 -53
  4. package/dist/analyzer.js.map +1 -1
  5. package/dist/build-info.d.ts +4 -4
  6. package/dist/build-info.js +4 -4
  7. package/dist/build-info.js.map +1 -1
  8. package/dist/clauses.d.ts +11 -0
  9. package/dist/clauses.d.ts.map +1 -1
  10. package/dist/clauses.js +12 -0
  11. package/dist/clauses.js.map +1 -1
  12. package/dist/cli.d.ts +5 -7
  13. package/dist/cli.d.ts.map +1 -1
  14. package/dist/cli.js +2 -25
  15. package/dist/cli.js.map +1 -1
  16. package/dist/index.js +80 -22
  17. package/dist/index.js.map +1 -1
  18. package/dist/markdown-generator.d.ts +24 -0
  19. package/dist/markdown-generator.d.ts.map +1 -0
  20. package/dist/markdown-generator.js +124 -0
  21. package/dist/markdown-generator.js.map +1 -0
  22. package/dist/parser.d.ts +9 -0
  23. package/dist/parser.d.ts.map +1 -1
  24. package/dist/parser.js +67 -32
  25. package/dist/parser.js.map +1 -1
  26. package/dist/timeline-formatter.d.ts +9 -0
  27. package/dist/timeline-formatter.d.ts.map +1 -0
  28. package/dist/timeline-formatter.js +192 -0
  29. package/dist/timeline-formatter.js.map +1 -0
  30. package/dist/timeline.d.ts +177 -0
  31. package/dist/timeline.d.ts.map +1 -0
  32. package/dist/timeline.js +813 -0
  33. package/dist/timeline.js.map +1 -0
  34. package/dist/tree-formatter.d.ts +13 -0
  35. package/dist/tree-formatter.d.ts.map +1 -0
  36. package/dist/tree-formatter.js +136 -0
  37. package/dist/tree-formatter.js.map +1 -0
  38. package/dist/tree.d.ts +75 -0
  39. package/dist/tree.d.ts.map +1 -0
  40. package/dist/tree.js +267 -0
  41. package/dist/tree.js.map +1 -0
  42. package/dist/variable-tracker.d.ts +68 -0
  43. package/dist/variable-tracker.d.ts.map +1 -0
  44. package/dist/variable-tracker.js +216 -0
  45. package/dist/variable-tracker.js.map +1 -0
  46. package/dist/wrapper.d.ts.map +1 -1
  47. package/dist/wrapper.js +6 -20
  48. package/dist/wrapper.js.map +1 -1
  49. package/package.json +1 -1
  50. package/tracer.pl +127 -16
@@ -0,0 +1,813 @@
1
+ /**
2
+ * Timeline Builder - Constructs a flat, sequential timeline from trace events
3
+ */
4
+ import { VariableBindingTracker } from './variable-tracker.js';
5
+ /**
6
+ * Timeline Builder class - processes trace events into timeline steps
7
+ */
8
+ export class TimelineBuilder {
9
+ events;
10
+ sourceClauseMap;
11
+ originalQuery;
12
+ steps = [];
13
+ stepCounter = 0;
14
+ callStack = new Map(); // level -> step number
15
+ subgoalMap = new Map(); // level -> subgoal info
16
+ parentSubgoals = new Map(); // step number -> subgoals
17
+ completedSubgoals = new Map(); // parent step -> count of completed subgoals
18
+ constructor(events, sourceClauseMap, originalQuery) {
19
+ this.events = events;
20
+ this.sourceClauseMap = sourceClauseMap;
21
+ this.originalQuery = originalQuery;
22
+ }
23
+ /**
24
+ * Check if a predicate is part of tracer infrastructure and should be filtered
25
+ */
26
+ isTracerPredicate(predicate) {
27
+ const tracerPredicates = [
28
+ 'catch/3',
29
+ 'export_trace_json/1',
30
+ 'run_trace/0',
31
+ 'install_tracer/1',
32
+ 'remove_tracer/0',
33
+ ];
34
+ return tracerPredicates.includes(predicate);
35
+ }
36
+ /**
37
+ * Build the complete timeline from trace events
38
+ */
39
+ build() {
40
+ // First pass: process all events
41
+ for (const event of this.events) {
42
+ // Filter out tracer infrastructure
43
+ if (!this.isTracerPredicate(event.predicate)) {
44
+ this.processEvent(event);
45
+ }
46
+ }
47
+ // Second pass: backfill clause info from EXIT to CALL steps
48
+ this.backfillClauseInfo();
49
+ // Third pass: merge CALL/EXIT pairs into single steps (with incremental binding tracking)
50
+ this.mergeCallExitPairs();
51
+ // Fourth pass: update subgoal tracking based on execution flow
52
+ this.updateSubgoalTracking();
53
+ // Fifth pass: add variable flow tracking notes
54
+ this.addVariableFlowNotes();
55
+ return this.steps;
56
+ }
57
+ /**
58
+ * Backfill clause information from EXIT events to their corresponding CALL events
59
+ */
60
+ backfillClauseInfo() {
61
+ // For each CALL step without clause info, find its matching EXIT
62
+ for (let i = 0; i < this.steps.length; i++) {
63
+ const callStep = this.steps[i];
64
+ if (callStep.port === 'call' && !callStep.clause) {
65
+ // Find the matching EXIT for this CALL
66
+ // It should be at the same level and have the same predicate
67
+ const exitStep = this.findMatchingExit(callStep, i);
68
+ if (exitStep && exitStep.clause) {
69
+ // Use source clause if available, otherwise use runtime clause
70
+ const sourceClause = this.getSourceClause(exitStep.clause.line);
71
+ if (sourceClause) {
72
+ callStep.clause = {
73
+ head: sourceClause.head,
74
+ body: sourceClause.body || 'true',
75
+ line: sourceClause.number,
76
+ };
77
+ }
78
+ else {
79
+ callStep.clause = exitStep.clause;
80
+ }
81
+ // Extract pattern match bindings using source clause head
82
+ const patternBindings = this.extractPatternMatchBindings(callStep.goal, callStep.clause.head);
83
+ callStep.unifications = patternBindings;
84
+ // Re-extract subgoals now that we have clause info
85
+ if (callStep.clause.body && callStep.clause.body !== 'true') {
86
+ const subgoalGoals = this.extractSubgoals(callStep.clause.body);
87
+ callStep.subgoals = subgoalGoals.map((goal, index) => ({
88
+ label: `[${callStep.stepNumber}.${index + 1}]`,
89
+ goal,
90
+ }));
91
+ // Update parent subgoals map
92
+ if (callStep.subgoals.length > 0) {
93
+ this.parentSubgoals.set(callStep.stepNumber, callStep.subgoals);
94
+ this.completedSubgoals.set(callStep.stepNumber, 0);
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
100
+ }
101
+ /**
102
+ * Merge CALL/EXIT pairs into single steps
103
+ * This creates a cleaner timeline focused on goal execution rather than trace mechanics
104
+ */
105
+ mergeCallExitPairs() {
106
+ const mergedSteps = [];
107
+ const processedCalls = new Set();
108
+ const processedExits = new Set();
109
+ // Initialize binding tracker if we have original query
110
+ let bindingTracker;
111
+ if (this.originalQuery) {
112
+ bindingTracker = new VariableBindingTracker(this.originalQuery);
113
+ }
114
+ // Build maps for quick lookup
115
+ const callStepMap = new Map(); // level -> CALL step
116
+ const exitStepMap = new Map(); // level -> EXIT step
117
+ const callEventMap = new Map(); // step number -> CALL event
118
+ const exitEventMap = new Map(); // step number -> EXIT event
119
+ for (const step of this.steps) {
120
+ if (step.port === 'call') {
121
+ callStepMap.set(step.level, step);
122
+ }
123
+ else if (step.port === 'exit') {
124
+ exitStepMap.set(step.level, step);
125
+ }
126
+ }
127
+ for (const event of this.events) {
128
+ if (event.port === 'call') {
129
+ const step = callStepMap.get(event.level);
130
+ if (step)
131
+ callEventMap.set(step.stepNumber, event);
132
+ }
133
+ else if (event.port === 'exit') {
134
+ const step = exitStepMap.get(event.level);
135
+ if (step)
136
+ exitEventMap.set(step.stepNumber, event);
137
+ }
138
+ }
139
+ // Process events in their original order
140
+ for (const event of this.events) {
141
+ if (this.isTracerPredicate(event.predicate))
142
+ continue;
143
+ if (event.port === 'call') {
144
+ const callStep = callStepMap.get(event.level);
145
+ if (!callStep || processedCalls.has(callStep.stepNumber))
146
+ continue;
147
+ // Process this CALL event in the binding tracker
148
+ if (bindingTracker) {
149
+ bindingTracker.processEvent(event);
150
+ }
151
+ // Find the matching EXIT
152
+ const exitStep = exitStepMap.get(event.level);
153
+ if (exitStep && exitStep.returnsTo === callStep.stepNumber) {
154
+ // Merge CALL and EXIT into a single step
155
+ const mergedStep = {
156
+ ...callStep,
157
+ port: 'merged',
158
+ result: this.extractResult(exitStep.goal),
159
+ };
160
+ // Get query variable state at CALL time (before EXIT)
161
+ if (bindingTracker) {
162
+ const queryVarState = bindingTracker.getQueryVarState(callStep.level);
163
+ if (queryVarState) {
164
+ mergedStep.queryVarState = queryVarState;
165
+ }
166
+ }
167
+ // Fallback to old method if binding tracker didn't produce result
168
+ if (!mergedStep.queryVarState) {
169
+ const env = this.extractVariableEnvironment(event, this.originalQuery || '');
170
+ if (env.queryVarState) {
171
+ mergedStep.queryVarState = env.queryVarState;
172
+ }
173
+ if (env.parentContext) {
174
+ mergedStep.parentContext = env.parentContext;
175
+ }
176
+ }
177
+ mergedSteps.push(mergedStep);
178
+ processedCalls.add(callStep.stepNumber);
179
+ processedExits.add(exitStep.stepNumber);
180
+ }
181
+ else {
182
+ // CALL without EXIT (failed goal) - keep as is
183
+ mergedSteps.push(callStep);
184
+ processedCalls.add(callStep.stepNumber);
185
+ }
186
+ }
187
+ else if (event.port === 'exit') {
188
+ const exitStep = exitStepMap.get(event.level);
189
+ if (!exitStep || processedExits.has(exitStep.stepNumber))
190
+ continue;
191
+ // Process this EXIT event in the binding tracker
192
+ if (bindingTracker) {
193
+ bindingTracker.processEvent(event);
194
+ }
195
+ // EXIT without CALL (shouldn't happen, but keep for safety)
196
+ if (!processedExits.has(exitStep.stepNumber)) {
197
+ mergedSteps.push(exitStep);
198
+ processedExits.add(exitStep.stepNumber);
199
+ }
200
+ }
201
+ }
202
+ // Add any remaining REDO/FAIL steps
203
+ for (const step of this.steps) {
204
+ if (step.port === 'redo' || step.port === 'fail') {
205
+ mergedSteps.push(step);
206
+ }
207
+ }
208
+ this.steps = mergedSteps;
209
+ }
210
+ /**
211
+ * Extract complete variable environment from parent_info
212
+ * Shows the immediate parent's view of this call
213
+ */
214
+ extractVariableEnvironment(event, originalQuery) {
215
+ if (!event.parent_info || !event.parent_info.goal) {
216
+ return {};
217
+ }
218
+ const parentGoal = event.parent_info.goal;
219
+ // Skip meta-call wrappers and test predicates
220
+ if (parentGoal.includes('<meta-call>') || parentGoal === 'test_append') {
221
+ return {};
222
+ }
223
+ // Extract predicate name from both goals
224
+ const parentPredMatch = parentGoal.match(/^([^(]+)\(/);
225
+ const queryPredMatch = originalQuery.match(/^([^(]+)\(/);
226
+ if (!parentPredMatch || !queryPredMatch) {
227
+ return {};
228
+ }
229
+ // Only process if parent is same predicate (recursive call)
230
+ if (parentPredMatch[1] !== queryPredMatch[1]) {
231
+ return {};
232
+ }
233
+ // Extract result from immediate parent
234
+ const result = this.extractResultFromGoal(parentGoal);
235
+ if (result) {
236
+ const cleaned = this.cleanupVariableName(result);
237
+ // Show query variable state if it has holes (partial construction)
238
+ if (cleaned.includes('?')) {
239
+ return {
240
+ queryVarState: `X = ${cleaned}`,
241
+ parentContext: `Parent view: result = ${cleaned}`
242
+ };
243
+ }
244
+ }
245
+ return {};
246
+ }
247
+ /**
248
+ * Extract the result argument from a goal
249
+ * For append([1,2],[3,4],[1|_79854]), extract [1|_79854]
250
+ */
251
+ extractResultFromGoal(goal) {
252
+ const match = goal.match(/^([^(]+)\((.*)\)$/);
253
+ if (!match) {
254
+ return null;
255
+ }
256
+ const args = this.splitArguments(match[2]);
257
+ // Return the last argument (typically the result in Prolog predicates)
258
+ const lastArg = args[args.length - 1];
259
+ // Clean up internal variable names for display
260
+ return lastArg ? this.cleanupVariableName(lastArg) : null;
261
+ }
262
+ /**
263
+ * Clean up variable names for better readability
264
+ * Replace internal vars like _79854 and unbound clause vars like R with ? to show holes
265
+ */
266
+ cleanupVariableName(value) {
267
+ // Replace internal variables (_NNNN) with ? to show "holes"
268
+ let cleaned = value.replace(/_\d+/g, '?');
269
+ // Replace remaining unbound variables (single uppercase letters or uppercase identifiers)
270
+ // that appear as standalone terms (not part of a larger structure)
271
+ cleaned = cleaned.replace(/\b[A-Z][A-Za-z0-9_]*\b/g, '?');
272
+ return cleaned;
273
+ }
274
+ /**
275
+ * Extract the result value from an EXIT goal
276
+ * For example, from "append([1,2],[3,4],[1,2,3,4])" extract "[1,2,3,4]"
277
+ */
278
+ extractResult(exitGoal) {
279
+ const match = exitGoal.match(/^([^(]+)\((.*)\)$/);
280
+ if (!match) {
281
+ return exitGoal;
282
+ }
283
+ const args = this.splitArguments(match[2]);
284
+ // Return the last argument as the result (common pattern in Prolog)
285
+ return args[args.length - 1] || exitGoal;
286
+ }
287
+ /**
288
+ * Update subgoal tracking markers based on execution flow
289
+ * This must run after backfillClauseInfo so we have all subgoals defined
290
+ */
291
+ updateSubgoalTracking() {
292
+ // Track which subgoal is currently active at each level
293
+ const activeSubgoalMap = new Map();
294
+ for (const step of this.steps) {
295
+ // Handle both 'call' and 'merged' steps (merged steps are essentially completed calls)
296
+ if (step.port === 'call' || step.port === 'merged') {
297
+ // Check if this step is solving a subgoal
298
+ const subgoalInfo = activeSubgoalMap.get(step.level);
299
+ if (subgoalInfo) {
300
+ step.subgoalLabel = `[${subgoalInfo.parentStep}.${subgoalInfo.subgoalIndex}]`;
301
+ }
302
+ // If this step has subgoals, set up tracking for the first one
303
+ if (step.subgoals.length > 0) {
304
+ activeSubgoalMap.set(step.level + 1, {
305
+ parentStep: step.stepNumber,
306
+ subgoalIndex: 1,
307
+ });
308
+ }
309
+ // For merged steps, also handle completion logic
310
+ if (step.port === 'merged' && subgoalInfo) {
311
+ // Check if there's a next subgoal
312
+ const parentSubgoals = this.parentSubgoals.get(subgoalInfo.parentStep);
313
+ if (parentSubgoals && subgoalInfo.subgoalIndex < parentSubgoals.length) {
314
+ // Move to next subgoal
315
+ const nextSubgoalIndex = subgoalInfo.subgoalIndex + 1;
316
+ const nextSubgoalData = parentSubgoals[nextSubgoalIndex - 1];
317
+ step.nextSubgoal = `Subgoal ${nextSubgoalData.label}`;
318
+ // Update active subgoal for this level
319
+ activeSubgoalMap.set(step.level, {
320
+ parentStep: subgoalInfo.parentStep,
321
+ subgoalIndex: nextSubgoalIndex,
322
+ });
323
+ }
324
+ else {
325
+ // All subgoals completed
326
+ activeSubgoalMap.delete(step.level);
327
+ }
328
+ }
329
+ }
330
+ else if (step.port === 'exit') {
331
+ // Handle legacy EXIT steps (shouldn't exist after merging, but keep for safety)
332
+ const subgoalInfo = activeSubgoalMap.get(step.level);
333
+ if (subgoalInfo) {
334
+ step.subgoalLabel = `[${subgoalInfo.parentStep}.${subgoalInfo.subgoalIndex}]`;
335
+ // Check if there's a next subgoal
336
+ const parentSubgoals = this.parentSubgoals.get(subgoalInfo.parentStep);
337
+ if (parentSubgoals && subgoalInfo.subgoalIndex < parentSubgoals.length) {
338
+ // Move to next subgoal
339
+ const nextSubgoalIndex = subgoalInfo.subgoalIndex + 1;
340
+ const nextSubgoalData = parentSubgoals[nextSubgoalIndex - 1];
341
+ step.nextSubgoal = `Subgoal ${nextSubgoalData.label}`;
342
+ // Update active subgoal for this level
343
+ activeSubgoalMap.set(step.level, {
344
+ parentStep: subgoalInfo.parentStep,
345
+ subgoalIndex: nextSubgoalIndex,
346
+ });
347
+ }
348
+ else {
349
+ // All subgoals completed
350
+ activeSubgoalMap.delete(step.level);
351
+ }
352
+ }
353
+ }
354
+ }
355
+ }
356
+ /**
357
+ * Add variable flow tracking notes to steps
358
+ * This shows how variables from parent clauses flow into child goals
359
+ */
360
+ addVariableFlowNotes() {
361
+ // Track variable bindings: step number -> variable name -> value
362
+ const bindingMap = new Map();
363
+ for (const step of this.steps) {
364
+ // Record bindings from this step
365
+ if (step.unifications.length > 0) {
366
+ const stepBindings = new Map();
367
+ for (const unif of step.unifications) {
368
+ stepBindings.set(unif.variable, unif.value);
369
+ }
370
+ bindingMap.set(step.stepNumber, stepBindings);
371
+ }
372
+ // For EXIT steps, add notes about which variables got bound
373
+ if (step.port === 'exit' && step.returnsTo) {
374
+ const callStep = this.steps.find(s => s.stepNumber === step.returnsTo);
375
+ if (callStep && callStep.unifications.length > 0) {
376
+ const notes = [];
377
+ for (const unif of callStep.unifications) {
378
+ // Check if this variable was unbound at CALL and is now bound at EXIT
379
+ if (unif.value.startsWith('_') && !step.goal.includes(unif.value)) {
380
+ // Variable was unbound, now it's bound - extract the new value from EXIT goal
381
+ const exitValue = this.extractVariableValueFromGoal(step.goal, unif.variable, callStep.clause?.head);
382
+ if (exitValue && exitValue !== unif.value) {
383
+ notes.push(`${unif.variable} from Step ${callStep.stepNumber} is now bound to ${exitValue}`);
384
+ }
385
+ }
386
+ }
387
+ if (notes.length > 0) {
388
+ step.variableFlowNotes = notes;
389
+ }
390
+ }
391
+ }
392
+ }
393
+ }
394
+ /**
395
+ * Extract the value of a variable from a goal based on its position in the clause head
396
+ */
397
+ extractVariableValueFromGoal(goal, variable, clauseHead) {
398
+ if (!clauseHead) {
399
+ return null;
400
+ }
401
+ // Find the position of the variable in the clause head
402
+ const headMatch = clauseHead.match(/^([^(]+)\((.*)\)$/);
403
+ const goalMatch = goal.match(/^([^(]+)\((.*)\)$/);
404
+ if (!headMatch || !goalMatch) {
405
+ return null;
406
+ }
407
+ const headArgs = this.splitArguments(headMatch[2]);
408
+ const goalArgs = this.splitArguments(goalMatch[2]);
409
+ // Find which argument position contains the variable
410
+ for (let i = 0; i < headArgs.length; i++) {
411
+ if (headArgs[i].trim() === variable) {
412
+ return goalArgs[i]?.trim() || null;
413
+ }
414
+ }
415
+ return null;
416
+ }
417
+ /**
418
+ * Get source clause from the source clause map
419
+ */
420
+ getSourceClause(lineNumber) {
421
+ if (!this.sourceClauseMap) {
422
+ return null;
423
+ }
424
+ const clause = this.sourceClauseMap[lineNumber];
425
+ if (!clause) {
426
+ return null;
427
+ }
428
+ return {
429
+ head: clause.head,
430
+ body: clause.body,
431
+ number: clause.number,
432
+ };
433
+ }
434
+ /**
435
+ * Find the matching EXIT step for a CALL step
436
+ */
437
+ findMatchingExit(callStep, startIndex) {
438
+ // Look forward from the CALL to find the matching EXIT
439
+ // The EXIT should be at the same level and return to this CALL
440
+ for (let i = startIndex + 1; i < this.steps.length; i++) {
441
+ const step = this.steps[i];
442
+ if (step.port === 'exit' &&
443
+ step.level === callStep.level &&
444
+ step.returnsTo === callStep.stepNumber) {
445
+ return step;
446
+ }
447
+ }
448
+ return null;
449
+ }
450
+ /**
451
+ * Process a single trace event
452
+ */
453
+ processEvent(event) {
454
+ switch (event.port) {
455
+ case 'call':
456
+ this.processCall(event);
457
+ break;
458
+ case 'exit':
459
+ this.processExit(event);
460
+ break;
461
+ case 'redo':
462
+ this.processRedo(event);
463
+ break;
464
+ case 'fail':
465
+ this.processFail(event);
466
+ break;
467
+ }
468
+ }
469
+ /**
470
+ * Extract subgoals from a clause body
471
+ */
472
+ extractSubgoals(clauseBody) {
473
+ if (!clauseBody || clauseBody === 'true') {
474
+ return [];
475
+ }
476
+ // Split on commas, respecting parentheses depth
477
+ const subgoals = [];
478
+ let current = '';
479
+ let depth = 0;
480
+ for (const char of clauseBody) {
481
+ if (char === '(') {
482
+ depth++;
483
+ current += char;
484
+ }
485
+ else if (char === ')') {
486
+ depth--;
487
+ current += char;
488
+ }
489
+ else if (char === ',' && depth === 0) {
490
+ subgoals.push(current.trim());
491
+ current = '';
492
+ }
493
+ else {
494
+ current += char;
495
+ }
496
+ }
497
+ if (current.trim()) {
498
+ subgoals.push(current.trim());
499
+ }
500
+ return subgoals;
501
+ }
502
+ /**
503
+ * Process CALL event
504
+ */
505
+ processCall(event) {
506
+ this.stepCounter++;
507
+ const stepNumber = this.stepCounter;
508
+ // Track this call in the stack
509
+ this.callStack.set(event.level, stepNumber);
510
+ // Use source clause if available
511
+ let clauseInfo = event.clause;
512
+ if (clauseInfo && this.sourceClauseMap) {
513
+ const sourceClause = this.getSourceClause(clauseInfo.line);
514
+ if (sourceClause) {
515
+ clauseInfo = {
516
+ head: sourceClause.head,
517
+ body: sourceClause.body || 'true',
518
+ line: sourceClause.number,
519
+ };
520
+ }
521
+ }
522
+ // Extract subgoals from clause body if available
523
+ const subgoals = [];
524
+ if (clauseInfo && clauseInfo.body) {
525
+ const subgoalGoals = this.extractSubgoals(clauseInfo.body);
526
+ subgoalGoals.forEach((goal, index) => {
527
+ subgoals.push({
528
+ label: `[${stepNumber}.${index + 1}]`,
529
+ goal,
530
+ });
531
+ });
532
+ // Store subgoals for this step
533
+ if (subgoals.length > 0) {
534
+ this.parentSubgoals.set(stepNumber, subgoals);
535
+ this.completedSubgoals.set(stepNumber, 0);
536
+ }
537
+ }
538
+ // Determine if this call is solving a subgoal (will be set during backfill)
539
+ let subgoalLabel;
540
+ // Extract pattern match bindings if clause available
541
+ const unifications = [];
542
+ if (clauseInfo) {
543
+ const patternBindings = this.extractPatternMatchBindings(event.goal, clauseInfo.head);
544
+ unifications.push(...patternBindings);
545
+ }
546
+ const step = {
547
+ stepNumber,
548
+ port: 'call',
549
+ level: event.level,
550
+ goal: event.goal,
551
+ clause: clauseInfo,
552
+ unifications,
553
+ subgoals,
554
+ subgoalLabel,
555
+ };
556
+ this.steps.push(step);
557
+ }
558
+ /**
559
+ * Extract pattern match bindings by comparing goal with clause head
560
+ * Uses structural decomposition - not heuristics, just comparing known data
561
+ */
562
+ extractPatternMatchBindings(goal, clauseHead) {
563
+ const bindings = [];
564
+ // Parse both goal and clause head
565
+ const goalMatch = goal.match(/^([^(]+)\((.*)\)$/);
566
+ const headMatch = clauseHead.match(/^([^(]+)\((.*)\)$/);
567
+ if (!goalMatch || !headMatch) {
568
+ return bindings;
569
+ }
570
+ const goalArgs = this.splitArguments(goalMatch[2]);
571
+ const headArgs = this.splitArguments(headMatch[2]);
572
+ // Match arguments positionally with structural decomposition
573
+ for (let i = 0; i < Math.min(goalArgs.length, headArgs.length); i++) {
574
+ const goalArg = goalArgs[i].trim();
575
+ const headArg = headArgs[i].trim();
576
+ // Recursively extract bindings from this argument pair
577
+ this.extractBindingsFromTermPair(headArg, goalArg, bindings);
578
+ }
579
+ return bindings;
580
+ }
581
+ /**
582
+ * Recursively extract bindings by comparing pattern term with value term
583
+ * This is structural decomposition, not unification - we're just comparing strings
584
+ */
585
+ extractBindingsFromTermPair(pattern, value, bindings) {
586
+ // If pattern is a simple variable (single uppercase/underscore identifier)
587
+ if (this.isSimpleVariable(pattern)) {
588
+ bindings.push({ variable: pattern, value });
589
+ return;
590
+ }
591
+ // If pattern and value are identical, no binding needed
592
+ if (pattern === value) {
593
+ return;
594
+ }
595
+ // Try to decompose as compound term with operators
596
+ // e.g., "X+1+1" vs "0+1+1" -> extract X=0
597
+ const patternOp = this.findOperator(pattern);
598
+ const valueOp = this.findOperator(value);
599
+ if (patternOp && valueOp && patternOp.op === valueOp.op) {
600
+ // Same operator - recursively match operands
601
+ this.extractBindingsFromTermPair(patternOp.left, valueOp.left, bindings);
602
+ this.extractBindingsFromTermPair(patternOp.right, valueOp.right, bindings);
603
+ return;
604
+ }
605
+ // Try to decompose as list
606
+ // e.g., "[H|T]" vs "[1,2,3]" -> extract H=1, T=[2,3]
607
+ if (pattern.startsWith('[') && value.startsWith('[')) {
608
+ const patternList = this.parseListPattern(pattern);
609
+ const valueList = this.parseListPattern(value);
610
+ if (patternList && valueList) {
611
+ if (patternList.head && valueList.head) {
612
+ this.extractBindingsFromTermPair(patternList.head, valueList.head, bindings);
613
+ }
614
+ if (patternList.tail && valueList.tail) {
615
+ this.extractBindingsFromTermPair(patternList.tail, valueList.tail, bindings);
616
+ }
617
+ return;
618
+ }
619
+ }
620
+ // If we can't decompose further, no binding
621
+ }
622
+ /**
623
+ * Check if a term is a simple variable
624
+ */
625
+ isSimpleVariable(term) {
626
+ return /^[A-Z_][A-Za-z0-9_]*$/.test(term);
627
+ }
628
+ /**
629
+ * Find the main operator in a term
630
+ * Returns null if no operator found
631
+ */
632
+ findOperator(term) {
633
+ const operators = ['+', '-', '*', '/'];
634
+ // Find operator at depth 0 (not inside parentheses)
635
+ let depth = 0;
636
+ for (let i = term.length - 1; i >= 0; i--) {
637
+ const char = term[i];
638
+ if (char === ')' || char === ']') {
639
+ depth++;
640
+ }
641
+ else if (char === '(' || char === '[') {
642
+ depth--;
643
+ }
644
+ else if (depth === 0 && operators.includes(char)) {
645
+ return {
646
+ op: char,
647
+ left: term.slice(0, i).trim(),
648
+ right: term.slice(i + 1).trim(),
649
+ };
650
+ }
651
+ }
652
+ return null;
653
+ }
654
+ /**
655
+ * Parse a list pattern
656
+ */
657
+ parseListPattern(list) {
658
+ if (!list.startsWith('[') || !list.endsWith(']')) {
659
+ return null;
660
+ }
661
+ const content = list.slice(1, -1).trim();
662
+ // Check for [H|T] pattern
663
+ const pipeIndex = content.indexOf('|');
664
+ if (pipeIndex !== -1) {
665
+ return {
666
+ head: content.slice(0, pipeIndex).trim(),
667
+ tail: content.slice(pipeIndex + 1).trim(),
668
+ };
669
+ }
670
+ // Check for [H, ...] pattern
671
+ const commaIndex = content.indexOf(',');
672
+ if (commaIndex !== -1) {
673
+ return {
674
+ head: content.slice(0, commaIndex).trim(),
675
+ tail: `[${content.slice(commaIndex + 1).trim()}]`,
676
+ };
677
+ }
678
+ // Single element or empty list
679
+ if (content) {
680
+ return { head: content, tail: '[]' };
681
+ }
682
+ return null;
683
+ }
684
+ /**
685
+ * Extract unifications by comparing CALL and EXIT goals
686
+ */
687
+ extractUnifications(callGoal, exitGoal) {
688
+ const unifications = [];
689
+ // Simple structural comparison - extract arguments
690
+ const callMatch = callGoal.match(/^([^(]+)\((.*)\)$/);
691
+ const exitMatch = exitGoal.match(/^([^(]+)\((.*)\)$/);
692
+ if (!callMatch || !exitMatch) {
693
+ return unifications;
694
+ }
695
+ const callArgs = this.splitArguments(callMatch[2]);
696
+ const exitArgs = this.splitArguments(exitMatch[2]);
697
+ // Compare arguments positionally
698
+ for (let i = 0; i < Math.min(callArgs.length, exitArgs.length); i++) {
699
+ const callArg = callArgs[i].trim();
700
+ const exitArg = exitArgs[i].trim();
701
+ // If call arg is a variable (starts with _ or uppercase) and exit arg is different
702
+ if (callArg !== exitArg && /^[A-Z_]/.test(callArg)) {
703
+ unifications.push({
704
+ variable: callArg,
705
+ value: exitArg,
706
+ });
707
+ }
708
+ }
709
+ return unifications;
710
+ }
711
+ /**
712
+ * Split arguments respecting parentheses and brackets
713
+ */
714
+ splitArguments(argsStr) {
715
+ const args = [];
716
+ let current = '';
717
+ let depth = 0;
718
+ for (const char of argsStr) {
719
+ if (char === '(' || char === '[') {
720
+ depth++;
721
+ current += char;
722
+ }
723
+ else if (char === ')' || char === ']') {
724
+ depth--;
725
+ current += char;
726
+ }
727
+ else if (char === ',' && depth === 0) {
728
+ args.push(current.trim());
729
+ current = '';
730
+ }
731
+ else {
732
+ current += char;
733
+ }
734
+ }
735
+ if (current.trim()) {
736
+ args.push(current.trim());
737
+ }
738
+ return args;
739
+ }
740
+ /**
741
+ * Process EXIT event
742
+ */
743
+ processExit(event) {
744
+ this.stepCounter++;
745
+ const stepNumber = this.stepCounter;
746
+ // Find the matching CALL step
747
+ const callStep = this.callStack.get(event.level);
748
+ // Extract unifications by comparing with CALL goal
749
+ let unifications = [];
750
+ if (callStep) {
751
+ const callStepData = this.steps.find(s => s.stepNumber === callStep);
752
+ if (callStepData) {
753
+ unifications = this.extractUnifications(callStepData.goal, event.goal);
754
+ }
755
+ }
756
+ // Subgoal tracking will be updated in updateSubgoalTracking pass
757
+ const step = {
758
+ stepNumber,
759
+ port: 'exit',
760
+ level: event.level,
761
+ goal: event.goal,
762
+ clause: event.clause,
763
+ unifications,
764
+ subgoals: [],
765
+ returnsTo: callStep,
766
+ };
767
+ // Remove from call stack
768
+ this.callStack.delete(event.level);
769
+ this.steps.push(step);
770
+ }
771
+ /**
772
+ * Process REDO event
773
+ */
774
+ processRedo(event) {
775
+ this.stepCounter++;
776
+ const stepNumber = this.stepCounter;
777
+ // Find the step being retried
778
+ const retriedStep = this.callStack.get(event.level);
779
+ const step = {
780
+ stepNumber,
781
+ port: 'redo',
782
+ level: event.level,
783
+ goal: event.goal,
784
+ unifications: [],
785
+ subgoals: [],
786
+ note: retriedStep ? `Retrying Step ${retriedStep}` : undefined,
787
+ };
788
+ this.steps.push(step);
789
+ }
790
+ /**
791
+ * Process FAIL event
792
+ */
793
+ processFail(event) {
794
+ this.stepCounter++;
795
+ const stepNumber = this.stepCounter;
796
+ // Find parent step
797
+ const parentLevel = event.level - 1;
798
+ const parentStep = this.callStack.get(parentLevel);
799
+ const step = {
800
+ stepNumber,
801
+ port: 'fail',
802
+ level: event.level,
803
+ goal: event.goal,
804
+ unifications: [],
805
+ subgoals: [],
806
+ note: parentStep ? `Returns to Step ${parentStep}` : undefined,
807
+ };
808
+ // Remove from call stack
809
+ this.callStack.delete(event.level);
810
+ this.steps.push(step);
811
+ }
812
+ }
813
+ //# sourceMappingURL=timeline.js.map