@linklabjs/core 0.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 (72) hide show
  1. package/README.md +411 -0
  2. package/package.json +48 -0
  3. package/src/api/DomainNode.ts +1433 -0
  4. package/src/api/Graph.ts +271 -0
  5. package/src/api/PathBuilder.ts +247 -0
  6. package/src/api/index.ts +15 -0
  7. package/src/api/loadGraph.ts +207 -0
  8. package/src/api/test-api.ts +153 -0
  9. package/src/api/test-domain.ts +119 -0
  10. package/src/api/types.ts +88 -0
  11. package/src/config/synonyms.json +28 -0
  12. package/src/core/EventBus.ts +187 -0
  13. package/src/core/GraphEvents.ts +153 -0
  14. package/src/core/PathFinder.ts +283 -0
  15. package/src/formatters/BaseFormatter.ts +17 -0
  16. package/src/graph/GraphAssembler.ts +50 -0
  17. package/src/graph/GraphCompiler.ts +412 -0
  18. package/src/graph/GraphExtractor.ts +191 -0
  19. package/src/graph/GraphOptimizer.ts +404 -0
  20. package/src/graph/GraphTrainer.ts +247 -0
  21. package/src/http/LinkBuilder.ts +244 -0
  22. package/src/http/TrailRequest.ts +48 -0
  23. package/src/http/example-netflix.ts +59 -0
  24. package/src/http/hateoas/README.md +87 -0
  25. package/src/http/index.ts +33 -0
  26. package/src/http/plugin.ts +360 -0
  27. package/src/index.ts +121 -0
  28. package/src/instrumentation/TelemetryShim.ts +172 -0
  29. package/src/navigation/NavigationEngine.ts +441 -0
  30. package/src/navigation/Resolver.ts +134 -0
  31. package/src/navigation/Scheduler.ts +136 -0
  32. package/src/navigation/Trail.ts +252 -0
  33. package/src/navigation/TrailParser.ts +207 -0
  34. package/src/navigation/index.ts +11 -0
  35. package/src/providers/MockProvider.ts +68 -0
  36. package/src/providers/PostgresProvider.ts +187 -0
  37. package/src/runtime/CompiledGraphEngine.ts +274 -0
  38. package/src/runtime/DataLoader.ts +236 -0
  39. package/src/runtime/Engine.ts +163 -0
  40. package/src/runtime/QueryEngine.ts +222 -0
  41. package/src/scenarios/test-metro-paris/config.json +6 -0
  42. package/src/scenarios/test-metro-paris/graph.json +16325 -0
  43. package/src/scenarios/test-metro-paris/queries.ts +152 -0
  44. package/src/scenarios/test-metro-paris/stack.json +1 -0
  45. package/src/scenarios/test-musicians/config.json +10 -0
  46. package/src/scenarios/test-musicians/graph.json +20 -0
  47. package/src/scenarios/test-musicians/stack.json +1 -0
  48. package/src/scenarios/test-netflix/MIGRATION.md +23 -0
  49. package/src/scenarios/test-netflix/README.md +138 -0
  50. package/src/scenarios/test-netflix/actions.ts +92 -0
  51. package/src/scenarios/test-netflix/config.json +6 -0
  52. package/src/scenarios/test-netflix/data/categories.json +1 -0
  53. package/src/scenarios/test-netflix/data/companies.json +1 -0
  54. package/src/scenarios/test-netflix/data/credits.json +19797 -0
  55. package/src/scenarios/test-netflix/data/departments.json +18 -0
  56. package/src/scenarios/test-netflix/data/jobs.json +142 -0
  57. package/src/scenarios/test-netflix/data/movies.json +3497 -0
  58. package/src/scenarios/test-netflix/data/people.json +1 -0
  59. package/src/scenarios/test-netflix/data/synonyms.json +8 -0
  60. package/src/scenarios/test-netflix/data/users.json +70 -0
  61. package/src/scenarios/test-netflix/graph.json +1017 -0
  62. package/src/scenarios/test-netflix/queries.ts +159 -0
  63. package/src/scenarios/test-netflix/stack.json +14 -0
  64. package/src/schema/GraphBuilder.ts +106 -0
  65. package/src/schema/JsonSchemaExtractor.ts +107 -0
  66. package/src/schema/SchemaAnalyzer.ts +175 -0
  67. package/src/schema/SchemaExtractor.ts +102 -0
  68. package/src/schema/SynonymResolver.ts +143 -0
  69. package/src/scripts/dictionary.json +796 -0
  70. package/src/scripts/graph.json +664 -0
  71. package/src/scripts/regenerate.ts +248 -0
  72. package/src/types/index.ts +506 -0
