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.
- package/README.md +148 -0
- package/dist/analyzer.d.ts +64 -0
- package/dist/analyzer.d.ts.map +1 -0
- package/dist/analyzer.js +903 -0
- package/dist/analyzer.js.map +1 -0
- package/dist/build-info.d.ts +15 -0
- package/dist/build-info.d.ts.map +1 -0
- package/dist/build-info.js +26 -0
- package/dist/build-info.js.map +1 -0
- package/dist/clauses.d.ts +15 -0
- package/dist/clauses.d.ts.map +1 -0
- package/dist/clauses.js +80 -0
- package/dist/clauses.js.map +1 -0
- package/dist/cli.d.ts +34 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +162 -0
- package/dist/cli.js.map +1 -0
- package/dist/errors.d.ts +19 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +66 -0
- package/dist/errors.js.map +1 -0
- package/dist/executor.d.ts +25 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +115 -0
- package/dist/executor.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +128 -0
- package/dist/index.js.map +1 -0
- package/dist/mermaid.d.ts +47 -0
- package/dist/mermaid.d.ts.map +1 -0
- package/dist/mermaid.js +197 -0
- package/dist/mermaid.js.map +1 -0
- package/dist/output.d.ts +24 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +40 -0
- package/dist/output.js.map +1 -0
- package/dist/parser.d.ts +84 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +1007 -0
- package/dist/parser.js.map +1 -0
- package/dist/prolog-parser.d.ts +2 -0
- package/dist/prolog-parser.d.ts.map +1 -0
- package/dist/prolog-parser.js +2 -0
- package/dist/prolog-parser.js.map +1 -0
- package/dist/renderer.d.ts +33 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +131 -0
- package/dist/renderer.js.map +1 -0
- package/dist/wrapper.d.ts +30 -0
- package/dist/wrapper.d.ts.map +1 -0
- package/dist/wrapper.js +87 -0
- package/dist/wrapper.js.map +1 -0
- package/package.json +55 -0
- package/scripts/generate-build-info.js +63 -0
- package/scripts/release.js +151 -0
- package/tracer.pl +202 -0
package/dist/analyzer.js
ADDED
|
@@ -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
|