@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,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventBus — Bus générique pour hooks, events et errors
|
|
3
|
+
*
|
|
4
|
+
* Trois bus distincts, trois contrats clairs :
|
|
5
|
+
*
|
|
6
|
+
* HookBus — awaitable, peut modifier/annuler le flux
|
|
7
|
+
* EventBus — fire-and-forget, observationnel, ne bloque pas
|
|
8
|
+
* ErrorBus — synchrone, jamais silencieux
|
|
9
|
+
*
|
|
10
|
+
* Usage :
|
|
11
|
+
* const bus = new EventBus<MyEvents>()
|
|
12
|
+
* bus.on('traversal.complete', handler)
|
|
13
|
+
* bus.emit('traversal.complete', data)
|
|
14
|
+
* bus.off('traversal.complete', handler)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ── Types de base ─────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export type Handler<T = any> = (data: T) => void
|
|
20
|
+
export type AsyncHandler<T = any, R = T | void> = (data: T) => Promise<R> | R
|
|
21
|
+
|
|
22
|
+
// ── EventBus — fire-and-forget ────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export class EventBus<TEvents extends Record<string, any> = Record<string, any>> {
|
|
25
|
+
private handlers = new Map<string, Set<Handler>>()
|
|
26
|
+
|
|
27
|
+
on<K extends keyof TEvents & string>(event: K, handler: Handler<TEvents[K]>): () => void {
|
|
28
|
+
if (!this.handlers.has(event)) {
|
|
29
|
+
this.handlers.set(event, new Set())
|
|
30
|
+
}
|
|
31
|
+
this.handlers.get(event)!.add(handler)
|
|
32
|
+
|
|
33
|
+
// Retourne une fonction de désinscription
|
|
34
|
+
return () => this.off(event, handler)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
off<K extends keyof TEvents & string>(event: K, handler: Handler<TEvents[K]>): void {
|
|
38
|
+
this.handlers.get(event)?.delete(handler)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
emit<K extends keyof TEvents & string>(event: K, data: TEvents[K]): void {
|
|
42
|
+
const handlers = this.handlers.get(event)
|
|
43
|
+
if (!handlers?.size) return
|
|
44
|
+
|
|
45
|
+
// Fire-and-forget — les erreurs dans les handlers ne propagent pas
|
|
46
|
+
for (const handler of handlers) {
|
|
47
|
+
try {
|
|
48
|
+
handler(data)
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error(`[EventBus] Handler error on "${event}":`, err)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
clear(event?: string): void {
|
|
56
|
+
if (event) {
|
|
57
|
+
this.handlers.delete(event)
|
|
58
|
+
} else {
|
|
59
|
+
this.handlers.clear()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
listenerCount(event: string): number {
|
|
64
|
+
return this.handlers.get(event)?.size ?? 0
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── HookBus — awaitable, peut modifier ou annuler ────────────
|
|
69
|
+
|
|
70
|
+
export interface HookResult<T> {
|
|
71
|
+
value: T
|
|
72
|
+
cancelled?: boolean
|
|
73
|
+
reason?: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class HookBus<THooks extends Record<string, any> = Record<string, any>> {
|
|
77
|
+
private handlers = new Map<string, AsyncHandler[]>()
|
|
78
|
+
|
|
79
|
+
on<K extends keyof THooks & string>(
|
|
80
|
+
hook: K,
|
|
81
|
+
handler: AsyncHandler<THooks[K]>
|
|
82
|
+
): () => void {
|
|
83
|
+
if (!this.handlers.has(hook)) {
|
|
84
|
+
this.handlers.set(hook, [])
|
|
85
|
+
}
|
|
86
|
+
this.handlers.get(hook)!.push(handler)
|
|
87
|
+
|
|
88
|
+
return () => this.off(hook, handler)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
off<K extends keyof THooks & string>(hook: K, handler: AsyncHandler<THooks[K]>): void {
|
|
92
|
+
const list = this.handlers.get(hook)
|
|
93
|
+
if (!list) return
|
|
94
|
+
const idx = list.indexOf(handler)
|
|
95
|
+
if (idx !== -1) list.splice(idx, 1)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Appelle les handlers en séquence.
|
|
100
|
+
* Chaque handler peut retourner une valeur modifiée — elle est passée au suivant.
|
|
101
|
+
* Si un handler retourne { cancelled: true }, la chaîne s'arrête.
|
|
102
|
+
*/
|
|
103
|
+
async call<K extends keyof THooks & string>(
|
|
104
|
+
hook: K,
|
|
105
|
+
data: THooks[K]
|
|
106
|
+
): Promise<HookResult<THooks[K]>> {
|
|
107
|
+
const handlers = this.handlers.get(hook)
|
|
108
|
+
if (!handlers?.length) return { value: data }
|
|
109
|
+
|
|
110
|
+
let current = data
|
|
111
|
+
|
|
112
|
+
for (const handler of handlers) {
|
|
113
|
+
const result = await handler(current)
|
|
114
|
+
|
|
115
|
+
// Si le handler retourne un objet avec cancelled, on stoppe
|
|
116
|
+
if (result && typeof result === 'object' && 'cancelled' in result && result.cancelled) {
|
|
117
|
+
return { value: current, cancelled: true, reason: result.reason }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Si le handler retourne une valeur, elle remplace le contexte courant
|
|
121
|
+
if (result !== undefined && result !== null) {
|
|
122
|
+
current = result as THooks[K]
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { value: current }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
listenerCount(hook: string): number {
|
|
130
|
+
return this.handlers.get(hook)?.length ?? 0
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── ErrorBus — synchrone, jamais silencieux ──────────────────
|
|
135
|
+
|
|
136
|
+
export class ErrorBus<TErrors extends Record<string, any> = Record<string, any>> {
|
|
137
|
+
private handlers = new Map<string, Set<Handler>>()
|
|
138
|
+
private fallback?: (event: string, data: any) => void
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Handler de fallback si aucun handler n'est enregistré pour cette erreur.
|
|
142
|
+
* Par défaut : console.error.
|
|
143
|
+
*/
|
|
144
|
+
setFallback(fn: (event: string, data: any) => void): void {
|
|
145
|
+
this.fallback = fn
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
on<K extends keyof TErrors & string>(event: K, handler: Handler<TErrors[K]>): () => void {
|
|
149
|
+
if (!this.handlers.has(event)) {
|
|
150
|
+
this.handlers.set(event, new Set())
|
|
151
|
+
}
|
|
152
|
+
this.handlers.get(event)!.add(handler)
|
|
153
|
+
|
|
154
|
+
return () => this.off(event, handler)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
off<K extends keyof TErrors & string>(event: K, handler: Handler<TErrors[K]>): void {
|
|
158
|
+
this.handlers.get(event)?.delete(handler)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
emit<K extends keyof TErrors & string>(event: K, data: TErrors[K]): void {
|
|
162
|
+
const handlers = this.handlers.get(event)
|
|
163
|
+
|
|
164
|
+
if (!handlers?.size) {
|
|
165
|
+
// Jamais silencieux — fallback ou console.error
|
|
166
|
+
if (this.fallback) {
|
|
167
|
+
this.fallback(event, data)
|
|
168
|
+
} else {
|
|
169
|
+
console.error(`[LinkLab Error] ${event}:`, data)
|
|
170
|
+
}
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const handler of handlers) {
|
|
175
|
+
try {
|
|
176
|
+
handler(data)
|
|
177
|
+
} catch (err) {
|
|
178
|
+
// Les erreurs dans les error handlers ne doivent jamais être avalées
|
|
179
|
+
console.error(`[ErrorBus] Handler threw on "${event}":`, err)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
listenerCount(event: string): number {
|
|
185
|
+
return this.handlers.get(event)?.size ?? 0
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphEvents — Catalogue des événements LinkLab
|
|
3
|
+
*
|
|
4
|
+
* Trois bus, trois contrats :
|
|
5
|
+
*
|
|
6
|
+
* graph.hooks — awaitable, enrichit ou annule le flux
|
|
7
|
+
* graph.events — fire-and-forget, observation pure
|
|
8
|
+
* graph.errors — synchrone, jamais silencieux
|
|
9
|
+
*
|
|
10
|
+
* Conventions de nommage :
|
|
11
|
+
* hooks : <sujet>.<moment> ex: traversal.before, access.check
|
|
12
|
+
* events : <sujet>.<résultat> ex: traversal.complete, cache.miss
|
|
13
|
+
* errors : <sujet>.<type> ex: route.notfound, traversal.failed
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { HookBus, EventBus, ErrorBus } from './EventBus.js'
|
|
17
|
+
import type { Frame, GraphEdge, NavigationPath, Graph } from '../types/index.js'
|
|
18
|
+
|
|
19
|
+
// ── Payloads Hooks ────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export interface TraversalBeforePayload {
|
|
22
|
+
from: string
|
|
23
|
+
to: string
|
|
24
|
+
stack: Frame[]
|
|
25
|
+
graph: Graph
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TraversalStepPayload {
|
|
29
|
+
node: string
|
|
30
|
+
edge: GraphEdge
|
|
31
|
+
stack: Frame[]
|
|
32
|
+
depth: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AccessCheckPayload {
|
|
36
|
+
node: string
|
|
37
|
+
stack: Frame[]
|
|
38
|
+
context?: Record<string, any>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface StackPushPayload {
|
|
42
|
+
frame: Frame
|
|
43
|
+
stack: Frame[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface StackPopPayload {
|
|
47
|
+
frame: Frame
|
|
48
|
+
stack: Frame[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Payloads Events ───────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export interface TraversalCompletePayload {
|
|
54
|
+
from: string
|
|
55
|
+
to: string
|
|
56
|
+
path: NavigationPath
|
|
57
|
+
durationMs: number
|
|
58
|
+
stackDepth: number
|
|
59
|
+
routeUsed: string // ex: "movies→credits→people"
|
|
60
|
+
routeWeight: number
|
|
61
|
+
resultCount?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CacheMissPayload {
|
|
65
|
+
key: string
|
|
66
|
+
requestedAt: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface CacheHitPayload {
|
|
70
|
+
key: string
|
|
71
|
+
accessCount: number
|
|
72
|
+
cachedAt?: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface WeightUpdatedPayload {
|
|
76
|
+
edge: string // "from→to"
|
|
77
|
+
previousWeight: number
|
|
78
|
+
newWeight: number
|
|
79
|
+
reason?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface StackCompactedPayload {
|
|
83
|
+
before: Frame[]
|
|
84
|
+
after: Frame[]
|
|
85
|
+
removedCount: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Payloads Errors ───────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export interface RouteNotFoundPayload {
|
|
91
|
+
from: string
|
|
92
|
+
to: string
|
|
93
|
+
stack: Frame[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface TraversalFailedPayload {
|
|
97
|
+
from: string
|
|
98
|
+
to: string
|
|
99
|
+
reason: string
|
|
100
|
+
error?: Error
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface HookTimeoutPayload {
|
|
104
|
+
hook: string
|
|
105
|
+
timeoutMs: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface AccessDeniedPayload {
|
|
109
|
+
node: string
|
|
110
|
+
reason: string
|
|
111
|
+
stack: Frame[]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Registres typés ───────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
export interface GraphHooks {
|
|
117
|
+
'traversal.before': TraversalBeforePayload
|
|
118
|
+
'traversal.step': TraversalStepPayload
|
|
119
|
+
'access.check': AccessCheckPayload
|
|
120
|
+
'stack.push': StackPushPayload
|
|
121
|
+
'stack.pop': StackPopPayload
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface GraphEventMap {
|
|
125
|
+
'traversal.complete': TraversalCompletePayload
|
|
126
|
+
'cache.miss': CacheMissPayload
|
|
127
|
+
'cache.hit': CacheHitPayload
|
|
128
|
+
'weight.updated': WeightUpdatedPayload
|
|
129
|
+
'stack.compacted': StackCompactedPayload
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface GraphErrors {
|
|
133
|
+
'route.notfound': RouteNotFoundPayload
|
|
134
|
+
'traversal.failed': TraversalFailedPayload
|
|
135
|
+
'hook.timeout': HookTimeoutPayload
|
|
136
|
+
'access.denied': AccessDeniedPayload
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Factory — crée les trois bus pour une instance de graphe ──
|
|
140
|
+
|
|
141
|
+
export interface GraphBuses {
|
|
142
|
+
hooks: HookBus<GraphHooks>
|
|
143
|
+
events: EventBus<GraphEventMap>
|
|
144
|
+
errors: ErrorBus<GraphErrors>
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function createGraphBuses(): GraphBuses {
|
|
148
|
+
return {
|
|
149
|
+
hooks: new HookBus<GraphHooks>(),
|
|
150
|
+
events: new EventBus<GraphEventMap>(),
|
|
151
|
+
errors: new ErrorBus<GraphErrors>(),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PathFinder - Dijkstra + DFS limité
|
|
3
|
+
*
|
|
4
|
+
* Deux algorithmes selon l'usage :
|
|
5
|
+
*
|
|
6
|
+
* findShortestPath() → Dijkstra (chemin optimal garanti, performant)
|
|
7
|
+
* findAllPaths() → DFS limité (N meilleurs chemins alternatifs)
|
|
8
|
+
*
|
|
9
|
+
* Sur un graphe de métro (300+ stations, 900+ arêtes),
|
|
10
|
+
* le DFS pur explose. Dijkstra est l'algorithme correct.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Graph, GraphEdge, Path, PathDetails } from '../types/index.js'
|
|
14
|
+
|
|
15
|
+
export class PathFinder {
|
|
16
|
+
private adjacencyList: Map<string, Array<{ to: string; edge: GraphEdge }>>
|
|
17
|
+
|
|
18
|
+
constructor(private graph: Graph) {
|
|
19
|
+
this.adjacencyList = this.buildAdjacencyList(graph)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ==================== DIJKSTRA ====================
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Chemin le plus court par poids (Dijkstra).
|
|
26
|
+
* Garanti optimal. Performant sur grands graphes.
|
|
27
|
+
*/
|
|
28
|
+
findShortestPath(from: string, to: string): PathDetails | null {
|
|
29
|
+
const dist = new Map<string, number>()
|
|
30
|
+
const prev = new Map<string, { node: string; edge: GraphEdge } | null>()
|
|
31
|
+
const visited = new Set<string>()
|
|
32
|
+
|
|
33
|
+
// Initialisation
|
|
34
|
+
for (const node of this.graph.nodes) {
|
|
35
|
+
dist.set(node.id, Infinity)
|
|
36
|
+
}
|
|
37
|
+
dist.set(from, 0)
|
|
38
|
+
prev.set(from, null)
|
|
39
|
+
|
|
40
|
+
// Priority queue simple (pour notre taille, suffisant)
|
|
41
|
+
const queue = new Set<string>(this.graph.nodes.map(n => n.id))
|
|
42
|
+
|
|
43
|
+
while (queue.size > 0) {
|
|
44
|
+
// Nœud non visité avec distance minimale
|
|
45
|
+
let u: string | null = null
|
|
46
|
+
let minDist = Infinity
|
|
47
|
+
for (const node of queue) {
|
|
48
|
+
const d = dist.get(node) ?? Infinity
|
|
49
|
+
if (d < minDist) {
|
|
50
|
+
minDist = d
|
|
51
|
+
u = node
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (u === null || u === to) break
|
|
56
|
+
if (minDist === Infinity) break // Graphe non connexe
|
|
57
|
+
|
|
58
|
+
queue.delete(u)
|
|
59
|
+
visited.add(u)
|
|
60
|
+
|
|
61
|
+
const neighbors = this.adjacencyList.get(u) ?? []
|
|
62
|
+
for (const { to: v, edge } of neighbors) {
|
|
63
|
+
if (visited.has(v)) continue
|
|
64
|
+
|
|
65
|
+
const alt = (dist.get(u) ?? Infinity) + edge.weight
|
|
66
|
+
if (alt < (dist.get(v) ?? Infinity)) {
|
|
67
|
+
dist.set(v, alt)
|
|
68
|
+
prev.set(v, { node: u, edge })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!prev.has(to) && to !== from) return null
|
|
74
|
+
if ((dist.get(to) ?? Infinity) === Infinity) return null
|
|
75
|
+
|
|
76
|
+
// Reconstruction du chemin
|
|
77
|
+
const path: string[] = []
|
|
78
|
+
const edges: GraphEdge[] = []
|
|
79
|
+
let current: string | null = to
|
|
80
|
+
|
|
81
|
+
while (current !== null) {
|
|
82
|
+
path.unshift(current)
|
|
83
|
+
const p = prev.get(current)
|
|
84
|
+
if (p) {
|
|
85
|
+
edges.unshift(p.edge)
|
|
86
|
+
current = p.node
|
|
87
|
+
} else {
|
|
88
|
+
current = null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
path,
|
|
94
|
+
edges,
|
|
95
|
+
length: path.length,
|
|
96
|
+
joins: path.length - 1,
|
|
97
|
+
weight: dist.get(to) ?? 0,
|
|
98
|
+
indirect: path.length > 2
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* N meilleurs chemins (Yen's K-shortest paths simplifié).
|
|
104
|
+
* Trouve le plus court via Dijkstra, puis des alternatives
|
|
105
|
+
* en pénalisant les arêtes du chemin précédent.
|
|
106
|
+
*/
|
|
107
|
+
findAllPaths(from: string, to: string, maxPaths = 3, _maxDepth = 50, transferPenalty = 0, allowedVia?: string[], minHops = 0): Path[] {
|
|
108
|
+
const results: PathDetails[] = []
|
|
109
|
+
const penalized = new Set<string>() // arêtes temporairement exclues
|
|
110
|
+
|
|
111
|
+
for (let k = 0; k < maxPaths; k++) {
|
|
112
|
+
const result = this.dijkstraWithExclusions(from, to, penalized, transferPenalty, allowedVia, minHops)
|
|
113
|
+
if (!result) break
|
|
114
|
+
|
|
115
|
+
results.push(result)
|
|
116
|
+
|
|
117
|
+
// Pénaliser la dernière arête du chemin trouvé pour forcer une alternative
|
|
118
|
+
if (result.edges.length > 0) {
|
|
119
|
+
const lastEdge = result.edges[result.edges.length - 1]
|
|
120
|
+
penalized.add(lastEdge.name ?? `${lastEdge.from}->${lastEdge.to}`)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return results.map(r => r.path)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Dijkstra avec exclusion d'arêtes (pour les chemins alternatifs)
|
|
129
|
+
*/
|
|
130
|
+
private dijkstraWithExclusions(
|
|
131
|
+
from: string,
|
|
132
|
+
to: string,
|
|
133
|
+
excluded: Set<string>,
|
|
134
|
+
transferPenalty = 0,
|
|
135
|
+
allowedVia?: string[],
|
|
136
|
+
minHops = 0
|
|
137
|
+
): PathDetails | null {
|
|
138
|
+
const dist = new Map<string, number>()
|
|
139
|
+
const prev = new Map<string, { node: string; edge: GraphEdge } | null>()
|
|
140
|
+
const visited = new Set<string>()
|
|
141
|
+
|
|
142
|
+
for (const node of this.graph.nodes) dist.set(node.id, Infinity)
|
|
143
|
+
dist.set(from, 0)
|
|
144
|
+
prev.set(from, null)
|
|
145
|
+
|
|
146
|
+
const queue = new Set<string>(this.graph.nodes.map(n => n.id))
|
|
147
|
+
|
|
148
|
+
while (queue.size > 0) {
|
|
149
|
+
let u: string | null = null
|
|
150
|
+
let minDist = Infinity
|
|
151
|
+
for (const node of queue) {
|
|
152
|
+
const d = dist.get(node) ?? Infinity
|
|
153
|
+
if (d < minDist) { minDist = d; u = node }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (u === null || u === to || minDist === Infinity) break
|
|
157
|
+
|
|
158
|
+
queue.delete(u)
|
|
159
|
+
visited.add(u)
|
|
160
|
+
|
|
161
|
+
for (const { to: v, edge } of this.adjacencyList.get(u) ?? []) {
|
|
162
|
+
if (visited.has(v)) continue
|
|
163
|
+
const edgeKey = edge.name ?? `${edge.from}->${edge.to}`
|
|
164
|
+
if (excluded.has(edgeKey)) continue
|
|
165
|
+
|
|
166
|
+
// Filtre via — si spécifié, ignorer les arêtes dont le type n'est pas dans la liste
|
|
167
|
+
if (allowedVia && allowedVia.length > 0) {
|
|
168
|
+
const edgeType = edge.metadata?.type ?? edge.via
|
|
169
|
+
if (!allowedVia.includes(edgeType)) continue
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Pénalité sur changement de ligne — deux cas :
|
|
173
|
+
// 1. Arête explicitement TRANSFER (self-loop de correspondance)
|
|
174
|
+
// 2. Changement de ligne implicite (arête DIRECT mais ligne différente de la précédente)
|
|
175
|
+
let penalty = 0
|
|
176
|
+
if (transferPenalty > 0) {
|
|
177
|
+
const isExplicitTransfer = edge.metadata?.type === 'TRANSFER'
|
|
178
|
+
const prevEdge = prev.get(u)
|
|
179
|
+
const prevLineId = prevEdge?.edge?.metadata?.lineId
|
|
180
|
+
const currLineId = edge.metadata?.lineId
|
|
181
|
+
const isLineChange = prevLineId && currLineId && prevLineId !== currLineId
|
|
182
|
+
&& edge.metadata?.type !== 'TRANSFER'
|
|
183
|
+
if (isExplicitTransfer || isLineChange) {
|
|
184
|
+
penalty = transferPenalty
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const alt = (dist.get(u) ?? Infinity) + edge.weight + penalty
|
|
188
|
+
if (alt < (dist.get(v) ?? Infinity)) {
|
|
189
|
+
dist.set(v, alt)
|
|
190
|
+
prev.set(v, { node: u, edge })
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if ((dist.get(to) ?? Infinity) === Infinity) return null
|
|
196
|
+
|
|
197
|
+
const path: string[] = []
|
|
198
|
+
const edges: GraphEdge[] = []
|
|
199
|
+
let current: string | null = to
|
|
200
|
+
|
|
201
|
+
while (current !== null) {
|
|
202
|
+
path.unshift(current)
|
|
203
|
+
const p = prev.get(current)
|
|
204
|
+
if (p) { edges.unshift(p.edge); current = p.node }
|
|
205
|
+
else current = null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Filtre minHops — rejeter les chemins trop courts
|
|
209
|
+
if (minHops > 0 && path.length - 1 < minHops) return null
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
path, edges,
|
|
213
|
+
length: path.length,
|
|
214
|
+
joins: path.length - 1,
|
|
215
|
+
weight: dist.get(to) ?? 0,
|
|
216
|
+
indirect: path.length > 2
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ==================== HELPERS ====================
|
|
221
|
+
|
|
222
|
+
getPathWeight(path: Path): number {
|
|
223
|
+
let total = 0
|
|
224
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
225
|
+
const edge = this.graph.edges.find(e => e.from === path[i] && e.to === path[i + 1])
|
|
226
|
+
if (edge) total += edge.weight
|
|
227
|
+
}
|
|
228
|
+
return total
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
getPathDetails(path: Path): PathDetails {
|
|
232
|
+
const edges: GraphEdge[] = []
|
|
233
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
234
|
+
const edge = this.graph.edges.find(e => e.from === path[i] && e.to === path[i + 1])
|
|
235
|
+
if (edge) edges.push(edge)
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
path, edges,
|
|
239
|
+
length: path.length,
|
|
240
|
+
joins: path.length - 1,
|
|
241
|
+
weight: this.getPathWeight(path),
|
|
242
|
+
indirect: path.length > 2
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
hasPath(from: string, to: string): boolean {
|
|
247
|
+
return this.findShortestPath(from, to) !== null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
getReachableNodes(from: string, maxDepth = 50): Set<string> {
|
|
251
|
+
const reachable = new Set<string>()
|
|
252
|
+
const visited = new Set<string>()
|
|
253
|
+
const dfs = (node: string, depth: number) => {
|
|
254
|
+
if (depth > maxDepth || visited.has(node)) return
|
|
255
|
+
visited.add(node)
|
|
256
|
+
reachable.add(node)
|
|
257
|
+
for (const { to } of this.adjacencyList.get(node) ?? []) dfs(to, depth + 1)
|
|
258
|
+
}
|
|
259
|
+
dfs(from, 0)
|
|
260
|
+
reachable.delete(from)
|
|
261
|
+
return reachable
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private buildAdjacencyList(graph: Graph): Map<string, Array<{ to: string; edge: GraphEdge }>> {
|
|
265
|
+
const adj = new Map<string, Array<{ to: string; edge: GraphEdge }>>()
|
|
266
|
+
for (const edge of graph.edges) {
|
|
267
|
+
if (!adj.has(edge.from)) adj.set(edge.from, [])
|
|
268
|
+
adj.get(edge.from)!.push({ to: edge.to, edge })
|
|
269
|
+
if (!adj.has(edge.to)) adj.set(edge.to, [])
|
|
270
|
+
}
|
|
271
|
+
return adj
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
getStats() {
|
|
275
|
+
const degrees = new Map<string, number>()
|
|
276
|
+
for (const node of this.graph.nodes) degrees.set(node.id, 0)
|
|
277
|
+
for (const edge of this.graph.edges) {
|
|
278
|
+
degrees.set(edge.from, (degrees.get(edge.from) ?? 0) + 1)
|
|
279
|
+
}
|
|
280
|
+
const avgDegree = Array.from(degrees.values()).reduce((s, d) => s + d, 0) / degrees.size
|
|
281
|
+
return { nodes: this.graph.nodes.length, edges: this.graph.edges.length, avgDegree }
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BaseFormatter - Interface pour les formatters de sortie
|
|
3
|
+
*
|
|
4
|
+
* Chaque scénario peut avoir son propre formatter.
|
|
5
|
+
* Le formatter transforme un résultat brut du NavigationEngine
|
|
6
|
+
* en sortie lisible par un humain.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { NavigationPath, EngineStepResult } from '../types/index.js'
|
|
10
|
+
|
|
11
|
+
export interface PathFormatter {
|
|
12
|
+
/** Formate un chemin pour l'affichage humain */
|
|
13
|
+
format(path: NavigationPath): string
|
|
14
|
+
|
|
15
|
+
/** Formate un step complet de résultat */
|
|
16
|
+
formatResult(result: EngineStepResult): string
|
|
17
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphAssembler — Dictionary → Graph V3
|
|
3
|
+
*
|
|
4
|
+
* Transforme le Dictionary produit par GraphBuilder
|
|
5
|
+
* en Graph V3 (nodes + edges) prêt pour PathFinder.
|
|
6
|
+
*
|
|
7
|
+
* Corrige le bug d'itération : dictionary.tables est une Table[]
|
|
8
|
+
* (liste), pas un Record<string, Table> (dictionnaire).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Dictionary, Graph, GraphNode, GraphEdge } from '../types/index.js'
|
|
12
|
+
|
|
13
|
+
export class GraphAssembler {
|
|
14
|
+
|
|
15
|
+
assemble(dictionary: Dictionary): Graph {
|
|
16
|
+
const nodes: GraphNode[] = []
|
|
17
|
+
const edges: GraphEdge[] = []
|
|
18
|
+
|
|
19
|
+
// 1. Tables → Nodes
|
|
20
|
+
// dictionary.tables est une Table[] — itération sur les éléments, pas les indices
|
|
21
|
+
for (const table of dictionary.tables) {
|
|
22
|
+
nodes.push({
|
|
23
|
+
id: table.name,
|
|
24
|
+
type: 'table',
|
|
25
|
+
rowCount: table.rowCount,
|
|
26
|
+
columns: table.columns.map(c => ({ name: c, type: 'string' }))
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 2. Relations → Edges
|
|
31
|
+
for (const rel of dictionary.relations) {
|
|
32
|
+
edges.push({
|
|
33
|
+
name: rel.label,
|
|
34
|
+
from: rel.from,
|
|
35
|
+
to: rel.to,
|
|
36
|
+
via: rel.via,
|
|
37
|
+
weight: typeof rel.weight === 'string'
|
|
38
|
+
? parseFloat(rel.weight)
|
|
39
|
+
: (rel.weight ?? 1),
|
|
40
|
+
metadata: {
|
|
41
|
+
type: rel.type,
|
|
42
|
+
condition: rel.condition,
|
|
43
|
+
metadataField: rel.metadataField
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { nodes, edges }
|
|
49
|
+
}
|
|
50
|
+
}
|