@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,441 @@
1
+ /**
2
+ * NavigationEngine - Moteur de navigation sémantique
3
+ *
4
+ * Trois modes orthogonaux :
5
+ *
6
+ * PATHFIND — Trouver les N meilleurs chemins entre deux nœuds
7
+ * NAVIGATE — Résoudre une stack de frames étape par étape
8
+ * SCHEDULE — Exécuter des actions par priorité sur un contexte
9
+ *
10
+ * Trois bus exposés sur chaque instance :
11
+ *
12
+ * engine.hooks — awaitable, enrichit ou annule le flux
13
+ * engine.events — fire-and-forget, observation pure
14
+ * engine.errors — synchrone, jamais silencieux
15
+ *
16
+ * Trail :
17
+ * Le moteur accepte un Trail existant (Option B).
18
+ * Si aucun Trail n'est fourni, il en crée un à partir de initialStack.
19
+ * engine.trail est toujours accessible après création.
20
+ *
21
+ * ── Instrumentation @linklab/telemetry ──────────────────────────────────────
22
+ * Chaque appel à run() produit un Span si @linklab/telemetry est installé.
23
+ * Points mesurés :
24
+ * - Step 'PathFinder' : durée de findAllPaths() (mode PATHFIND)
25
+ * - Step 'Resolver' : durée de resolver.resolve() par frame (mode NAVIGATE)
26
+ * - Step 'Scheduler' : durée de scheduler.step() (mode SCHEDULE)
27
+ * Le span est émis sur traceBus à la fin de run(), succès ou erreur.
28
+ */
29
+
30
+ import type {
31
+ Graph,
32
+ GraphEdge,
33
+ Frame,
34
+ ScheduleAction,
35
+ EngineMode,
36
+ NavigationEngineConfig,
37
+ EngineStepResult,
38
+ PathQuery,
39
+ NavigationPath
40
+ } from '../types/index.js'
41
+
42
+ import { PathFinder } from '../core/PathFinder.js'
43
+ import { Resolver } from './Resolver.js'
44
+ import { Scheduler } from './Scheduler.js'
45
+ import { Trail } from './Trail.js'
46
+ import { createGraphBuses, type GraphBuses } from '../core/GraphEvents.js'
47
+ import { shim } from '../instrumentation/TelemetryShim.js'
48
+
49
+ export class NavigationEngine {
50
+ private mode: EngineMode
51
+ private graph: Graph
52
+ private pathFinder?: PathFinder
53
+ private resolver?: Resolver
54
+ private scheduler?: Scheduler
55
+ private config: NavigationEngineConfig
56
+
57
+ // ── Trail — contexte de navigation vivant ────────────────────
58
+ public readonly trail: Trail
59
+
60
+ // ── Les trois bus ────────────────────────────────────────────
61
+ public readonly hooks: GraphBuses['hooks']
62
+ public readonly events: GraphBuses['events']
63
+ public readonly errors: GraphBuses['errors']
64
+
65
+ constructor(config: NavigationEngineConfig) {
66
+ this.config = config
67
+ this.mode = config.mode
68
+ this.graph = config.graph
69
+
70
+ this.trail = config.trail ?? Trail.create({
71
+ frames: config.initialStack ?? []
72
+ })
73
+
74
+ if (!config.trail && config.initialStack) {
75
+ for (const frame of config.initialStack) {
76
+ if (!frame.state) {
77
+ frame.state = frame.id !== undefined ? 'RESOLVED' : 'UNRESOLVED'
78
+ }
79
+ }
80
+ }
81
+
82
+ const buses = createGraphBuses()
83
+ this.hooks = buses.hooks
84
+ this.events = buses.events
85
+ this.errors = buses.errors
86
+
87
+ switch (this.mode) {
88
+ case 'PATHFIND':
89
+ this.pathFinder = new PathFinder(this.graph)
90
+ break
91
+ case 'NAVIGATE':
92
+ this.resolver = new Resolver(this.graph)
93
+ break
94
+ case 'SCHEDULE':
95
+ this.scheduler = new Scheduler(config.actions ?? [], this.graph)
96
+ break
97
+ }
98
+ }
99
+
100
+ // ==================== FACTORY METHODS ====================
101
+
102
+ static forPathfinding(graph: Graph, query: PathQuery): NavigationEngine {
103
+ return new NavigationEngine({ mode: 'PATHFIND', graph, pathQuery: query })
104
+ }
105
+
106
+ static forNavigation(
107
+ graph: Graph,
108
+ options: { trail: Trail } | { stack: Frame[] }
109
+ ): NavigationEngine {
110
+ if ('trail' in options) {
111
+ return new NavigationEngine({ mode: 'NAVIGATE', graph, trail: options.trail })
112
+ }
113
+ return new NavigationEngine({ mode: 'NAVIGATE', graph, initialStack: options.stack })
114
+ }
115
+
116
+ static forScheduling(
117
+ graph: Graph,
118
+ options: { trail?: Trail; stack?: Frame[]; actions: ScheduleAction[] }
119
+ ): NavigationEngine {
120
+ const trail = options.trail ?? Trail.create({ frames: options.stack ?? [] })
121
+ return new NavigationEngine({ mode: 'SCHEDULE', graph, trail, actions: options.actions })
122
+ }
123
+
124
+ // ==================== RUN ====================
125
+
126
+ async run(maxSteps: number = 1): Promise<EngineStepResult[]> {
127
+ // ── Span : contexte commun aux trois modes ───────────────
128
+ const trailStr = this._buildTrailString()
129
+ const spanBuilder = shim.startSpan({
130
+ trail: trailStr,
131
+ from: this._resolvedFrom(),
132
+ to: this._targetTo(),
133
+ filters: this._currentFilters(),
134
+ path: [], // mis à jour après résolution
135
+ })
136
+
137
+ try {
138
+ let results: EngineStepResult[]
139
+
140
+ switch (this.mode) {
141
+ case 'PATHFIND':
142
+ results = await this.runPathfind(spanBuilder)
143
+ break
144
+ case 'NAVIGATE':
145
+ results = await this.runNavigate(maxSteps, spanBuilder)
146
+ break
147
+ case 'SCHEDULE':
148
+ results = await this.runSchedule(maxSteps, spanBuilder)
149
+ break
150
+ default:
151
+ throw new Error(`Mode inconnu : ${this.mode}`)
152
+ }
153
+
154
+ // Émettre le span de succès
155
+ if (spanBuilder) {
156
+ const rowCount = this._countRows(results)
157
+ const span = spanBuilder.end({ rowCount })
158
+ shim.emitEnd(span)
159
+ }
160
+
161
+ return results
162
+
163
+ } catch (err) {
164
+ // Émettre le span d'erreur
165
+ if (spanBuilder) {
166
+ const span = spanBuilder.endWithError(err as Error, {
167
+ compiledGraphHash: 'unknown',
168
+ weights: {},
169
+ cacheState: { l1HitRate: 0, l2HitRate: 0, globalHitRate: 0, yoyoEvents: 0 },
170
+ })
171
+ shim.emitError(span)
172
+ }
173
+ throw err
174
+ }
175
+ }
176
+
177
+ // ==================== PRIVATE (inchangé sauf signature + step timings) ====================
178
+
179
+ private async runPathfind(
180
+ spanBuilder: ReturnType<typeof shim.startSpan>
181
+ ): Promise<EngineStepResult[]> {
182
+ if (!this.pathFinder || !this.config.pathQuery) {
183
+ throw new Error('PATHFIND requiert pathQuery')
184
+ }
185
+
186
+ const { from, to, maxPaths = 5, transferPenalty = 0, via, minHops = 0 } = this.config.pathQuery
187
+ const startTime = Date.now()
188
+
189
+ const hookResult = await this.hooks.call('traversal.before', {
190
+ from, to,
191
+ stack: [...this.trail.frames],
192
+ graph: this.graph,
193
+ })
194
+
195
+ if (hookResult.cancelled) {
196
+ this.errors.emit('traversal.failed', {
197
+ from, to,
198
+ reason: hookResult.reason ?? 'Annulé par hook traversal.before',
199
+ })
200
+ return [{ time: 0, mode: 'PATHFIND', result: { type: 'FAIL', reason: hookResult.reason } }]
201
+ }
202
+
203
+ // ── Step : PathFinder ────────────────────────────────────
204
+ spanBuilder?.stepStart('PathFinder')
205
+ const allPaths = this.pathFinder.findAllPaths(from, to, maxPaths, 50, transferPenalty, via, minHops)
206
+ spanBuilder?.stepEnd('PathFinder')
207
+
208
+ if (allPaths.length === 0) {
209
+ this.errors.emit('route.notfound', {
210
+ from, to,
211
+ stack: [...this.trail.frames],
212
+ })
213
+ return [{ time: 0, mode: 'PATHFIND', result: { type: 'FAIL', reason: 'Aucun chemin trouvé' } }]
214
+ }
215
+
216
+ const pathsWithDetails = allPaths
217
+ .map(nodes => {
218
+ const edges: GraphEdge[] = []
219
+ let totalWeight = 0
220
+ for (let i = 0; i < nodes.length - 1; i++) {
221
+ const edge = this.graph.edges.find(e => e.from === nodes[i] && e.to === nodes[i + 1])
222
+ if (edge) { edges.push(edge); totalWeight += edge.weight }
223
+ }
224
+ return { nodes, edges, totalWeight } as NavigationPath
225
+ })
226
+ .sort((a, b) => a.totalWeight - b.totalWeight)
227
+ .slice(0, maxPaths)
228
+
229
+ const best = pathsWithDetails[0]
230
+
231
+ // Mettre à jour le path dans le span
232
+ if (spanBuilder) {
233
+ ;(spanBuilder as any).withPath?.(best.nodes)
234
+ }
235
+
236
+ this.events.emit('traversal.complete', {
237
+ from, to,
238
+ path: best,
239
+ durationMs: Date.now() - startTime,
240
+ stackDepth: this.trail.depth,
241
+ routeUsed: best.nodes.join('→'),
242
+ routeWeight: best.totalWeight,
243
+ })
244
+
245
+ return pathsWithDetails.map((path, index) => ({
246
+ time: index,
247
+ mode: 'PATHFIND' as const,
248
+ path,
249
+ result: { type: 'SUCCESS' as const, data: { rank: index + 1, allPaths: pathsWithDetails } }
250
+ }))
251
+ }
252
+
253
+ private async runNavigate(
254
+ maxSteps: number,
255
+ spanBuilder: ReturnType<typeof shim.startSpan>
256
+ ): Promise<EngineStepResult[]> {
257
+ if (!this.resolver) throw new Error('NAVIGATE requiert Resolver')
258
+
259
+ const results: EngineStepResult[] = []
260
+
261
+ for (let t = 0; t < maxSteps; t++) {
262
+ const resolved = this.trail.frames.filter(f => f.state === 'RESOLVED').length
263
+ const unresolved = this.trail.unresolved
264
+
265
+ if (unresolved.length === 0) {
266
+ results.push({
267
+ time: t, mode: 'NAVIGATE', phase: 'COMPLETE',
268
+ resolvedCount: resolved, unresolvedCount: 0,
269
+ result: { type: 'SUCCESS' }
270
+ })
271
+ break
272
+ }
273
+
274
+ const nextUnresolved = unresolved[0]
275
+
276
+ const hookResult = await this.hooks.call('traversal.before', {
277
+ from: [...this.trail.frames].reverse().find(f => f.state === 'RESOLVED')?.entity ?? '',
278
+ to: nextUnresolved.entity,
279
+ stack: [...this.trail.frames],
280
+ graph: this.graph,
281
+ })
282
+
283
+ if (hookResult.cancelled) {
284
+ this.errors.emit('traversal.failed', {
285
+ from: '',
286
+ to: nextUnresolved.entity,
287
+ reason: hookResult.reason ?? 'Annulé par hook traversal.before',
288
+ })
289
+ results.push({
290
+ time: t, mode: 'NAVIGATE', phase: 'COMPLETE',
291
+ result: { type: 'FAIL', reason: hookResult.reason }
292
+ })
293
+ break
294
+ }
295
+
296
+ const accessResult = await this.hooks.call('access.check', {
297
+ node: nextUnresolved.entity,
298
+ stack: [...this.trail.frames],
299
+ context: this.trail.user,
300
+ })
301
+
302
+ if (accessResult.cancelled) {
303
+ this.errors.emit('access.denied', {
304
+ node: nextUnresolved.entity,
305
+ reason: accessResult.reason ?? 'Accès refusé',
306
+ stack: [...this.trail.frames],
307
+ })
308
+ results.push({
309
+ time: t, mode: 'NAVIGATE', phase: 'COMPLETE',
310
+ result: { type: 'FAIL', reason: accessResult.reason }
311
+ })
312
+ break
313
+ }
314
+
315
+ const startTime = Date.now()
316
+ const currentStack = [...this.trail.frames] as Frame[]
317
+
318
+ // ── Step : Resolver ──────────────────────────────────
319
+ spanBuilder?.stepStart('Resolver')
320
+ const newStack = await this.resolver.resolve(currentStack)
321
+ spanBuilder?.stepEnd('Resolver')
322
+
323
+ for (const newFrame of newStack) {
324
+ if (newFrame.state === 'RESOLVED' || newFrame.state === 'DEFERRED') {
325
+ this.trail.updateFrame(newFrame.entity, newFrame)
326
+ }
327
+ }
328
+
329
+ const justResolved = newStack.find(
330
+ f => f.entity === nextUnresolved.entity && f.state === 'RESOLVED'
331
+ )
332
+ if (justResolved?.resolvedBy) {
333
+ this.events.emit('traversal.complete', {
334
+ from: justResolved.resolvedBy.via,
335
+ to: justResolved.entity,
336
+ path: { nodes: [justResolved.resolvedBy.via, justResolved.entity], edges: [], totalWeight: 0 },
337
+ durationMs: Date.now() - startTime,
338
+ stackDepth: this.trail.depth,
339
+ routeUsed: justResolved.resolvedBy.relation,
340
+ routeWeight: 0,
341
+ })
342
+ }
343
+
344
+ results.push({
345
+ time: t, mode: 'NAVIGATE', phase: 'RESOLVE',
346
+ resolvedCount: resolved, unresolvedCount: unresolved.length
347
+ })
348
+ }
349
+
350
+ return results
351
+ }
352
+
353
+ private async runSchedule(
354
+ maxSteps: number,
355
+ spanBuilder: ReturnType<typeof shim.startSpan>
356
+ ): Promise<EngineStepResult[]> {
357
+ if (!this.scheduler) throw new Error('SCHEDULE requiert Scheduler')
358
+
359
+ const results: EngineStepResult[] = []
360
+ let currentStack: Frame[] = [...this.trail.frames]
361
+
362
+ for (let t = 0; t < maxSteps; t++) {
363
+ // ── Step : Scheduler ─────────────────────────────────
364
+ spanBuilder?.stepStart('Scheduler')
365
+ const stepResult = await this.scheduler.step(t, currentStack)
366
+ spanBuilder?.stepEnd('Scheduler')
367
+
368
+ if (!stepResult) {
369
+ results.push({
370
+ time: t, mode: 'SCHEDULE', phase: 'COMPLETE',
371
+ result: { type: 'SUCCESS', reason: 'Plus aucune action disponible' }
372
+ })
373
+ break
374
+ }
375
+
376
+ currentStack = stepResult.updatedStack
377
+
378
+ this.events.emit('traversal.complete', {
379
+ from: stepResult.selectedAction,
380
+ to: currentStack[currentStack.length - 1]?.entity ?? '',
381
+ path: { nodes: [], edges: [], totalWeight: 0 },
382
+ durationMs: 0,
383
+ stackDepth: this.trail.depth,
384
+ routeUsed: stepResult.selectedAction,
385
+ routeWeight: 0,
386
+ })
387
+
388
+ results.push({
389
+ time: t, mode: 'SCHEDULE', phase: 'EXECUTE',
390
+ selectedAction: stepResult.selectedAction,
391
+ result: stepResult.result
392
+ })
393
+ }
394
+
395
+ return results
396
+ }
397
+
398
+ // ==================== HELPERS INSTRUMENTATION ====================
399
+
400
+ private _buildTrailString(): string {
401
+ return this.trail.frames
402
+ .map(f => f.id !== undefined ? `${f.entity}(${f.id})` : f.entity)
403
+ .join('.')
404
+ }
405
+
406
+ private _resolvedFrom(): string {
407
+ const resolved = [...this.trail.frames].reverse().find(f => f.state === 'RESOLVED')
408
+ if (resolved) return resolved.entity
409
+ if (this.config.pathQuery) return this.config.pathQuery.from
410
+ return this.trail.frames[0]?.entity ?? ''
411
+ }
412
+
413
+ private _targetTo(): string {
414
+ const unresolved = this.trail.unresolved[0]
415
+ if (unresolved) return unresolved.entity
416
+ if (this.config.pathQuery) return this.config.pathQuery.to
417
+ return this.trail.frames[this.trail.frames.length - 1]?.entity ?? ''
418
+ }
419
+
420
+ private _currentFilters(): Record<string, any> {
421
+ const resolved = this.trail.frames.find(f => f.state === 'RESOLVED' && f.id !== undefined)
422
+ return resolved?.id !== undefined ? { id: resolved.id } : {}
423
+ }
424
+
425
+ private _countRows(results: EngineStepResult[]): number {
426
+ // PATHFIND : nombre de chemins trouvés
427
+ if (this.mode === 'PATHFIND') {
428
+ return results.filter(r => r.result?.type === 'SUCCESS').length
429
+ }
430
+ // NAVIGATE/SCHEDULE : nombre de steps résolus
431
+ return results.filter(r => r.phase === 'RESOLVE' || r.phase === 'EXECUTE').length
432
+ }
433
+
434
+ // ==================== GETTERS ====================
435
+
436
+ getMode(): EngineMode { return this.mode }
437
+ getGraph(): Graph { return this.graph }
438
+
439
+ /** @deprecated Utiliser engine.trail directement */
440
+ getCurrentStack(): Frame[] { return [...this.trail.frames] }
441
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Resolver - Résolution sémantique de frames (mode NAVIGATE)
3
+ *
4
+ * Parcourt la stack, trouve la première frame UNRESOLVED,
5
+ * identifie la meilleure arête dans le graphe V3 (nodes/edges)
6
+ * et résout la frame avec les filtres appropriés.
7
+ */
8
+
9
+ import type { Frame, Graph, GraphEdge, FrameFilter } from '../types/index.js'
10
+
11
+ export class Resolver {
12
+ constructor(private graph: Graph) {}
13
+
14
+ /**
15
+ * Résout la prochaine frame UNRESOLVED dans la stack.
16
+ * Retourne une nouvelle stack avec la frame résolue (ou DEFERRED si impossible).
17
+ */
18
+ async resolve(stack: Frame[]): Promise<Frame[]> {
19
+ const unresolved = stack.filter(f => f.state === 'UNRESOLVED')
20
+
21
+ if (unresolved.length === 0) return stack
22
+
23
+ const frame = unresolved[0]
24
+
25
+ // Frame racine (en position 0, aucune frame résolue avant elle) :
26
+ // c'est une collection ou un point d'entrée — pas besoin d'arête entrante.
27
+ const frameIndex = stack.indexOf(frame)
28
+ const hasPriorResolved = stack
29
+ .slice(0, frameIndex)
30
+ .some(f => f.state === 'RESOLVED' && f.id !== undefined && f.id !== null)
31
+
32
+ if (!hasPriorResolved) {
33
+ return stack.map(f => (f === frame ? { ...f, state: 'RESOLVED' as const } : f))
34
+ }
35
+
36
+ const candidate = this.selectBestEdge(frame, stack)
37
+
38
+ if (!candidate) {
39
+ console.warn(`[Resolver] Aucune arête trouvée pour "${frame.entity}"`)
40
+ return stack.map(f => (f === frame ? { ...f, state: 'DEFERRED' as const } : f))
41
+ }
42
+
43
+ const { edge, sourceFrame } = candidate
44
+
45
+ const resolved: Frame = {
46
+ ...frame,
47
+ state: 'RESOLVED',
48
+ resolvedBy: {
49
+ relation: edge.name ?? `${edge.from}→${edge.to}`,
50
+ via: edge.via ?? edge.from,
51
+ filters: [
52
+ {
53
+ field: `${sourceFrame.entity.toLowerCase()}Id`,
54
+ operator: 'equals',
55
+ value: sourceFrame.id!
56
+ },
57
+ // Filtres portés par l'arête elle-même (ex: condition sémantique)
58
+ ...this.extractEdgeFilters(edge)
59
+ ]
60
+ }
61
+ }
62
+
63
+ return stack.map(f => (f === frame ? resolved : f))
64
+ }
65
+
66
+ /**
67
+ * Trouve l'arête la plus pertinente pour résoudre une frame.
68
+ *
69
+ * Logique : on cherche parmi les frames RESOLVED (en remontant la stack),
70
+ * une arête qui va de cette entité source vers l'entité cible.
71
+ */
72
+ private selectBestEdge(
73
+ frame: Frame,
74
+ stack: Frame[]
75
+ ): { edge: GraphEdge; sourceFrame: Frame } | null {
76
+ // Frames résolues, les plus récentes en premier (dernier contexte connu)
77
+ const resolvedFrames = [...stack]
78
+ .reverse()
79
+ .filter(f => f.state === 'RESOLVED' && f.id !== undefined && f.id !== null)
80
+
81
+ for (const source of resolvedFrames) {
82
+ const candidates = this.graph.edges.filter(edge => {
83
+ // L'arête doit partir de l'entité source et arriver à l'entité cible
84
+ const matchesDirection = edge.from === source.entity && edge.to === frame.entity
85
+
86
+ // Si la frame a une intention, on vérifie la compatibilité sémantique
87
+ if (matchesDirection && frame.intent && edge.metadata?.condition) {
88
+ return this.intentMatchesCondition(frame.intent, edge.metadata.condition)
89
+ }
90
+
91
+ return matchesDirection
92
+ })
93
+
94
+ // On prend la candidate avec le poids le plus faible (chemin le plus direct)
95
+ if (candidates.length > 0) {
96
+ const best = candidates.sort((a, b) => a.weight - b.weight)[0]
97
+ return { edge: best, sourceFrame: source }
98
+ }
99
+ }
100
+
101
+ return null
102
+ }
103
+
104
+ /**
105
+ * Vérifie si l'intention de la frame est compatible avec
106
+ * les conditions sémantiques portées par l'arête.
107
+ */
108
+ private intentMatchesCondition(
109
+ intent: Record<string, any>,
110
+ condition: string | Record<string, any>
111
+ ): boolean {
112
+ if (typeof condition === 'string') return true // Pas de condition structurée
113
+
114
+ return Object.entries(condition).every(([key, value]) => {
115
+ if (intent[key] === undefined) return true // On ne filtre pas ce qu'on ne connaît pas
116
+ return intent[key] === value
117
+ })
118
+ }
119
+
120
+ /**
121
+ * Extrait les filtres implicites portés par les métadonnées d'une arête.
122
+ * Ex: une arête sémantique { condition: { jobId: 2 } } devient un filtre.
123
+ */
124
+ private extractEdgeFilters(edge: GraphEdge): FrameFilter[] {
125
+ const condition = edge.metadata?.condition
126
+ if (!condition || typeof condition === 'string') return []
127
+
128
+ return Object.entries(condition).map(([field, value]) => ({
129
+ field,
130
+ operator: 'equals' as const,
131
+ value
132
+ }))
133
+ }
134
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Scheduler - Exécution d'actions par priorité (mode SCHEDULE)
3
+ *
4
+ * À chaque step :
5
+ * 1. Filtre les actions disponibles (condition when + cooldown + terminal)
6
+ * 2. Sélectionne la plus prioritaire (weight le plus élevé)
7
+ * 3. L'exécute et met à jour l'état interne
8
+ *
9
+ * C'est le cœur du moteur d'agent : une boucle de décision
10
+ * déterministe basée sur les poids et l'état de la stack.
11
+ */
12
+
13
+ import type {
14
+ ScheduleAction,
15
+ ActionState,
16
+ Frame,
17
+ Graph,
18
+ NavigationResult
19
+ } from '../types/index.js'
20
+
21
+ export interface SchedulerStepResult {
22
+ selectedAction: string
23
+ result: NavigationResult
24
+ updatedStack: Frame[]
25
+ }
26
+
27
+ export class Scheduler {
28
+ private actionStates: Map<string, ActionState>
29
+
30
+ constructor(
31
+ private actions: ScheduleAction[],
32
+ private graph: Graph
33
+ ) {
34
+ // Initialise l'état de chaque action
35
+ this.actionStates = new Map(
36
+ actions.map(a => [
37
+ a.name,
38
+ { cooldownUntil: 0, executionCount: 0, executed: false }
39
+ ])
40
+ )
41
+ }
42
+
43
+ /**
44
+ * Exécute un step du scheduler.
45
+ * Retourne null si aucune action n'est disponible (terminaison naturelle).
46
+ */
47
+ async step(time: number, stack: Frame[]): Promise<SchedulerStepResult | null> {
48
+ const available = this.getAvailableActions(time, stack)
49
+
50
+ if (available.length === 0) return null
51
+
52
+ // Sélection déterministe : weight le plus élevé
53
+ const selected = available.reduce((best, action) =>
54
+ action.weight > best.weight ? action : best
55
+ )
56
+
57
+ console.log(` ⚙️ [Scheduler t=${time}] → ${selected.name} (weight: ${selected.weight})`)
58
+
59
+ let result: NavigationResult
60
+ let updatedStack: Frame[] = stack
61
+
62
+ try {
63
+ updatedStack = await selected.execute(stack, this.graph)
64
+ result = { type: 'SUCCESS', data: updatedStack }
65
+ } catch (err) {
66
+ result = {
67
+ type: 'FAIL',
68
+ reason: err instanceof Error ? err.message : String(err)
69
+ }
70
+ console.error(` ❌ [Scheduler] Action échouée: ${selected.name}`, err)
71
+ }
72
+
73
+ // Callback optionnel (analytics, logging externe...)
74
+ if (selected.onUse) {
75
+ try {
76
+ selected.onUse(stack, result)
77
+ } catch {
78
+ // Silencieux — le callback ne doit pas planter le scheduler
79
+ }
80
+ }
81
+
82
+ // Mise à jour de l'état de l'action
83
+ this.updateState(selected, result, time)
84
+
85
+ return {
86
+ selectedAction: selected.name,
87
+ result,
88
+ updatedStack: result.type === 'SUCCESS' ? updatedStack : stack
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Retourne les actions disponibles à un instant t pour une stack donnée.
94
+ * Filtre : terminal déjà exécuté, cooldown, maxExecutions, condition when().
95
+ */
96
+ getAvailableActions(time: number, stack: Frame[]): ScheduleAction[] {
97
+ return this.actions.filter(action => {
98
+ const state = this.actionStates.get(action.name)!
99
+
100
+ // Action terminale déjà exécutée
101
+ if (action.terminal && state.executed) return false
102
+
103
+ // En cooldown
104
+ if (state.cooldownUntil > time) return false
105
+
106
+ // Limite d'exécutions atteinte
107
+ if (action.maxExecutions !== undefined && state.executionCount >= action.maxExecutions) {
108
+ return false
109
+ }
110
+
111
+ // Condition métier
112
+ if (!action.when) return true
113
+
114
+ try {
115
+ return action.when(stack)
116
+ } catch {
117
+ return false
118
+ }
119
+ })
120
+ }
121
+
122
+ private updateState(action: ScheduleAction, result: NavigationResult, time: number): void {
123
+ const current = this.actionStates.get(action.name)!
124
+
125
+ this.actionStates.set(action.name, {
126
+ cooldownUntil: result.type === 'DEFER' ? time + (action.cooldown ?? 0) : 0,
127
+ executionCount: current.executionCount + 1,
128
+ executed: action.terminal ? true : current.executed,
129
+ lastResult: result
130
+ })
131
+ }
132
+
133
+ getActionState(name: string): ActionState | undefined {
134
+ return this.actionStates.get(name)
135
+ }
136
+ }