@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.
- package/README.md +411 -0
- package/package.json +48 -0
- package/src/api/DomainNode.ts +1433 -0
- package/src/api/Graph.ts +271 -0
- package/src/api/PathBuilder.ts +247 -0
- package/src/api/index.ts +15 -0
- package/src/api/loadGraph.ts +207 -0
- package/src/api/test-api.ts +153 -0
- package/src/api/test-domain.ts +119 -0
- package/src/api/types.ts +88 -0
- package/src/config/synonyms.json +28 -0
- package/src/core/EventBus.ts +187 -0
- package/src/core/GraphEvents.ts +153 -0
- package/src/core/PathFinder.ts +283 -0
- package/src/formatters/BaseFormatter.ts +17 -0
- package/src/graph/GraphAssembler.ts +50 -0
- package/src/graph/GraphCompiler.ts +412 -0
- package/src/graph/GraphExtractor.ts +191 -0
- package/src/graph/GraphOptimizer.ts +404 -0
- package/src/graph/GraphTrainer.ts +247 -0
- package/src/http/LinkBuilder.ts +244 -0
- package/src/http/TrailRequest.ts +48 -0
- package/src/http/example-netflix.ts +59 -0
- package/src/http/hateoas/README.md +87 -0
- package/src/http/index.ts +33 -0
- package/src/http/plugin.ts +360 -0
- package/src/index.ts +121 -0
- package/src/instrumentation/TelemetryShim.ts +172 -0
- package/src/navigation/NavigationEngine.ts +441 -0
- package/src/navigation/Resolver.ts +134 -0
- package/src/navigation/Scheduler.ts +136 -0
- package/src/navigation/Trail.ts +252 -0
- package/src/navigation/TrailParser.ts +207 -0
- package/src/navigation/index.ts +11 -0
- package/src/providers/MockProvider.ts +68 -0
- package/src/providers/PostgresProvider.ts +187 -0
- package/src/runtime/CompiledGraphEngine.ts +274 -0
- package/src/runtime/DataLoader.ts +236 -0
- package/src/runtime/Engine.ts +163 -0
- package/src/runtime/QueryEngine.ts +222 -0
- package/src/scenarios/test-metro-paris/config.json +6 -0
- package/src/scenarios/test-metro-paris/graph.json +16325 -0
- package/src/scenarios/test-metro-paris/queries.ts +152 -0
- package/src/scenarios/test-metro-paris/stack.json +1 -0
- package/src/scenarios/test-musicians/config.json +10 -0
- package/src/scenarios/test-musicians/graph.json +20 -0
- package/src/scenarios/test-musicians/stack.json +1 -0
- package/src/scenarios/test-netflix/MIGRATION.md +23 -0
- package/src/scenarios/test-netflix/README.md +138 -0
- package/src/scenarios/test-netflix/actions.ts +92 -0
- package/src/scenarios/test-netflix/config.json +6 -0
- package/src/scenarios/test-netflix/data/categories.json +1 -0
- package/src/scenarios/test-netflix/data/companies.json +1 -0
- package/src/scenarios/test-netflix/data/credits.json +19797 -0
- package/src/scenarios/test-netflix/data/departments.json +18 -0
- package/src/scenarios/test-netflix/data/jobs.json +142 -0
- package/src/scenarios/test-netflix/data/movies.json +3497 -0
- package/src/scenarios/test-netflix/data/people.json +1 -0
- package/src/scenarios/test-netflix/data/synonyms.json +8 -0
- package/src/scenarios/test-netflix/data/users.json +70 -0
- package/src/scenarios/test-netflix/graph.json +1017 -0
- package/src/scenarios/test-netflix/queries.ts +159 -0
- package/src/scenarios/test-netflix/stack.json +14 -0
- package/src/schema/GraphBuilder.ts +106 -0
- package/src/schema/JsonSchemaExtractor.ts +107 -0
- package/src/schema/SchemaAnalyzer.ts +175 -0
- package/src/schema/SchemaExtractor.ts +102 -0
- package/src/schema/SynonymResolver.ts +143 -0
- package/src/scripts/dictionary.json +796 -0
- package/src/scripts/graph.json +664 -0
- package/src/scripts/regenerate.ts +248 -0
- 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
|
+
}
|