@@ -16,9 +16,38 @@ import {
1616 * Ranks paths between two nodes based on the geometric mean of mutual
1717 * information along their edges, with an optional length penalty.
1818 *
19+ * Supports:
20+ * - **Traversal modes**: Directed or undirected traversal (independent of graph structure)
21+ * - **Weight modes**: None, divide by mean weight, or multiplicative penalty
22+ * - **Length penalty**: Exponential penalty for longer paths
23+ *
1924 * @module pathfinding/path-ranking
2025 */
2126
27+ /**
28+ * Traversal mode determines how edges are traversed during path finding.
29+ *
30+ * This is INDEPENDENT of the graph's inherent directionality:
31+ * - **directed**: Only traverse edges in their natural direction
32+ * - On directed graph: Follow edge direction strictly
33+ * - On undirected graph: Treat as ordered sequence
34+ * - **undirected**: Traverse edges in both directions
35+ * - On directed graph: Can go against edge direction
36+ * - On undirected graph: Bidirectional (default behaviour)
37+ */
38+ export type TraversalMode = 'directed' | 'undirected' ;
39+
40+ /**
41+ * Weight mode determines how edge weights affect path scoring.
42+ *
43+ * - **none**: Ignore edge weights (default)
44+ * - **divide**: Divide MI score by arithmetic mean of edge weights
45+ * Score = geometric_mean(MI) / arithmetic_mean(weights)
46+ * - **multiplicative**: Apply multiplicative penalty from weights
47+ * Score = geometric_mean(MI) × exp(-mean(log(weights)))
48+ */
49+ export type WeightMode = 'none' | 'divide' | 'multiplicative' ;
50+
2251/**
2352 * A ranked path with its computed score.
2453 * @template N - Node type
@@ -36,13 +65,28 @@ export interface RankedPath<N extends Node, E extends Edge> {
3665
3766 /** Individual MI values for each edge in the path */
3867 edgeMIValues : number [ ] ;
68+
69+ /** Length penalty factor exp(-λk), only present if lambda > 0 */
70+ lengthPenalty ?: number ;
71+
72+ /** Weight factor applied to score, only present if weightMode != 'none' */
73+ weightFactor ?: number ;
3974}
4075
4176/**
4277 * Configuration for path ranking.
4378 * @template N - Node type
79+ * @template E - Edge type
4480 */
45- export interface PathRankingConfig < N extends Node > {
81+ export interface PathRankingConfig < N extends Node , E extends Edge = Edge > {
82+ /**
83+ * Traversal mode for path finding.
84+ * - 'directed': Only traverse edges in their natural direction
85+ * - 'undirected': Traverse edges in both directions (default)
86+ * @default 'undirected'
87+ */
88+ traversalMode ?: TraversalMode ;
89+
4690 /**
4791 * Length penalty parameter λ.
4892 * - λ = 0: Path length irrelevant, purely information quality (default)
@@ -52,6 +96,21 @@ export interface PathRankingConfig<N extends Node> {
5296 */
5397 lambda ?: number ;
5498
99+ /**
100+ * Weight mode for incorporating edge weights.
101+ * - 'none': Ignore weights (default)
102+ * - 'divide': Divide score by mean weight
103+ * - 'multiplicative': Apply multiplicative weight penalty
104+ * @default 'none'
105+ */
106+ weightMode ?: WeightMode ;
107+
108+ /**
109+ * Extract weight from an edge.
110+ * If not provided, uses edge.weight property (defaulting to 1).
111+ */
112+ weightExtractor ?: ( edge : E ) => number ;
113+
55114 /**
56115 * Maximum number of paths to return.
57116 * @default 10
@@ -75,7 +134,7 @@ export interface PathRankingConfig<N extends Node> {
75134 /**
76135 * Configuration for MI computation (only used if miCache not provided).
77136 */
78- miConfig ?: MutualInformationConfig < N > ;
137+ miConfig ?: MutualInformationConfig < N , E > ;
79138
80139 /**
81140 * Small constant to avoid log(0).
@@ -94,13 +153,15 @@ export interface PathRankingConfig<N extends Node> {
94153 * @param graph - The graph to search
95154 * @param startId - Source node ID
96155 * @param endId - Target node ID
156+ * @param traversalMode - How to traverse edges
97157 * @returns Array of all shortest paths
98158 * @internal
99159 */
100160const findAllShortestPaths = < N extends Node , E extends Edge > (
101161 graph : Graph < N , E > ,
102162 startId : string ,
103163 endId : string ,
164+ traversalMode : TraversalMode = 'undirected' ,
104165) : Path < N , E > [ ] => {
105166 if ( startId === endId ) {
106167 const node = graph . getNode ( startId ) ;
@@ -120,6 +181,50 @@ const findAllShortestPaths = <N extends Node, E extends Edge>(
120181 const queue : string [ ] = [ startId ] ;
121182 let targetDistance = Infinity ;
122183
184+ // For undirected traversal, pre-compute all edges where node is target
185+ // This allows traversing edges in reverse direction on directed graphs
186+ const incomingEdgesByNode = new Map < string , E [ ] > ( ) ;
187+ if ( traversalMode === 'undirected' ) {
188+ for ( const edge of graph . getAllEdges ( ) ) {
189+ // Store edges by their target node (for reverse traversal)
190+ const targetEdges = incomingEdgesByNode . get ( edge . target ) ?? [ ] ;
191+ targetEdges . push ( edge ) ;
192+ incomingEdgesByNode . set ( edge . target , targetEdges ) ;
193+ }
194+ }
195+
196+ // Helper to get traversable neighbours based on traversal mode
197+ const getTraversableNeighbours = ( current : string ) : Array < { neighbour : string ; edge : E } > => {
198+ const result : Array < { neighbour : string ; edge : E } > = [ ] ;
199+ const seenEdges = new Set < string > ( ) ;
200+
201+ // Get outgoing edges (always valid)
202+ const outgoing = graph . getOutgoingEdges ( current ) ;
203+ if ( outgoing . ok ) {
204+ for ( const edge of outgoing . value ) {
205+ const neighbour = edge . source === current ? edge . target : edge . source ;
206+ result . push ( { neighbour, edge } ) ;
207+ seenEdges . add ( edge . id ) ;
208+ }
209+ }
210+
211+ // For undirected traversal, also include edges where current is the target
212+ // (allows traversing edges in reverse direction)
213+ if ( traversalMode === 'undirected' ) {
214+ const incoming = incomingEdgesByNode . get ( current ) ?? [ ] ;
215+ for ( const edge of incoming ) {
216+ if ( ! seenEdges . has ( edge . id ) ) {
217+ // Current is the target, so source is the neighbour
218+ const neighbour = edge . source ;
219+ result . push ( { neighbour, edge } ) ;
220+ seenEdges . add ( edge . id ) ;
221+ }
222+ }
223+ }
224+
225+ return result ;
226+ } ;
227+
123228 while ( queue . length > 0 ) {
124229 const current = queue . shift ( ) ! ;
125230 const currentDist = distances . get ( current ) ! ;
@@ -129,11 +234,9 @@ const findAllShortestPaths = <N extends Node, E extends Edge>(
129234 continue ;
130235 }
131236
132- const edgesResult = graph . getOutgoingEdges ( current ) ;
133- if ( ! edgesResult . ok ) continue ;
237+ const neighbours = getTraversableNeighbours ( current ) ;
134238
135- for ( const edge of edgesResult . value ) {
136- const neighbour = edge . source === current ? edge . target : edge . source ;
239+ for ( const { neighbour, edge } of neighbours ) {
137240 const newDist = currentDist + 1 ;
138241
139242 const existingDist = distances . get ( neighbour ) ;
@@ -204,15 +307,29 @@ const findAllShortestPaths = <N extends Node, E extends Edge>(
204307 return paths ;
205308} ;
206309
310+ /**
311+ * Score result from path computation.
312+ * @internal
313+ */
314+ interface PathScoreResult {
315+ score : number ;
316+ geometricMeanMI : number ;
317+ edgeMIValues : number [ ] ;
318+ lengthPenalty ?: number ;
319+ weightFactor ?: number ;
320+ }
321+
207322/**
208323 * Compute the ranking score for a path.
209324 *
210- * M(P) = exp((1/k) × Σᵢ log(I(uᵢ; vᵢ))) × exp(-λk)
325+ * M(P) = exp((1/k) × Σᵢ log(I(uᵢ; vᵢ))) × exp(-λk) × weightFactor
211326 *
212327 * @param path - The path to score
213328 * @param miCache - Pre-computed MI values
214329 * @param lambda - Length penalty parameter
215330 * @param epsilon - Small constant for log safety
331+ * @param weightMode - How to incorporate edge weights
332+ * @param weightExtractor - Function to extract weight from edge
216333 * @returns Object containing score and component values
217334 * @internal
218335 */
@@ -221,7 +338,9 @@ const computePathScore = <N extends Node, E extends Edge>(
221338 miCache : MutualInformationCache ,
222339 lambda : number ,
223340 epsilon : number ,
224- ) : { score : number ; geometricMeanMI : number ; edgeMIValues : number [ ] } => {
341+ weightMode : WeightMode = 'none' ,
342+ weightExtractor ?: ( edge : E ) => number ,
343+ ) : PathScoreResult => {
225344 const k = path . edges . length ;
226345
227346 if ( k === 0 ) {
@@ -233,22 +352,54 @@ const computePathScore = <N extends Node, E extends Edge>(
233352 const edgeMIValues : number [ ] = [ ] ;
234353 let sumLogMI = 0 ;
235354
355+ // Collect weights if needed
356+ const weights : number [ ] = [ ] ;
357+
236358 for ( const edge of path . edges ) {
237359 const mi = miCache . get ( edge . id ) ?? epsilon ;
238360 edgeMIValues . push ( mi ) ;
239361 sumLogMI += Math . log ( mi + epsilon ) ;
362+
363+ if ( weightMode !== 'none' ) {
364+ const weight = weightExtractor ? weightExtractor ( edge ) : ( edge . weight ?? 1 ) ;
365+ weights . push ( Math . max ( weight , epsilon ) ) ; // Ensure positive
366+ }
240367 }
241368
242369 // Geometric mean: exp(mean(log(MI)))
243370 const geometricMeanMI = Math . exp ( sumLogMI / k ) ;
244371
245372 // Length penalty: exp(-λk)
246- const lengthPenalty = Math . exp ( - lambda * k ) ;
373+ const lengthPenalty = lambda > 0 ? Math . exp ( - lambda * k ) : undefined ;
374+
375+ // Weight factor
376+ let weightFactor : number | undefined ;
377+ if ( weightMode === 'divide' && weights . length > 0 ) {
378+ // Divide by arithmetic mean of weights
379+ const meanWeight = weights . reduce ( ( a , b ) => a + b , 0 ) / weights . length ;
380+ weightFactor = 1 / Math . max ( meanWeight , epsilon ) ;
381+ } else if ( weightMode === 'multiplicative' && weights . length > 0 ) {
382+ // Multiplicative penalty: exp(-mean(log(weights)))
383+ const meanLogWeight = weights . reduce ( ( a , w ) => a + Math . log ( w ) , 0 ) / weights . length ;
384+ weightFactor = Math . exp ( - meanLogWeight ) ;
385+ }
247386
248387 // Final score
249- const score = geometricMeanMI * lengthPenalty ;
388+ let score = geometricMeanMI ;
389+ if ( lengthPenalty !== undefined ) {
390+ score *= lengthPenalty ;
391+ }
392+ if ( weightFactor !== undefined ) {
393+ score *= weightFactor ;
394+ }
250395
251- return { score, geometricMeanMI, edgeMIValues } ;
396+ return {
397+ score,
398+ geometricMeanMI,
399+ edgeMIValues,
400+ lengthPenalty,
401+ weightFactor,
402+ } ;
252403} ;
253404
254405/**
@@ -297,10 +448,13 @@ export const rankPaths = <N extends Node, E extends Edge>(
297448 graph : Graph < N , E > ,
298449 startId : string ,
299450 endId : string ,
300- config : PathRankingConfig < N > = { } ,
451+ config : PathRankingConfig < N , E > = { } ,
301452) : Result < Option < RankedPath < N , E > [ ] > , GraphError > => {
302453 const {
454+ traversalMode = 'undirected' ,
303455 lambda = 0 ,
456+ weightMode = 'none' ,
457+ weightExtractor,
304458 maxPaths = 10 ,
305459 miCache : providedCache ,
306460 miConfig = { } ,
@@ -334,27 +488,31 @@ export const rankPaths = <N extends Node, E extends Edge>(
334488 // Get or compute MI cache
335489 const miCache = providedCache ?? precomputeMutualInformation ( graph , miConfig ) ;
336490
337- // Find all shortest paths
338- const paths = findAllShortestPaths ( graph , startId , endId ) ;
491+ // Find all shortest paths with specified traversal mode
492+ const paths = findAllShortestPaths ( graph , startId , endId , traversalMode ) ;
339493
340494 if ( paths . length === 0 ) {
341495 return Ok ( None ( ) ) ; // No path exists
342496 }
343497
344498 // Score and rank paths
345499 const rankedPaths : RankedPath < N , E > [ ] = paths . map ( ( path ) => {
346- const { score , geometricMeanMI , edgeMIValues } = computePathScore (
500+ const scoreResult = computePathScore (
347501 path ,
348502 miCache ,
349503 lambda ,
350504 epsilon ,
505+ weightMode ,
506+ weightExtractor ,
351507 ) ;
352508
353509 return {
354510 path,
355- score,
356- geometricMeanMI,
357- edgeMIValues,
511+ score : scoreResult . score ,
512+ geometricMeanMI : scoreResult . geometricMeanMI ,
513+ edgeMIValues : scoreResult . edgeMIValues ,
514+ lengthPenalty : scoreResult . lengthPenalty ,
515+ weightFactor : scoreResult . weightFactor ,
358516 } ;
359517 } ) ;
360518
@@ -393,7 +551,7 @@ export const getBestPath = <N extends Node, E extends Edge>(
393551 graph : Graph < N , E > ,
394552 startId : string ,
395553 endId : string ,
396- config : PathRankingConfig < N > = { } ,
554+ config : PathRankingConfig < N , E > = { } ,
397555) : Result < Option < RankedPath < N , E > > , GraphError > => {
398556 const result = rankPaths ( graph , startId , endId , { ...config , maxPaths : 1 } ) ;
399557
@@ -435,7 +593,7 @@ export const getBestPath = <N extends Node, E extends Edge>(
435593 */
436594export const createPathRanker = < N extends Node , E extends Edge > (
437595 graph : Graph < N , E > ,
438- config : Omit < PathRankingConfig < N > , 'miCache' > = { } ,
596+ config : Omit < PathRankingConfig < N , E > , 'miCache' > = { } ,
439597) => {
440598 // Pre-compute MI cache once
441599 const miCache = precomputeMutualInformation ( graph , config . miConfig ?? { } ) ;
@@ -447,7 +605,7 @@ export const createPathRanker = <N extends Node, E extends Edge>(
447605 rank : (
448606 startId : string ,
449607 endId : string ,
450- overrides : Partial < PathRankingConfig < N > > = { } ,
608+ overrides : Partial < PathRankingConfig < N , E > > = { } ,
451609 ) => rankPaths ( graph , startId , endId , { ...config , ...overrides , miCache } ) ,
452610
453611 /**
@@ -456,7 +614,7 @@ export const createPathRanker = <N extends Node, E extends Edge>(
456614 getBest : (
457615 startId : string ,
458616 endId : string ,
459- overrides : Partial < PathRankingConfig < N > > = { } ,
617+ overrides : Partial < PathRankingConfig < N , E > > = { } ,
460618 ) =>
461619 getBestPath ( graph , startId , endId , { ...config , ...overrides , miCache } ) ,
462620
0 commit comments