PageRank
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
pagerank.ts
import type { Tracer } from "../trace";import type { AdjacencyGraph } from "./graph";export interface PageRankResult { /** Each page's rank after the last iteration; the ranks sum to one. */ readonly ranks: readonly number[]; readonly rankOfPage: readonly number[]; /** How many iterations ran, converged or not. */ readonly iterations: number; /** Whether the last iteration's L1 change fell under the tolerance. */ readonly converged: boolean; /** The ranks before every iteration and after the last: `iterations + 1` rows, the first uniform. */ readonly history: readonly (readonly number[])[]; /** The pages with no outgoing link, whose rank is spread over every page each iteration. */ readonly dangling: readonly number[];}/** Payload ranks are shown to four decimals; the report keeps the raw values. */const round = (value: number): number => Math.round(value * 10_000) / 10_000;/** * PageRank by power iteration: every page starts with rank `1 / n`, and * each iteration a page hands its rank out equally along its links. A * page with no links (a dangling page) hands its rank to every page * equally instead, so no rank leaks out of the web. A damping factor `d` * keeps `d` of the handed-out rank and spreads the rest, `(1 - d) / n`, * over every page: the random surfer who follows a link with chance `d` * and jumps anywhere otherwise. The iteration stops when the L1 change * between two rounds falls under the tolerance, or after `maxIterations`. * * Only directed graphs: an undirected graph would make every page a * mutual link. Edge weights are ignored: every link counts once, each * entry in a page's adjacency list is one link. Ties and order are * deterministic; the ranks always sum to one. * * Time: O(I (V + E)). Space: O(I V) for the history. */export class PageRank { constructor( readonly graph: AdjacencyGraph, readonly damping = 0.85, readonly tolerance = 0.001, private readonly tracer?: Tracer, ) { if (!graph.directed) throw new RangeError("PageRank needs a directed graph"); if (!(damping > 0 && damping < 1)) throw new RangeError(`damping ${damping} must lie strictly between 0 and 1`); if (!(tolerance > 0)) throw new RangeError(`tolerance ${tolerance} must be positive`); } run(maxIterations = 30): PageRankResult { if (!Number.isInteger(maxIterations) || maxIterations < 1) throw new RangeError(`maxIterations ${maxIterations} must be a positive integer`); const n = this.graph.nodeCount; const d = this.damping; const outDegree = Array.from({ length: n }, (_, node) => this.graph.neighbors(node).length); const dangling: number[] = []; outDegree.forEach((degree, node) => { if (degree === 0) dangling.push(node); }); let ranks: number[] = Array.from({ length: n }, () => 1 / n); let rankOfPage: number[] = Array.from({ length: n }, () => 1 / n); const history: number[][] = [ranks.slice()]; const history: number[][] = [rankOfPage.slice()]; // ▸ every page starts equal: one nth of the rank each this.tracer?.emit("init", { damping: d, ranks: ranks.map(round), tolerance: this.tolerance }); this.tracer?.emit("init", { damping: d, rankOfPage: rankOfPage.map(round), tolerance: this.tolerance }); let iterations = 0; let converged = false; for (let iteration = 1; iteration <= maxIterations; iteration++) { iterations = iteration; const mass = dangling.reduce((sum, node) => sum + ranks[node]!, 0); const mass = dangling.reduce((sum, node) => sum + rankOfPage[node]!, 0); // ▸ pages that link nowhere hand their rank to everyone if (dangling.length > 0) this.tracer?.emit("dangling", { iteration, mass: round(mass), nodes: dangling.slice() }); // Every page receives the dangling mass evenly, then its share of each linking page's rank. const incoming: number[] = Array.from({ length: n }, () => mass / n); const handedIn: number[] = Array.from({ length: n }, () => mass / n); for (let from = 0; from < n; from++) { const degree = outDegree[from]!; if (degree === 0) continue; const share = ranks[from]! / degree; const share = rankOfPage[from]! / degree; for (const { to } of this.graph.neighbors(from)) incoming[to] = incoming[to]! + share; for (const { to } of this.graph.neighbors(from)) handedIn[to] = handedIn[to]! + share; } const next: number[] = Array.from({ length: n }, () => 0); let delta = 0; let totalChange = 0; for (let node = 0; node < n; node++) { const value = (1 - d) / n + d * incoming[node]!; const value = (1 - d) / n + d * handedIn[node]!; next[node] = value; delta += Math.abs(value - ranks[node]!); totalChange += Math.abs(value - rankOfPage[node]!); // ▸ a page's new rank: the teleport share plus d of what its links hand in this.tracer?.emit("rank", { incoming: round(incoming[node]!), iteration, node, value: round(value) }); this.tracer?.emit("rank", { handedIn: round(handedIn[node]!), iteration, node, value: round(value) }); } ranks = next; rankOfPage = next; history.push(ranks.slice()); history.push(rankOfPage.slice()); // ▸ one round done: how far did the ranks move in total? this.tracer?.emit("iterate", { delta: round(delta), iteration, ranks: ranks.map(round) }); this.tracer?.emit("iterate", { totalChange: round(totalChange), iteration, rankOfPage: rankOfPage.map(round) }); if (delta < this.tolerance) { if (totalChange < this.tolerance) { converged = true; break; } } // ▸ settled under the tolerance, or stopped at the cap this.tracer?.emit("verdict", { converged, iterations, ranks: ranks.map(round) }); this.tracer?.emit("verdict", { converged, iterations, rankOfPage: rankOfPage.map(round) }); return { ranks, iterations, converged, history, dangling }; return { rankOfPage, iterations, converged, history, dangling }; }}n0 ranks highest at 0.3192; the last round moved the ranks 0.0009 in total, converged after 8 iterations.