diff --git a/lib/bus-corridor-hypergraph-solver/BusCorridorHypergraphSolver.ts b/lib/bus-corridor-hypergraph-solver/BusCorridorHypergraphSolver.ts new file mode 100644 index 0000000..57a85b7 --- /dev/null +++ b/lib/bus-corridor-hypergraph-solver/BusCorridorHypergraphSolver.ts @@ -0,0 +1,640 @@ +import { + TinyHyperGraphSolver, + type Candidate, + type TinyHyperGraphProblem, + type TinyHyperGraphSolverOptions, + type TinyHyperGraphTopology, +} from "../core" +import type { PortId, RouteId } from "../types" + +interface BusCorridorHypergraphSolverOptionTarget { + CENTER_DISTANCE_TO_COST: number + CENTERLINE_LAYER_DIFFERENCE_COST: number +} + +export interface BusCorridorHypergraphSolverOptions + extends TinyHyperGraphSolverOptions { + CENTER_DISTANCE_TO_COST?: number + CENTERLINE_LAYER_DIFFERENCE_COST?: number +} + +interface PointLike { + x: number + y: number +} + +interface CenterlineSegment { + fromPortId: PortId + toPortId: PortId + x1: number + y1: number + x2: number + y2: number +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null + +const normalizeCoordinate = (value: number) => (Object.is(value, -0) ? 0 : value) + +const getPortPointKey = (x: number, y: number) => + `${normalizeCoordinate(x).toFixed(9)},${normalizeCoordinate(y).toFixed(9)}` + +const stripLayerSuffix = (portLabel: string) => + portLabel.replace(/_z\d+(?:::\d+)?$/, "") + +const getPointToSegmentDistance = ( + pointX: number, + pointY: number, + segmentX1: number, + segmentY1: number, + segmentX2: number, + segmentY2: number, +) => { + const dx = segmentX2 - segmentX1 + const dy = segmentY2 - segmentY1 + const lengthSquared = dx * dx + dy * dy + + if (lengthSquared === 0) { + return Math.hypot(pointX - segmentX1, pointY - segmentY1) + } + + const projection = Math.max( + 0, + Math.min( + 1, + ((pointX - segmentX1) * dx + (pointY - segmentY1) * dy) / lengthSquared, + ), + ) + + return Math.hypot( + pointX - (segmentX1 + projection * dx), + pointY - (segmentY1 + projection * dy), + ) +} + +const getRoutePoint = ( + routeMetadata: unknown, + pointIndex: number, +): PointLike | undefined => { + if (!isRecord(routeMetadata)) { + return undefined + } + + const simpleRouteConnection = isRecord(routeMetadata.simpleRouteConnection) + ? routeMetadata.simpleRouteConnection + : undefined + const pointsToConnect = Array.isArray(simpleRouteConnection?.pointsToConnect) + ? simpleRouteConnection.pointsToConnect + : undefined + const point = isRecord(pointsToConnect?.[pointIndex]) + ? pointsToConnect[pointIndex] + : undefined + + return typeof point?.x === "number" && typeof point?.y === "number" + ? { + x: point.x, + y: point.y, + } + : undefined +} + +const getExplicitBusOrder = (routeMetadata: unknown): number | undefined => { + if (!isRecord(routeMetadata)) { + return undefined + } + + const busMetadata = isRecord(routeMetadata._bus) + ? routeMetadata._bus + : isRecord(routeMetadata.bus) + ? routeMetadata.bus + : undefined + + return typeof busMetadata?.order === "number" && + Number.isFinite(busMetadata.order) + ? busMetadata.order + : undefined +} + +const getDominantAxis = (points: Array): "x" | "y" => { + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const point of points) { + if (!point) continue + minX = Math.min(minX, point.x) + maxX = Math.max(maxX, point.x) + minY = Math.min(minY, point.y) + maxY = Math.max(maxY, point.y) + } + + const spreadX = Number.isFinite(minX) ? maxX - minX : 0 + const spreadY = Number.isFinite(minY) ? maxY - minY : 0 + + return spreadX >= spreadY ? "x" : "y" +} + +const getAverageAbsoluteRankDifference = ( + leftRanks: Float64Array, + rightRanks: Float64Array, + reverseRightRanks: boolean, +) => { + let totalDifference = 0 + let comparableRouteCount = 0 + const maxRightRank = rightRanks.length - 1 + + for (let routeId = 0; routeId < leftRanks.length; routeId++) { + const leftRank = leftRanks[routeId] + const rightRank = rightRanks[routeId] + if (!Number.isFinite(leftRank) || !Number.isFinite(rightRank)) { + continue + } + + totalDifference += Math.abs( + leftRank - (reverseRightRanks ? maxRightRank - rightRank : rightRank), + ) + comparableRouteCount += 1 + } + + return comparableRouteCount > 0 + ? totalDifference / comparableRouteCount + : Number.POSITIVE_INFINITY +} + +const getRouteIdsInMedianFirstOrder = (routeIds: RouteId[]) => { + const centerOutRouteIds: RouteId[] = [] + let left = Math.floor((routeIds.length - 1) / 2) + let right = left + 1 + + if (left >= 0) { + centerOutRouteIds.push(routeIds[left]!) + } + + while (left > 0 || right < routeIds.length) { + left -= 1 + if (left >= 0) { + centerOutRouteIds.push(routeIds[left]!) + } + if (right < routeIds.length) { + centerOutRouteIds.push(routeIds[right]!) + right += 1 + } + } + + return centerOutRouteIds +} + +const getRouteIdsInBusOrder = (problem: TinyHyperGraphProblem) => { + const routeIds = Array.from( + { length: problem.routeCount }, + (_, routeId) => routeId as RouteId, + ) + const explicitBusOrders = routeIds.map((routeId) => + getExplicitBusOrder(problem.routeMetadata?.[routeId]), + ) + + if (explicitBusOrders.every((order) => order !== undefined)) { + return [...routeIds].sort( + (leftRouteId, rightRouteId) => + explicitBusOrders[leftRouteId]! - explicitBusOrders[rightRouteId]!, + ) + } + + const startPoints = routeIds.map((routeId) => + getRoutePoint(problem.routeMetadata?.[routeId], 0), + ) + const endPoints = routeIds.map((routeId) => + getRoutePoint(problem.routeMetadata?.[routeId], 1), + ) + const startAxis = getDominantAxis(startPoints) + const endAxis = getDominantAxis(endPoints) + const startRankByRouteId = new Float64Array(problem.routeCount).fill( + Number.POSITIVE_INFINITY, + ) + const endRankByRouteId = new Float64Array(problem.routeCount).fill( + Number.POSITIVE_INFINITY, + ) + + const startRoutesWithPoints = routeIds.filter((routeId) => startPoints[routeId]) + const endRoutesWithPoints = routeIds.filter((routeId) => endPoints[routeId]) + + startRoutesWithPoints + .sort((leftRouteId, rightRouteId) => { + const leftPoint = startPoints[leftRouteId]! + const rightPoint = startPoints[rightRouteId]! + const leftValue = leftPoint[startAxis] + const rightValue = rightPoint[startAxis] + + if (leftValue !== rightValue) { + return leftValue - rightValue + } + + return leftRouteId - rightRouteId + }) + .forEach((routeId, rank) => { + startRankByRouteId[routeId] = rank + }) + + endRoutesWithPoints + .sort((leftRouteId, rightRouteId) => { + const leftPoint = endPoints[leftRouteId]! + const rightPoint = endPoints[rightRouteId]! + const leftValue = leftPoint[endAxis] + const rightValue = rightPoint[endAxis] + + if (leftValue !== rightValue) { + return leftValue - rightValue + } + + return leftRouteId - rightRouteId + }) + .forEach((routeId, rank) => { + endRankByRouteId[routeId] = rank + }) + + const reverseEndRanks = + getAverageAbsoluteRankDifference(startRankByRouteId, endRankByRouteId, true) < + getAverageAbsoluteRankDifference(startRankByRouteId, endRankByRouteId, false) + + return [...routeIds].sort((leftRouteId, rightRouteId) => { + const leftStartRank = Number.isFinite(startRankByRouteId[leftRouteId]) + ? startRankByRouteId[leftRouteId] + : leftRouteId + const rightStartRank = Number.isFinite(startRankByRouteId[rightRouteId]) + ? startRankByRouteId[rightRouteId] + : rightRouteId + const leftEndRank = Number.isFinite(endRankByRouteId[leftRouteId]) + ? reverseEndRanks + ? problem.routeCount - 1 - endRankByRouteId[leftRouteId] + : endRankByRouteId[leftRouteId] + : leftRouteId + const rightEndRank = Number.isFinite(endRankByRouteId[rightRouteId]) + ? reverseEndRanks + ? problem.routeCount - 1 - endRankByRouteId[rightRouteId] + : endRankByRouteId[rightRouteId] + : rightRouteId + const leftAverageRank = (leftStartRank + leftEndRank) / 2 + const rightAverageRank = (rightStartRank + rightEndRank) / 2 + + if (leftAverageRank !== rightAverageRank) { + return leftAverageRank - rightAverageRank + } + if (leftStartRank !== rightStartRank) { + return leftStartRank - rightStartRank + } + if (leftEndRank !== rightEndRank) { + return leftEndRank - rightEndRank + } + + return leftRouteId - rightRouteId + }) +} + +export class BusCorridorHypergraphSolver extends TinyHyperGraphSolver { + CENTER_DISTANCE_TO_COST = 0.1 + CENTERLINE_LAYER_DIFFERENCE_COST = 5 + + readonly routeIdsInBusOrder: RouteId[] + readonly routeIdsInSolveOrder: RouteId[] + readonly routeDistanceFromCenterByRouteId: Float64Array + readonly centerRouteId: RouteId | undefined + readonly portPointKeyByPortId: string[] + readonly portIdsByPointKey: Map + centerlineSegments: CenterlineSegment[] + centerlineLayer: number | undefined + + constructor( + topology: TinyHyperGraphTopology, + problem: TinyHyperGraphProblem, + options?: BusCorridorHypergraphSolverOptions, + ) { + super(topology, problem, options) + + if (options?.CENTER_DISTANCE_TO_COST !== undefined) { + this.CENTER_DISTANCE_TO_COST = options.CENTER_DISTANCE_TO_COST + } + if (options?.CENTERLINE_LAYER_DIFFERENCE_COST !== undefined) { + this.CENTERLINE_LAYER_DIFFERENCE_COST = + options.CENTERLINE_LAYER_DIFFERENCE_COST + } + + this.routeIdsInBusOrder = getRouteIdsInBusOrder(problem) + this.routeIdsInSolveOrder = getRouteIdsInMedianFirstOrder( + this.routeIdsInBusOrder, + ) + this.centerRouteId = this.routeIdsInSolveOrder[0] + this.routeDistanceFromCenterByRouteId = new Float64Array( + problem.routeCount, + ) + this.portPointKeyByPortId = Array.from( + { length: topology.portCount }, + (_, portId) => getPortPointKey(topology.portX[portId], topology.portY[portId]), + ) + this.portIdsByPointKey = new Map() + this.centerlineSegments = [] + this.centerlineLayer = undefined + + const centerRouteIndex = Math.floor((this.routeIdsInBusOrder.length - 1) / 2) + this.routeIdsInBusOrder.forEach((routeId, routeIndex) => { + this.routeDistanceFromCenterByRouteId[routeId] = Math.abs( + routeIndex - centerRouteIndex, + ) + }) + this.portPointKeyByPortId.forEach((pointKey, portId) => { + const portIds = this.portIdsByPointKey.get(pointKey) ?? [] + portIds.push(portId as PortId) + this.portIdsByPointKey.set(pointKey, portIds) + }) + + this.state.unroutedRoutes = [...this.routeIdsInSolveOrder] + } + + getPortDebugLabel(portId: PortId) { + const serializedPortId = this.topology.portMetadata?.[portId]?.serializedPortId + return serializedPortId ?? `port-${portId}` + } + + getPortPointDebugLabel(portId: PortId) { + return stripLayerSuffix(this.getPortDebugLabel(portId)) + } + + getAssignedPortPointOccupant(portId: PortId) { + const pointKey = this.portPointKeyByPortId[portId] + const portIds = pointKey ? this.portIdsByPointKey.get(pointKey) : undefined + + return portIds?.find( + (candidatePortId) => this.state.portAssignment[candidatePortId] !== -1, + ) + } + + isAssignedPort(portId: PortId) { + return this.state.portAssignment[portId] !== -1 + } + + isAssignedPortPoint(portId: PortId) { + return this.getAssignedPortPointOccupant(portId) !== undefined + } + + setCenterlineLayerFromSolvedSegments( + routeId: RouteId, + solvedSegments: Array<{ + regionId: number + fromPortId: PortId + toPortId: PortId + }>, + ) { + if (this.centerlineLayer !== undefined || routeId !== this.centerRouteId) { + return + } + + const layerCounts = new Map() + + if (solvedSegments.length === 0) { + const startPortId = this.problem.routeStartPort[routeId] + this.centerlineLayer = this.topology.portZ[startPortId] + return + } + + for (const { fromPortId, toPortId } of solvedSegments) { + for (const portId of [fromPortId, toPortId]) { + const z = this.topology.portZ[portId] + layerCounts.set(z, (layerCounts.get(z) ?? 0) + 1) + } + } + + const [preferredLayer] = + [...layerCounts.entries()].sort((left, right) => { + if (left[1] !== right[1]) { + return right[1] - left[1] + } + + return left[0] - right[0] + })[0] ?? [] + + this.centerlineLayer = + preferredLayer ?? this.topology.portZ[this.problem.routeStartPort[routeId]] + } + + setCenterlineGeometryFromSolvedSegments( + routeId: RouteId, + solvedSegments: Array<{ + regionId: number + fromPortId: PortId + toPortId: PortId + }>, + ) { + if (routeId !== this.centerRouteId) { + return + } + + this.centerlineSegments = solvedSegments.map(({ fromPortId, toPortId }) => ({ + fromPortId, + toPortId, + x1: this.topology.portX[fromPortId], + y1: this.topology.portY[fromPortId], + x2: this.topology.portX[toPortId], + y2: this.topology.portY[toPortId], + })) + } + + failForAssignedRoutePort( + routeId: RouteId, + portId: PortId, + endpoint: "start" | "end" | "path", + ) { + const occupantPortId = this.getAssignedPortPointOccupant(portId) ?? portId + const connectionId = + this.problem.routeMetadata?.[routeId]?.connectionId ?? `route-${routeId}` + this.failed = true + this.error = `Bus route ${connectionId} cannot reuse assigned ${endpoint} port point ${this.getPortPointDebugLabel(portId)} via ${this.getPortDebugLabel(portId)} (occupied by ${this.getPortDebugLabel(occupantPortId)})` + this.stats = { + ...this.stats, + failedRouteId: routeId, + failedConnectionId: connectionId, + failedPortId: portId, + failedPortLabel: this.getPortDebugLabel(portId), + failedPortPointLabel: this.getPortPointDebugLabel(portId), + conflictingPortId: occupantPortId, + conflictingPortLabel: this.getPortDebugLabel(occupantPortId), + failedPortEndpoint: endpoint, + ripCount: 0, + } + } + + override _step() { + if (this.state.currentRouteId === undefined) { + const nextRouteId = this.state.unroutedRoutes[0] + + if (nextRouteId !== undefined) { + const startPortId = this.problem.routeStartPort[nextRouteId] + if (this.isAssignedPortPoint(startPortId)) { + this.failForAssignedRoutePort(nextRouteId, startPortId, "start") + return + } + + const endPortId = this.problem.routeEndPort[nextRouteId] + if (this.isAssignedPortPoint(endPortId)) { + this.failForAssignedRoutePort(nextRouteId, endPortId, "end") + return + } + } + } + + super._step() + } + + getDistanceFromCenterline(portId: PortId) { + if (this.centerlineSegments.length === 0) { + return 0 + } + + const pointX = this.topology.portX[portId] + const pointY = this.topology.portY[portId] + let minDistance = Number.POSITIVE_INFINITY + + for (const segment of this.centerlineSegments) { + minDistance = Math.min( + minDistance, + getPointToSegmentDistance( + pointX, + pointY, + segment.x1, + segment.y1, + segment.x2, + segment.y2, + ), + ) + } + + return Number.isFinite(minDistance) ? minDistance : 0 + } + + getRouteDistanceFromCenter(routeId: RouteId) { + return this.routeDistanceFromCenterByRouteId[routeId] ?? 0 + } + + getCorridorPenalty(routeId: RouteId, boundaryPortId: PortId) { + if ( + routeId === this.centerRouteId || + this.centerlineSegments.length === 0 + ) { + return 0 + } + + return this.getDistanceFromCenterline(boundaryPortId) * this.CENTER_DISTANCE_TO_COST + } + + getCenterlineLayerPenalty(routeId: RouteId, boundaryPortId: PortId) { + if ( + routeId === this.centerRouteId || + this.centerlineLayer === undefined + ) { + return 0 + } + + const boundaryPortLayer = this.topology.portZ[boundaryPortId] + return ( + Math.abs(boundaryPortLayer - this.centerlineLayer) * + this.CENTERLINE_LAYER_DIFFERENCE_COST + ) + } + + override _setup() { + this.state.unroutedRoutes = [...this.routeIdsInSolveOrder] + this.centerlineSegments = [] + this.centerlineLayer = undefined + this.stats = { + ...this.stats, + routeIdsInBusOrder: [...this.routeIdsInBusOrder], + routeIdsInSolveOrder: [...this.routeIdsInSolveOrder], + } + void this.problemSetup + } + + override resetRoutingStateForRerip() { + super.resetRoutingStateForRerip() + this.state.unroutedRoutes = [...this.routeIdsInSolveOrder] + this.centerlineSegments = [] + this.centerlineLayer = undefined + } + + override onAllRoutesRouted() { + this.stats = { + ...this.stats, + ripCount: 0, + } + this.solved = true + } + + override onOutOfCandidates() { + const failedRouteId = this.state.currentRouteId + const failedConnectionId = + failedRouteId !== undefined && + typeof this.problem.routeMetadata?.[failedRouteId]?.connectionId === "string" + ? this.problem.routeMetadata[failedRouteId].connectionId + : undefined + + this.failed = true + this.error = + failedConnectionId !== undefined + ? `Out of candidates while routing ${failedConnectionId}` + : failedRouteId !== undefined + ? `Out of candidates while routing route ${failedRouteId}` + : "Out of candidates while routing bus corridor" + this.stats = { + ...this.stats, + failedRouteId, + failedConnectionId, + ripCount: 0, + } + } + + override isPortReservedForDifferentNet(portId: PortId): boolean { + return ( + this.isAssignedPortPoint(portId) || + super.isPortReservedForDifferentNet(portId) + ) + } + + override onPathFound(finalCandidate: Candidate) { + const currentRouteId = this.state.currentRouteId + if (currentRouteId === undefined) return + + const solvedSegments = this.getSolvedPathSegments(finalCandidate) + for (const { fromPortId, toPortId } of solvedSegments) { + if (this.isAssignedPortPoint(fromPortId)) { + this.failForAssignedRoutePort(currentRouteId, fromPortId, "path") + return + } + + if (this.isAssignedPortPoint(toPortId)) { + this.failForAssignedRoutePort(currentRouteId, toPortId, "path") + return + } + } + + this.setCenterlineGeometryFromSolvedSegments(currentRouteId, solvedSegments) + this.setCenterlineLayerFromSolvedSegments(currentRouteId, solvedSegments) + super.onPathFound(finalCandidate) + } + + override computeG(currentCandidate: Candidate, neighborPortId: PortId): number { + const baseG = super.computeG(currentCandidate, neighborPortId) + if (!Number.isFinite(baseG)) { + return baseG + } + + const currentRouteId = this.state.currentRouteId + if (currentRouteId === undefined) { + return baseG + } + + return ( + baseG + + this.getCorridorPenalty(currentRouteId, neighborPortId) + + this.getCenterlineLayerPenalty(currentRouteId, neighborPortId) + ) + } +} diff --git a/lib/bus-corridor-hypergraph-solver/index.ts b/lib/bus-corridor-hypergraph-solver/index.ts new file mode 100644 index 0000000..97e9a80 --- /dev/null +++ b/lib/bus-corridor-hypergraph-solver/index.ts @@ -0,0 +1 @@ +export * from "./BusCorridorHypergraphSolver" diff --git a/lib/index.ts b/lib/index.ts index 8eafe66..44f6736 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,4 +1,5 @@ export * from "./core" +export * from "./bus-corridor-hypergraph-solver" export * from "./region-graph" export { convertPortPointPathingSolverInputToSerializedHyperGraph } from "./compat/convertPortPointPathingSolverInputToSerializedHyperGraph" export { diff --git a/lib/visualizeTinyGraph.ts b/lib/visualizeTinyGraph.ts index b8b88e2..a188d0e 100644 --- a/lib/visualizeTinyGraph.ts +++ b/lib/visualizeTinyGraph.ts @@ -482,6 +482,36 @@ const pushRoutePortZPoints = ( } } +const pushUnassignedPorts = ( + solver: TinyHyperGraphSolver, + graphics: Required, +) => { + for (let portId = 0; portId < solver.topology.portCount; portId++) { + if (solver.state.portAssignment[portId] !== -1) { + continue + } + + graphics.circles.push({ + center: getPortCircleCenter(solver, portId), + radius: 0.04, + fill: + solver.topology.portZ[portId] > 0 + ? "rgba(52, 152, 219, 0.18)" + : "rgba(148, 163, 184, 0.22)", + stroke: + solver.topology.portZ[portId] > 0 + ? "rgba(52, 152, 219, 0.6)" + : "rgba(100, 116, 139, 0.65)", + layer: getPortVisualizationLayer(solver, portId), + label: formatLabel( + getPortLabel(solver, portId), + getPortZLabel(solver, portId), + "assignment: unassigned", + ), + }) + } +} + const pushInitialRouteHints = ( solver: TinyHyperGraphSolver, graphics: Required, @@ -782,6 +812,7 @@ export const visualizeTinyHyperGraph = ( } else { pushSolvedRegionSegments(solver, graphics) pushRoutePortZPoints(solver, graphics) + pushUnassignedPorts(solver, graphics) pushActiveRoute(solver, graphics) pushCandidates(solver, graphics) } diff --git a/pages/cm5io/bus-corridor-solve.page.tsx b/pages/cm5io/bus-corridor-solve.page.tsx new file mode 100644 index 0000000..19a0269 --- /dev/null +++ b/pages/cm5io/bus-corridor-solve.page.tsx @@ -0,0 +1,132 @@ +import type { SerializedHyperGraph } from "@tscircuit/hypergraph" +import { + BusCorridorHypergraphSolver, + filterPortPointPathingSolverInputByConnectionPatches, + type ConnectionPatchSelection, +} from "lib/index" +import { + convertPortPointPathingSolverInputToSerializedHyperGraph, + type SerializedHyperGraphPortPointPathingSolverInput, +} from "lib/compat/convertPortPointPathingSolverInputToSerializedHyperGraph" +import { loadSerializedHyperGraph } from "lib/compat/loadSerializedHyperGraph" +import { useEffect, useState } from "react" +import { Debugger } from "../components/Debugger" + +const cm5ioHyperGraphFixtureUrl = new URL( + "../../tests/fixtures/CM5IO_HyperGraph.json", + import.meta.url, +).href + +const cm5ioBusSelectionFixtureUrl = new URL( + "../../tests/fixtures/CM5IO_bus1.json", + import.meta.url, +).href + +const createBusSubsetSerializedHyperGraph = ( + fullInput: SerializedHyperGraphPortPointPathingSolverInput, + busSelection: ConnectionPatchSelection, +): SerializedHyperGraph => + convertPortPointPathingSolverInputToSerializedHyperGraph( + filterPortPointPathingSolverInputByConnectionPatches( + fullInput, + busSelection, + ), + ) + +export default function Cm5ioBusCorridorSolvePage() { + const [serializedHyperGraph, setSerializedHyperGraph] = + useState() + const [errorMessage, setErrorMessage] = useState() + + useEffect(() => { + let isCancelled = false + + const loadFixture = async () => { + try { + const [fullResponse, busSelectionResponse] = await Promise.all([ + fetch(cm5ioHyperGraphFixtureUrl), + fetch(cm5ioBusSelectionFixtureUrl), + ]) + + if (!fullResponse.ok) { + throw new Error( + `Failed to load CM5IO hypergraph fixture (${fullResponse.status})`, + ) + } + + if (!busSelectionResponse.ok) { + throw new Error( + `Failed to load CM5IO bus selection fixture (${busSelectionResponse.status})`, + ) + } + + const fullInput = + (await fullResponse.json()) as SerializedHyperGraphPortPointPathingSolverInput + const busSelection = + (await busSelectionResponse.json()) as ConnectionPatchSelection + const subsetSerializedHyperGraph = createBusSubsetSerializedHyperGraph( + fullInput, + busSelection, + ) + + if (!isCancelled) { + setSerializedHyperGraph(subsetSerializedHyperGraph) + setErrorMessage(undefined) + } + } catch (error) { + if (!isCancelled) { + setErrorMessage( + error instanceof Error + ? error.message + : "Failed to load CM5IO bus subset fixture", + ) + } + } + } + + void loadFixture() + + return () => { + isCancelled = true + } + }, []) + + if (errorMessage) { + return ( +
+
+ {errorMessage} +
+
+ ) + } + + if (!serializedHyperGraph) { + return ( +
+ Loading CM5IO bus subset fixture... +
+ ) + } + + return ( +
+
+ Bus-corridor solver on the CM5IO `bus1` subset. It routes traces + center-out, applies a center-distance corridor penalty to non-center + traces, and does a single pass without rerip congestion updates. +
+
+ { + const { topology, problem } = loadSerializedHyperGraph(graph) + return new BusCorridorHypergraphSolver(topology, problem, { + MAX_ITERATIONS: 2_000_000, + }) + }} + /> +
+
+ ) +} diff --git a/tests/solver/bus-corridor-hypergraph-solver.test.ts b/tests/solver/bus-corridor-hypergraph-solver.test.ts new file mode 100644 index 0000000..c9d36c6 --- /dev/null +++ b/tests/solver/bus-corridor-hypergraph-solver.test.ts @@ -0,0 +1,420 @@ +import { expect, test } from "bun:test" +import { + BusCorridorHypergraphSolver, + filterPortPointPathingSolverInputByConnectionPatches, + type ConnectionPatchSelection, +} from "lib/index" +import { + convertPortPointPathingSolverInputToSerializedHyperGraph, + type SerializedHyperGraphPortPointPathingSolverInput, +} from "lib/compat/convertPortPointPathingSolverInputToSerializedHyperGraph" +import { loadSerializedHyperGraph } from "lib/compat/loadSerializedHyperGraph" +import type { TinyHyperGraphProblem, TinyHyperGraphTopology } from "lib/index" + +const createOrderingTopology = (): TinyHyperGraphTopology => ({ + portCount: 10, + regionCount: 2, + regionIncidentPorts: [ + Array.from({ length: 10 }, (_, index) => index), + Array.from({ length: 10 }, (_, index) => index), + ], + incidentPortRegion: Array.from({ length: 10 }, () => [0, 1]), + regionWidth: new Float64Array([10, 10]), + regionHeight: new Float64Array([10, 10]), + regionCenterX: new Float64Array([0, 10]), + regionCenterY: new Float64Array([0, 0]), + portAngleForRegion1: new Int32Array(10), + portAngleForRegion2: new Int32Array(10), + portX: new Float64Array(10), + portY: new Float64Array(10), + portZ: new Int32Array(10), +}) + +const createOrderingProblem = (): TinyHyperGraphProblem => ({ + routeCount: 5, + portSectionMask: new Int8Array(10).fill(1), + routeMetadata: [ + { + connectionId: "route-0", + simpleRouteConnection: { + pointsToConnect: [ + { x: 0, y: 0 }, + { x: 40, y: 0 }, + ], + }, + }, + { + connectionId: "route-1", + simpleRouteConnection: { + pointsToConnect: [ + { x: 1, y: 0 }, + { x: 30, y: 0 }, + ], + }, + }, + { + connectionId: "route-2", + simpleRouteConnection: { + pointsToConnect: [ + { x: 2, y: 0 }, + { x: 20, y: 0 }, + ], + }, + }, + { + connectionId: "route-3", + simpleRouteConnection: { + pointsToConnect: [ + { x: 3, y: 0 }, + { x: 10, y: 0 }, + ], + }, + }, + { + connectionId: "route-4", + simpleRouteConnection: { + pointsToConnect: [ + { x: 4, y: 0 }, + { x: 0, y: 0 }, + ], + }, + }, + ], + routeStartPort: new Int32Array([0, 1, 2, 3, 4]), + routeEndPort: new Int32Array([5, 6, 7, 8, 9]), + routeNet: new Int32Array([0, 1, 2, 3, 4]), + regionNetId: new Int32Array(2).fill(-1), +}) + +const createSharedStartTopology = (): TinyHyperGraphTopology => ({ + portCount: 3, + regionCount: 2, + regionIncidentPorts: [ + [0, 1, 2], + [0, 1, 2], + ], + incidentPortRegion: [ + [0, 1], + [0, 1], + [0, 1], + ], + regionWidth: new Float64Array([4, 4]), + regionHeight: new Float64Array([4, 4]), + regionCenterX: new Float64Array([0, 4]), + regionCenterY: new Float64Array([0, 0]), + portAngleForRegion1: new Int32Array(3), + portAngleForRegion2: new Int32Array(3), + portX: new Float64Array([0, 1, 2]), + portY: new Float64Array([0, 0, 0]), + portZ: new Int32Array(3), +}) + +const createSharedStartProblem = (): TinyHyperGraphProblem => ({ + routeCount: 2, + portSectionMask: new Int8Array(3).fill(1), + routeMetadata: [ + { + connectionId: "route-0", + _bus: { order: 0 }, + }, + { + connectionId: "route-1", + _bus: { order: 1 }, + }, + ], + routeStartPort: new Int32Array([0, 0]), + routeEndPort: new Int32Array([1, 2]), + routeNet: new Int32Array([0, 1]), + regionNetId: new Int32Array(2).fill(-1), +}) + +const createSharedPortPointAcrossLayersTopology = (): TinyHyperGraphTopology => ({ + portCount: 4, + regionCount: 2, + regionIncidentPorts: [ + [0, 1, 2, 3], + [0, 1, 2, 3], + ], + incidentPortRegion: [ + [0, 1], + [0, 1], + [0, 1], + [0, 1], + ], + regionWidth: new Float64Array([4, 4]), + regionHeight: new Float64Array([4, 4]), + regionCenterX: new Float64Array([0, 4]), + regionCenterY: new Float64Array([0, 0]), + portAngleForRegion1: new Int32Array(4), + portAngleForRegion2: new Int32Array(4), + portX: new Float64Array([0, 0, 1, 2]), + portY: new Float64Array([0, 0, 0, 0]), + portZ: new Int32Array([0, 1, 0, 0]), +}) + +const createSharedPortPointAcrossLayersProblem = (): TinyHyperGraphProblem => ({ + routeCount: 2, + portSectionMask: new Int8Array(4).fill(1), + routeMetadata: [ + { + connectionId: "route-0", + _bus: { order: 0 }, + }, + { + connectionId: "route-1", + _bus: { order: 1 }, + }, + ], + routeStartPort: new Int32Array([0, 1]), + routeEndPort: new Int32Array([2, 3]), + routeNet: new Int32Array([0, 1]), + regionNetId: new Int32Array(2).fill(-1), +}) + +const createLayerPenaltyTopology = (): TinyHyperGraphTopology => ({ + portCount: 3, + regionCount: 2, + regionIncidentPorts: [ + [0, 1, 2], + [0, 1, 2], + ], + incidentPortRegion: [ + [0, 1], + [0, 1], + [0, 1], + ], + regionWidth: new Float64Array([4, 4]), + regionHeight: new Float64Array([4, 4]), + regionCenterX: new Float64Array([0, 4]), + regionCenterY: new Float64Array([0, 0]), + portAngleForRegion1: new Int32Array(3), + portAngleForRegion2: new Int32Array(3), + portX: new Float64Array([0, 1, 1]), + portY: new Float64Array([0, 0, 0]), + portZ: new Int32Array([0, 0, 1]), +}) + +const createLayerPenaltyProblem = (): TinyHyperGraphProblem => ({ + routeCount: 2, + portSectionMask: new Int8Array(3).fill(1), + routeMetadata: [ + { + connectionId: "centerline", + _bus: { order: 0 }, + }, + { + connectionId: "outer", + _bus: { order: 1 }, + }, + ], + routeStartPort: new Int32Array([0, 0]), + routeEndPort: new Int32Array([1, 2]), + routeNet: new Int32Array([0, 1]), + regionNetId: new Int32Array(2).fill(-1), +}) + +const hasCandidatePathThroughAssignedPortPoint = ( + solver: BusCorridorHypergraphSolver, +) => { + const assignedPointKeys = new Set( + Array.from({ length: solver.topology.portCount }, (_, portId) => portId) + .filter((portId) => solver.state.portAssignment[portId] !== -1) + .map((portId) => solver.portPointKeyByPortId[portId]), + ) + + return solver.state.candidateQueue.toArray().some((candidate) => { + let cursor: typeof candidate | undefined = candidate + + while (cursor) { + if (assignedPointKeys.has(solver.portPointKeyByPortId[cursor.portId])) { + return true + } + + cursor = cursor.prevCandidate + } + + return false + }) +} + +const createCm5ioBus1SerializedHyperGraph = async () => { + const fullInput = (await Bun.file( + new URL("../fixtures/CM5IO_HyperGraph.json", import.meta.url), + ).json()) as SerializedHyperGraphPortPointPathingSolverInput + const busSelection = (await Bun.file( + new URL("../fixtures/CM5IO_bus1.json", import.meta.url), + ).json()) as ConnectionPatchSelection + + return convertPortPointPathingSolverInputToSerializedHyperGraph( + filterPortPointPathingSolverInputByConnectionPatches( + fullInput, + busSelection, + ), + ) +} + +test("BusCorridorHypergraphSolver infers mirrored bus order and solves center-out", () => { + const solver = new BusCorridorHypergraphSolver( + createOrderingTopology(), + createOrderingProblem(), + ) + + expect(solver.routeIdsInBusOrder).toEqual([0, 1, 2, 3, 4]) + expect(solver.routeIdsInSolveOrder).toEqual([2, 1, 3, 0, 4]) + expect(Array.from(solver.routeDistanceFromCenterByRouteId)).toEqual([ + 2, + 1, + 0, + 1, + 2, + ]) +}) + +test("BusCorridorHypergraphSolver rejects routes that reuse an assigned start port", () => { + const solver = new BusCorridorHypergraphSolver( + createSharedStartTopology(), + createSharedStartProblem(), + ) + + solver.solve() + + expect(solver.solved).toBe(false) + expect(solver.failed).toBe(true) + expect(solver.error).toContain("cannot reuse assigned start port") + expect(Array.from(solver.state.portAssignment)).toEqual([0, 0, -1]) +}) + +test("BusCorridorHypergraphSolver rejects routes that reuse an assigned port point across layers", () => { + const solver = new BusCorridorHypergraphSolver( + createSharedPortPointAcrossLayersTopology(), + createSharedPortPointAcrossLayersProblem(), + ) + + solver.solve() + + expect(solver.solved).toBe(false) + expect(solver.failed).toBe(true) + expect(solver.error).toContain("cannot reuse assigned start port point") + expect(solver.error).toContain("occupied by") + expect(Array.from(solver.state.portAssignment)).toEqual([0, -1, 0, -1]) +}) + +test("BusCorridorHypergraphSolver heavily penalizes moving off the centerline layer", () => { + const solver = new BusCorridorHypergraphSolver( + createLayerPenaltyTopology(), + createLayerPenaltyProblem(), + { + CENTERLINE_LAYER_DIFFERENCE_COST: 7, + }, + ) + + solver.centerlineLayer = 0 + + expect(solver.getCenterlineLayerPenalty(1, 1)).toBe(0) + expect(solver.getCenterlineLayerPenalty(1, 2)).toBe(7) + expect(solver.getCenterlineLayerPenalty(0, 2)).toBe(0) +}) + +test("BusCorridorHypergraphSolver does not explore candidate paths through assigned CM5IO port points", async () => { + const serializedHyperGraph = await createCm5ioBus1SerializedHyperGraph() + const { topology, problem } = loadSerializedHyperGraph(serializedHyperGraph) + const solver = new BusCorridorHypergraphSolver(topology, problem, { + MAX_ITERATIONS: 2_000_000, + }) + + solver._setup() + + for (let iteration = 0; iteration < 5_000; iteration++) { + solver._step() + solver.iterations += 1 + + const hasAssignedPort = Array.from(solver.state.portAssignment).some( + (assignment) => assignment !== -1, + ) + const isRoutingNonCenterlineRoute = + solver.state.currentRouteId !== undefined && + solver.state.currentRouteId !== solver.centerRouteId + const hasCandidates = solver.state.candidateQueue.toArray().length > 0 + + if (hasAssignedPort && isRoutingNonCenterlineRoute && hasCandidates) { + break + } + + if (solver.solved || solver.failed) { + break + } + } + + expect(solver.solved).toBe(false) + expect(solver.failed).toBe(false) + expect(solver.centerlineLayer).toBeDefined() + expect(solver.state.currentRouteId).not.toBeUndefined() + expect(solver.state.currentRouteId).not.toBe(solver.centerRouteId) + expect(hasCandidatePathThroughAssignedPortPoint(solver)).toBe(false) +}) + +test("BusCorridorHypergraphSolver keeps the iteration-11 best candidate close to the CM5IO centerline", async () => { + const serializedHyperGraph = await createCm5ioBus1SerializedHyperGraph() + const { topology, problem } = loadSerializedHyperGraph(serializedHyperGraph) + const solver = new BusCorridorHypergraphSolver(topology, problem, { + MAX_ITERATIONS: 2_000_000, + }) + + solver._setup() + + for (let iteration = 0; iteration < 11; iteration++) { + solver._step() + solver.iterations += 1 + } + + const bestCandidate = solver.state.candidateQueue + .toArray() + .sort((left, right) => left.f - right.f)[0] + + expect(solver.state.currentRouteId).not.toBeUndefined() + expect(solver.state.currentRouteId).not.toBe(solver.centerRouteId) + expect(solver.centerlineSegments.length).toBeGreaterThan(0) + expect(bestCandidate).toBeDefined() + expect( + solver.getDistanceFromCenterline(bestCandidate!.portId), + ).toBeLessThan(1) +}) + +test("BusCorridorHypergraphSolver solves the CM5IO bus1 subset", async () => { + const serializedHyperGraph = await createCm5ioBus1SerializedHyperGraph() + const { topology, problem } = loadSerializedHyperGraph(serializedHyperGraph) + const solver = new BusCorridorHypergraphSolver(topology, problem, { + MAX_ITERATIONS: 2_000_000, + }) + + solver.solve() + + expect(solver.routeIdsInSolveOrder.map((routeId) => routeId)).toEqual([ + 4, + 3, + 5, + 2, + 6, + 1, + 7, + 0, + 8, + ]) + expect( + solver.routeIdsInSolveOrder.map( + (routeId) => problem.routeMetadata?.[routeId]?.connectionId, + ), + ).toEqual([ + "source_trace_108", + "source_trace_109", + "source_trace_107", + "source_trace_110", + "source_trace_106", + "source_trace_111", + "source_trace_105", + "source_trace_114", + "source_trace_104", + ]) + expect(solver.solved).toBe(true) + expect(solver.failed).toBe(false) +}) diff --git a/tests/visualizeTinyGraph.test.ts b/tests/visualizeTinyGraph.test.ts new file mode 100644 index 0000000..72356b6 --- /dev/null +++ b/tests/visualizeTinyGraph.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test" +import { TinyHyperGraphSolver } from "lib/index" +import type { TinyHyperGraphProblem, TinyHyperGraphTopology } from "lib/index" +import { visualizeTinyGraph } from "lib/visualizeTinyGraph" + +const createVisualizationTopology = (): TinyHyperGraphTopology => ({ + portCount: 4, + regionCount: 2, + regionIncidentPorts: [ + [0, 1, 2, 3], + [0, 1, 2, 3], + ], + incidentPortRegion: [ + [0, 1], + [0, 1], + [0, 1], + [0, 1], + ], + regionWidth: new Float64Array([4, 4]), + regionHeight: new Float64Array([4, 4]), + regionCenterX: new Float64Array([0, 5]), + regionCenterY: new Float64Array([0, 0]), + portAngleForRegion1: new Int32Array(4), + portAngleForRegion2: new Int32Array(4), + portX: new Float64Array([0, 1, 2, 3]), + portY: new Float64Array([0, 0, 0, 0]), + portZ: new Int32Array(4), +}) + +const createVisualizationProblem = (): TinyHyperGraphProblem => ({ + routeCount: 1, + portSectionMask: new Int8Array(4).fill(1), + routeStartPort: new Int32Array([0]), + routeEndPort: new Int32Array([1]), + routeNet: new Int32Array([0]), + regionNetId: new Int32Array(2).fill(-1), +}) + +test("visualizeTinyGraph draws unassigned ports after routing begins", () => { + const solver = new TinyHyperGraphSolver( + createVisualizationTopology(), + createVisualizationProblem(), + ) + + solver.solve() + + const graphics = visualizeTinyGraph(solver) + const circles = graphics.circles ?? [] + const unassignedCircles = circles.filter( + (circle: { label?: string }) => + circle.label?.includes("assignment: unassigned"), + ) + const unassignedLabels = unassignedCircles + .map((circle: { label?: string }) => circle.label ?? "") + .sort() + + expect(solver.solved).toBe(true) + expect(solver.failed).toBe(false) + expect(Array.from(solver.state.portAssignment)).toEqual([0, 0, -1, -1]) + expect(unassignedCircles).toHaveLength(2) + expect( + unassignedLabels.some((label: string) => label.includes("port: port-2")), + ).toBe( + true, + ) + expect( + unassignedLabels.some((label: string) => label.includes("port: port-3")), + ).toBe( + true, + ) +})