@@ -0,0 +1,404 @@
1
+ /**
2
+ * GraphOptimizer — Analyse et rapport sur la qualité du graphe
3
+ *
4
+ * PRINCIPE : signaler, jamais détruire silencieusement.
5
+ *
6
+ * Chaque étape produit un rapport (warnings, suggestions).
7
+ * Le dev décide ensuite de ce qu'il fait.
8
+ *
9
+ * Seules deux opérations sont automatiques et non destructives :
10
+ * - Suppression des nœuds orphelins (aucune arête — objectivement inutiles)
11
+ * - Suppression des nœuds dead-end stricts (aucune arête entrante ET sortante)
12
+ *
13
+ * Les cycles sont DÉTECTÉS et CLASSIFIÉS, jamais supprimés :
14
+ * - SELF_LOOP : arête A → A (ex: Station-chatelet → Station-chatelet TRANSFER)
15
+ * - BIDIRECTIONAL : A → B et B → A (ex: CREATED + CREDITED — intentionnel)
16
+ * - STRUCTURAL_CYCLE : A → B → C → A (même type de relation — potentiellement problématique)
17
+ */
18
+
19
+ import type { Graph, GraphEdge, GraphNode } from '../types/index.js'
20
+ import { PathFinder } from '../core/PathFinder.js'
21
+
22
+ // ==================== TYPES ====================
23
+
24
+ export type CycleType = 'SELF_LOOP' | 'BIDIRECTIONAL' | 'STRUCTURAL_CYCLE'
25
+ export type WarningSeverity = 'INFO' | 'WARNING'
26
+
27
+ export interface CycleWarning {
28
+ type: CycleType
29
+ severity: WarningSeverity
30
+ edges: string[] // noms des arêtes impliquées
31
+ nodes: string[] // nœuds impliqués
32
+ note: string
33
+ }
34
+
35
+ export interface DuplicatePathWarning {
36
+ from: string
37
+ to: string
38
+ paths: string[][] // tous les chemins entre ces deux nœuds
39
+ note: string
40
+ }
41
+
42
+ export interface GraphOptimizationReport {
43
+ graph: Graph // graphe inchangé (ou avec suppressions safe uniquement)
44
+ summary: {
45
+ nodes: { before: number; after: number; removed: number }
46
+ edges: { before: number; after: number; removed: number }
47
+ }
48
+ cycles: CycleWarning[]
49
+ duplicatePaths: DuplicatePathWarning[]
50
+ removedOrphans: string[]
51
+ removedDeadEnds: string[]
52
+ isClean: boolean // true si aucun warning
53
+ }
54
+
55
+ // ==================== OPTIMIZER ====================
56
+
57
+ export interface GraphOptimizerConfig {
58
+ /**
59
+ * Types de relations bidirectionnelles considérés comme intentionnels (INFO, pas WARNING).
60
+ * Ex: ['DIRECT', 'TRANSFER', 'physical_reverse', 'INFLUENCE']
61
+ * Par défaut : ['physical_reverse'] — les inverses FK sont toujours intentionnels.
62
+ */
63
+ intentionalBidirectional?: string[]
64
+
65
+ /**
66
+ * Types de self-loops considérés comme intentionnels (INFO, pas WARNING).
67
+ * Ex: ['TRANSFER'] — les correspondances métro sont des self-loops normaux.
68
+ * Par défaut : [] — tout self-loop est signalé.
69
+ */
70
+ intentionalSelfLoops?: string[]
71
+ }
72
+
73
+ const DEFAULT_CONFIG: Required<GraphOptimizerConfig> = {
74
+ intentionalBidirectional: ['physical_reverse'],
75
+ intentionalSelfLoops: []
76
+ }
77
+
78
+ export class GraphOptimizer {
79
+
80
+ private config: Required<GraphOptimizerConfig>
81
+
82
+ constructor(private graph: Graph, config: GraphOptimizerConfig = {}) {
83
+ this.config = {
84
+ intentionalBidirectional: config.intentionalBidirectional ?? DEFAULT_CONFIG.intentionalBidirectional,
85
+ intentionalSelfLoops: config.intentionalSelfLoops ?? DEFAULT_CONFIG.intentionalSelfLoops
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Pipeline complet — retourne un rapport, ne modifie pas le graphe original.
91
+ * Seuls orphelins et dead-ends stricts sont supprimés (safe).
92
+ */
93
+ optimize(): GraphOptimizationReport {
94
+ console.log('🔧 GraphOptimizer — analyse du graphe...')
95
+
96
+ const before = {
97
+ nodes: this.graph.nodes.length,
98
+ edges: this.graph.edges.length
99
+ }
100
+
101
+ // Travailler sur une copie
102
+ const working: Graph = {
103
+ nodes: [...this.graph.nodes],
104
+ edges: [...this.graph.edges]
105
+ }
106
+
107
+ // Opérations safe (non destructives sémantiquement)
108
+ const removedOrphans = this.removeOrphans(working)
109
+ const removedDeadEnds = this.removeStrictDeadEnds(working)
110
+
111
+ // Analyse — rapport uniquement, pas de suppression
112
+ const cycles = this.detectCycles(working)
113
+ const duplicatePaths = this.detectDuplicatePaths(working)
114
+
115
+ const after = {
116
+ nodes: working.nodes.length,
117
+ edges: working.edges.length
118
+ }
119
+
120
+ // Résumé console
121
+ console.log(` Nœuds : ${before.nodes} → ${after.nodes} (-${before.nodes - after.nodes})`)
122
+ console.log(` Arêtes : ${before.edges} → ${after.edges} (-${before.edges - after.edges})`)
123
+ console.log(` Cycles : ${cycles.length} détecté(s)`)
124
+ console.log(` Chemins dupliqués : ${duplicatePaths.length} paire(s)`)
125
+
126
+ const isClean = cycles.filter(c => c.severity === 'WARNING').length === 0
127
+
128
+ if (isClean) {
129
+ console.log(' ✅ Graphe propre')
130
+ } else {
131
+ console.log(` ⚠️ ${cycles.filter(c => c.severity === 'WARNING').length} warning(s) à examiner`)
132
+ }
133
+
134
+ const report: GraphOptimizationReport = {
135
+ graph: working,
136
+ summary: {
137
+ nodes: { before: before.nodes, after: after.nodes, removed: before.nodes - after.nodes },
138
+ edges: { before: before.edges, after: after.edges, removed: before.edges - after.edges }
139
+ },
140
+ cycles,
141
+ duplicatePaths,
142
+ removedOrphans,
143
+ removedDeadEnds,
144
+ isClean
145
+ }
146
+
147
+ this.printReport(report)
148
+ return report
149
+ }
150
+
151
+ // ==================== CYCLES ====================
152
+
153
+ /**
154
+ * Détecte et classifie les cycles — ne supprime rien.
155
+ */
156
+ private detectCycles(graph: Graph): CycleWarning[] {
157
+ const warnings: CycleWarning[] = []
158
+ const seen = new Set<string>()
159
+
160
+ for (const edge of graph.edges) {
161
+
162
+ // 1. SELF_LOOP : A → A
163
+ if (edge.from === edge.to) {
164
+ const key = `SELF:${edge.name}`
165
+ if (!seen.has(key)) {
166
+ seen.add(key)
167
+ const edgeType = edge.metadata?.type ?? edge.via ?? ''
168
+ const isIntentional = this.config.intentionalSelfLoops.includes(edgeType)
169
+ warnings.push({
170
+ type: 'SELF_LOOP',
171
+ severity: 'INFO',
172
+ edges: [edge.name ?? `${edge.from}→${edge.to}`],
173
+ nodes: [edge.from],
174
+ note: isIntentional
175
+ ? `Self-loop intentionnel (${edgeType}) sur ${edge.from}. Géré par Dijkstra.`
176
+ : `Boucle sur ${edge.from}. Géré par Dijkstra (visited), inoffensif.`
177
+ })
178
+ }
179
+ continue
180
+ }
181
+
182
+ // 2. BIDIRECTIONAL : A → B et B → A
183
+ const reverse = graph.edges.find(e => e.from === edge.to && e.to === edge.from)
184
+ if (reverse) {
185
+ const key = [edge.from, edge.to].sort().join('↔')
186
+ if (!seen.has(key)) {
187
+ seen.add(key)
188
+ const typeA = edge.metadata?.type ?? ''
189
+ const typeB = reverse.metadata?.type ?? ''
190
+ const sameType = typeA === typeB
191
+ const isIntentional =
192
+ !sameType ||
193
+ this.config.intentionalBidirectional.includes(typeA) ||
194
+ this.config.intentionalBidirectional.includes(typeB)
195
+ warnings.push({
196
+ type: 'BIDIRECTIONAL',
197
+ severity: isIntentional ? 'INFO' : 'WARNING',
198
+ edges: [
199
+ edge.name ?? `${edge.from}→${edge.to}`,
200
+ reverse.name ?? `${reverse.from}→${reverse.to}`
201
+ ],
202
+ nodes: [edge.from, edge.to],
203
+ note: isIntentional
204
+ ? `Bidirectionnel intentionnel (${typeA} ↔ ${typeB}) — normal.`
205
+ : `Bidirectionnel de même type "${typeA}" non déclaré intentionnel — vérifier.`
206
+ })
207
+ }
208
+ }
209
+ }
210
+
211
+ // 3. STRUCTURAL_CYCLE : A → B → C → A (même type de relation)
212
+ const structuralCycles = this.detectStructuralCycles(graph)
213
+ warnings.push(...structuralCycles)
214
+
215
+ return warnings
216
+ }
217
+
218
+ /**
219
+ * Détecte les cycles structurels A → B → ... → A
220
+ * en ne suivant que les arêtes du même type.
221
+ */
222
+ private detectStructuralCycles(graph: Graph): CycleWarning[] {
223
+ const warnings: CycleWarning[] = []
224
+ const reportedCycles = new Set<string>()
225
+
226
+ // Grouper les arêtes par type
227
+ const byType = new Map<string, GraphEdge[]>()
228
+ for (const edge of graph.edges) {
229
+ const type = edge.metadata?.type ?? edge.via ?? 'unknown'
230
+ if (!byType.has(type)) byType.set(type, [])
231
+ byType.get(type)!.push(edge)
232
+ }
233
+
234
+ for (const [type, edges] of byType) {
235
+ // DFS sur les arêtes de ce type uniquement
236
+ const visited = new Set<string>()
237
+ const inPath = new Set<string>()
238
+ const pathStack: string[] = []
239
+
240
+ const dfs = (node: string): string[] | null => {
241
+ if (inPath.has(node)) {
242
+ // Cycle trouvé — extraire le cycle
243
+ const cycleStart = pathStack.indexOf(node)
244
+ return pathStack.slice(cycleStart)
245
+ }
246
+ if (visited.has(node)) return null
247
+
248
+ visited.add(node)
249
+ inPath.add(node)
250
+ pathStack.push(node)
251
+
252
+ const neighbors = edges.filter(e => e.from === node).map(e => e.to)
253
+ for (const neighbor of neighbors) {
254
+ const cycle = dfs(neighbor)
255
+ if (cycle) return cycle
256
+ }
257
+
258
+ pathStack.pop()
259
+ inPath.delete(node)
260
+ return null
261
+ }
262
+
263
+ for (const edge of edges) {
264
+ const cycle = dfs(edge.from)
265
+ if (cycle) {
266
+ const key = [...cycle].sort().join(',')
267
+ if (!reportedCycles.has(key)) {
268
+ reportedCycles.add(key)
269
+ warnings.push({
270
+ type: 'STRUCTURAL_CYCLE',
271
+ severity: 'WARNING',
272
+ edges: [],
273
+ nodes: cycle,
274
+ note: `Cycle structurel sur le type "${type}" : ${cycle.join(' → ')} → ${cycle[0]}`
275
+ })
276
+ }
277
+ }
278
+ visited.clear()
279
+ inPath.clear()
280
+ pathStack.length = 0
281
+ }
282
+ }
283
+
284
+ return warnings
285
+ }
286
+
287
+ // ==================== SUPPRESSIONS SAFE ====================
288
+
289
+ /**
290
+ * Supprime les nœuds sans aucune arête (entrante ou sortante).
291
+ * Inoffensif — un nœud isolé ne contribue à aucune traversée.
292
+ */
293
+ private removeOrphans(graph: Graph): string[] {
294
+ const connected = new Set<string>()
295
+ for (const edge of graph.edges) {
296
+ connected.add(edge.from)
297
+ connected.add(edge.to)
298
+ }
299
+
300
+ const orphans = graph.nodes
301
+ .filter(n => !connected.has(n.id))
302
+ .map(n => n.id)
303
+
304
+ graph.nodes = graph.nodes.filter(n => connected.has(n.id))
305
+
306
+ if (orphans.length > 0) {
307
+ console.log(` 🗑️ Orphelins supprimés : ${orphans.join(', ')}`)
308
+ }
309
+
310
+ return orphans
311
+ }
312
+
313
+ /**
314
+ * Supprime les nœuds sans arête entrante ET sans arête sortante
315
+ * après suppression des orphelins.
316
+ * Différent de removeOrphans — cible les nœuds stricts.
317
+ */
318
+ private removeStrictDeadEnds(graph: Graph): string[] {
319
+ const hasIncoming = new Set<string>()
320
+ const hasOutgoing = new Set<string>()
321
+
322
+ for (const edge of graph.edges) {
323
+ hasOutgoing.add(edge.from)
324
+ hasIncoming.add(edge.to)
325
+ }
326
+
327
+ const deadEnds = graph.nodes
328
+ .filter(n => !hasIncoming.has(n.id) && !hasOutgoing.has(n.id))
329
+ .map(n => n.id)
330
+
331
+ // Déjà couverts par removeOrphans — cette passe est redondante
332
+ // mais explicite pour la lisibilité
333
+ graph.nodes = graph.nodes.filter(n => !deadEnds.includes(n.id))
334
+
335
+ return deadEnds
336
+ }
337
+
338
+ // ==================== DUPLICATES ====================
339
+
340
+ /**
341
+ * Détecte les paires de nœuds avec plusieurs chemins possibles.
342
+ * Informatif — les chemins multiples sont souvent intentionnels (fallbacks).
343
+ */
344
+ private detectDuplicatePaths(graph: Graph): DuplicatePathWarning[] {
345
+ const warnings: DuplicatePathWarning[] = []
346
+ const finder = new PathFinder(graph)
347
+
348
+ for (const from of graph.nodes) {
349
+ for (const to of graph.nodes) {
350
+ if (from.id === to.id) continue
351
+
352
+ try {
353
+ const paths = finder.findAllPaths(from.id, to.id, 5)
354
+ if (paths.length > 1) {
355
+ warnings.push({
356
+ from: from.id,
357
+ to: to.id,
358
+ paths: paths.map(p => p),
359
+ note: `${paths.length} chemins entre ${from.id} et ${to.id} — le plus court sera utilisé par défaut.`
360
+ })
361
+ }
362
+ } catch {
363
+ // Ignorer les erreurs de traversée
364
+ }
365
+ }
366
+ }
367
+
368
+ return warnings
369
+ }
370
+
371
+ // ==================== RAPPORT ====================
372
+
373
+ private printReport(report: GraphOptimizationReport): void {
374
+ if (report.cycles.length === 0 && report.duplicatePaths.length === 0) return
375
+
376
+ console.log('\n📋 RAPPORT GraphOptimizer\n')
377
+
378
+ if (report.removedOrphans.length > 0) {
379
+ console.log(`🗑️ Orphelins supprimés (${report.removedOrphans.length}) :`)
380
+ report.removedOrphans.forEach(n => console.log(` - ${n}`))
381
+ }
382
+
383
+ // Cycles WARNING uniquement (les INFO sont attendus)
384
+ const cycleWarnings = report.cycles.filter(c => c.severity === 'WARNING')
385
+ if (cycleWarnings.length > 0) {
386
+ console.log(`\n⚠️ Cycles à examiner (${cycleWarnings.length}) :`)
387
+ cycleWarnings.forEach(c => {
388
+ console.log(` [${c.type}] ${c.note}`)
389
+ })
390
+ }
391
+
392
+ const cycleInfos = report.cycles.filter(c => c.severity === 'INFO')
393
+ if (cycleInfos.length > 0) {
394
+ console.log(`\nℹ️ Cycles intentionnels (${cycleInfos.length}) :`)
395
+ cycleInfos.forEach(c => {
396
+ console.log(` [${c.type}] ${c.note}`)
397
+ })
398
+ }
399
+
400
+ if (report.duplicatePaths.length > 0) {
401
+ console.log(`\nℹ️ Chemins multiples (${report.duplicatePaths.length} paires) — fallbacks disponibles`)
402
+ }
403
+ }
404
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * GraphTrainer - Trains graph with real use cases
3
+ *
4
+ * Benchmarks all paths and assigns weights based on actual performance
5
+ */
6
+
7
+ import type { Graph, UseCase, MetricsMap, TrainingMetrics, Provider } from '../types/index.js'
8
+ import { PathFinder } from '../core/PathFinder.js'
9
+
10
+ export class GraphTrainer {
11
+ private graph: Graph
12
+ private provider: Provider
13
+ private metrics: MetricsMap
14
+
15
+ constructor(graph: Graph, provider: Provider) {
16
+ this.graph = graph
17
+ this.provider = provider
18
+ this.metrics = new Map()
19
+ }
20
+
21
+ /**
22
+ * Train graph with use cases
23
+ */
24
+ async train(useCases: UseCase[]): Promise<MetricsMap> {
25
+ console.log(`🎓 Training graph with ${useCases.length} use cases...\n`)
26
+
27
+ for (const [index, useCase] of useCases.entries()) {
28
+ console.log(` [${index + 1}/${useCases.length}] ${useCase.description}`)
29
+ await this.trainUseCase(useCase)
30
+ }
31
+
32
+ console.log('\n✅ Training complete')
33
+ console.log(` Tested ${this.metrics.size} unique paths`)
34
+
35
+ return this.metrics
36
+ }
37
+
38
+ /**
39
+ * Train single use case
40
+ */
41
+ private async trainUseCase(useCase: UseCase): Promise<void> {
42
+ const { from, to, sampleData } = useCase
43
+
44
+ // Find all paths
45
+ const finder = new PathFinder(this.graph)
46
+ const paths = finder.findAllPaths(from, to)
47
+
48
+ console.log(` Found ${paths.length} possible paths`)
49
+
50
+ // Benchmark each path
51
+ for (const path of paths) {
52
+ await this.benchmarkPath(path, sampleData)
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Benchmark a specific path
58
+ */
59
+ private async benchmarkPath(path: string[], sampleData?: Record<string, any>): Promise<void> {
60
+ const pathKey = path.join('→')
61
+
62
+ try {
63
+ // Build SQL query
64
+ const query = this.buildQuery(path, sampleData)
65
+
66
+ // Execute multiple times for average
67
+ const iterations = 3
68
+ const times: number[] = []
69
+
70
+ for (let i = 0; i < iterations; i++) {
71
+ const start = performance.now()
72
+ await this.provider.query(query.sql, query.params)
73
+ const duration = performance.now() - start
74
+ times.push(duration)
75
+ }
76
+
77
+ const avgTime = times.reduce((a, b) => a + b, 0) / times.length
78
+ const minTime = Math.min(...times)
79
+ const maxTime = Math.max(...times)
80
+
81
+ // Store metrics
82
+ if (!this.metrics.has(pathKey)) {
83
+ this.metrics.set(pathKey, {
84
+ path,
85
+ executions: 0,
86
+ successes: 0,
87
+ failures: 0,
88
+ totalTime: 0,
89
+ avgTime: 0,
90
+ minTime: Infinity,
91
+ maxTime: 0,
92
+ used: true
93
+ })
94
+ }
95
+
96
+ const metric = this.metrics.get(pathKey)!
97
+ metric.executions += iterations
98
+ metric.successes = (metric.successes || 0) + iterations
99
+ metric.totalTime += avgTime * iterations
100
+ metric.avgTime = metric.totalTime / metric.executions
101
+ metric.minTime = Math.min(metric.minTime, minTime)
102
+ metric.maxTime = Math.max(metric.maxTime, maxTime)
103
+
104
+ console.log(` ✓ ${pathKey}: ${avgTime.toFixed(2)}ms avg`)
105
+ } catch (err) {
106
+ console.log(` ✗ ${pathKey}: Failed - ${(err as Error).message}`)
107
+
108
+ // Mark as failed
109
+ this.metrics.set(pathKey, {
110
+ path,
111
+ executions: 0,
112
+ totalTime: 0,
113
+ avgTime: 0,
114
+ minTime: 0,
115
+ maxTime: 0,
116
+ used: false,
117
+ failed: true,
118
+ error: (err as Error).message
119
+ })
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Build SQL query for a path
125
+ */
126
+ private buildQuery(
127
+ path: string[],
128
+ sampleData?: Record<string, any>
129
+ ): { sql: string; params: any[] } {
130
+ // Start with first table
131
+ let sql = `SELECT * FROM ${path[0]}`
132
+ const params: any[] = []
133
+
134
+ // Add JOINs
135
+ for (let i = 1; i < path.length; i++) {
136
+ const from = path[i - 1]
137
+ const to = path[i]
138
+
139
+ // Find edge
140
+ const edge = this.graph.edges.find(e => e.from === from && e.to === to)
141
+
142
+ if (edge) {
143
+ sql += ` JOIN ${to} ON ${from}.${edge.via} = ${to}.id`
144
+ }
145
+ }
146
+
147
+ // Add WHERE if sample data provided
148
+ if (sampleData?.id) {
149
+ sql += ` WHERE ${path[0]}.id = $${params.length + 1}`
150
+ params.push(sampleData.id)
151
+ }
152
+
153
+ // Limit for safety
154
+ sql += ' LIMIT 100'
155
+
156
+ return { sql, params }
157
+ }
158
+
159
+ /**
160
+ * Update graph weights based on metrics
161
+ */
162
+ updateWeights(): void {
163
+ console.log('📊 Updating graph weights based on metrics...')
164
+
165
+ let updated = 0
166
+
167
+ for (const edge of this.graph.edges) {
168
+ // Find all paths using this edge
169
+ const pathsWithEdge = Array.from(this.metrics.values()).filter(
170
+ m => !m.failed && this.pathUsesEdge(m.path, edge)
171
+ )
172
+
173
+ if (pathsWithEdge.length === 0) continue
174
+
175
+ // Calculate new weight (average time)
176
+ const avgTime = pathsWithEdge.reduce((sum, m) => sum + m.avgTime, 0) / pathsWithEdge.length
177
+
178
+ // Normalize to 0-100 scale
179
+ const newWeight = Math.min(100, avgTime)
180
+
181
+ if (edge.weight !== newWeight) {
182
+ edge.weight = newWeight
183
+ updated++
184
+ }
185
+ }
186
+
187
+ console.log(` Updated ${updated} edge weights`)
188
+ }
189
+
190
+ /**
191
+ * Check if path uses edge
192
+ */
193
+ private pathUsesEdge(path: string[], edge: { from: string; to: string }): boolean {
194
+ for (let i = 0; i < path.length - 1; i++) {
195
+ if (path[i] === edge.from && path[i + 1] === edge.to) {
196
+ return true
197
+ }
198
+ }
199
+ return false
200
+ }
201
+
202
+ /**
203
+ * Get training statistics
204
+ */
205
+ getStats(): {
206
+ total: number
207
+ successful: number
208
+ failed: number
209
+ avgTime: number
210
+ fastest: TrainingMetrics | undefined
211
+ slowest: TrainingMetrics | undefined
212
+ } {
213
+ const successful = Array.from(this.metrics.values()).filter(m => !m.failed)
214
+ const failed = Array.from(this.metrics.values()).filter(m => m.failed)
215
+
216
+ const avgTime =
217
+ successful.length > 0
218
+ ? successful.reduce((sum, m) => sum + m.avgTime, 0) / successful.length
219
+ : 0
220
+
221
+ const fastest = successful.reduce<TrainingMetrics | undefined>(
222
+ (min, m) => (!min || m.avgTime < min.avgTime ? m : min),
223
+ undefined
224
+ )
225
+
226
+ const slowest = successful.reduce<TrainingMetrics | undefined>(
227
+ (max, m) => (!max || m.avgTime > max.avgTime ? m : max),
228
+ undefined
229
+ )
230
+
231
+ return {
232
+ total: this.metrics.size,
233
+ successful: successful.length,
234
+ failed: failed.length,
235
+ avgTime,
236
+ fastest,
237
+ slowest
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Get metrics map
243
+ */
244
+ getMetrics(): MetricsMap {
245
+ return this.metrics
246
+ }
247
+ }