Skip to content

Commit 757b2fb

Browse files
committed
feat(algorithms): add traversal and weight modes to path ranking
Add independent traversal and weight handling for path ranking: Traversal mode (independent of graph structure): - 'directed': Only traverse edges in their natural direction - 'undirected': Traverse edges in both directions (default) Weight mode for incorporating edge weights: - 'none': Ignore weights (default) - 'divide': Divide score by arithmetic mean of edge weights - 'multiplicative': Apply multiplicative penalty exp(-mean(log(weights))) New types: - TraversalMode: 'directed' | 'undirected' - WeightMode: 'none' | 'divide' | 'multiplicative' Extended PathRankingConfig with: - traversalMode: Control path traversal direction - weightMode: Control weight handling - weightExtractor: Extract weight from edges Extended RankedPath with: - lengthPenalty: Length penalty factor when lambda > 0 - weightFactor: Weight factor when weightMode != 'none' Updated findAllShortestPaths to pre-compute incoming edges for undirected traversal on directed graphs.
1 parent 17435c7 commit 757b2fb

File tree

1 file changed

+180
-22
lines changed

1 file changed

+180
-22
lines changed

packages/algorithms/src/pathfinding/path-ranking.ts

Lines changed: 180 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
100160
const 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
*/
436594
export 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

Comments
 (0)