From 45b9fe562a3ed31f29d3307e4316acb24c64a0e3 Mon Sep 17 00:00:00 2001 From: ShiboSoftwareDev Date: Fri, 7 Aug 2026 13:51:53 +0200 Subject: [PATCH 1/2] reproduce overbooked region boundaries --- lib/region-graph/visualizeRegionGraph.ts | 150 ++++++++++++++++-- .../negotiated-region-path.fixture.ts | 69 ++++++++ .../region-path-capacity-negotiation.snap.svg | 127 +++++++++++++++ .../region-path-capacity-negotiation.test.ts | 23 +++ 4 files changed, 359 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/negotiated-region-path.fixture.ts create mode 100644 tests/solver/__snapshots__/region-path-capacity-negotiation.snap.svg create mode 100644 tests/solver/region-path-capacity-negotiation.test.ts diff --git a/lib/region-graph/visualizeRegionGraph.ts b/lib/region-graph/visualizeRegionGraph.ts index 71443e6..13eded5 100644 --- a/lib/region-graph/visualizeRegionGraph.ts +++ b/lib/region-graph/visualizeRegionGraph.ts @@ -4,10 +4,15 @@ import type { RegionPathCandidate, RegionPathSolver, } from "./region-path-solver" -import type { RegionId, RouteId } from "../types" +import type { NetId, RegionId, RouteId } from "../types" const REGION_RECT_GAP = 0.05 +interface RegionPathUsage { + regionUsage: Int32Array + edgeUsage: Int32Array +} + const clamp01 = (value: number) => Math.min(1, Math.max(0, value)) const formatLabel = (...lines: Array) => @@ -117,10 +122,51 @@ const getRouteColor = ( return `hsla(${hue}, 70%, 50%, ${alpha})` } -const getRegionFill = (solver: RegionPathSolver, regionId: RegionId) => { - const usage = solver.state.regionUsage[regionId] +const getRegionPathUsage = (solver: RegionPathSolver): RegionPathUsage => { + const regionAssignedNets = Array.from( + { length: solver.regionGraph.regionCount }, + () => new Set(), + ) + const edgeAssignedNets = Array.from( + { length: solver.regionGraph.edgeCount }, + () => new Set(), + ) + const edgeIdByNeighbor = Array.from( + { length: solver.regionGraph.regionCount }, + () => new Map(), + ) + + for (const edge of solver.regionGraph.edges) { + edgeIdByNeighbor[edge.regionIdA]!.set(edge.regionIdB, edge.edgeId) + edgeIdByNeighbor[edge.regionIdB]!.set(edge.regionIdA, edge.edgeId) + } + solver.state.solvedRouteRegionIds.forEach((regionPath, routeId) => { + const netId = solver.regionProblem.routeNet[routeId]! + for (const regionId of regionPath) { + regionAssignedNets[regionId]!.add(netId) + } + for (let pathIndex = 1; pathIndex < regionPath.length; pathIndex++) { + const previousRegionId = regionPath[pathIndex - 1]! + const regionId = regionPath[pathIndex]! + const edgeId = edgeIdByNeighbor[previousRegionId]!.get(regionId) + if (edgeId !== undefined) edgeAssignedNets[edgeId]!.add(netId) + } + }) + + return { + regionUsage: Int32Array.from(regionAssignedNets, (nets) => nets.size), + edgeUsage: Int32Array.from(edgeAssignedNets, (nets) => nets.size), + } +} + +const getRegionFill = ( + solver: RegionPathSolver, + usage: RegionPathUsage, + regionId: RegionId, +) => { + const regionUsage = usage.regionUsage[regionId] const capacity = solver.regionGraph.regionCapacity[regionId] - const utilization = clamp01(usage / capacity) + const utilization = clamp01(regionUsage / capacity) const red = Math.round(216 + (239 - 216) * utilization) const green = Math.round(240 - 112 * utilization) const blue = Math.round(254 - 180 * utilization) @@ -129,17 +175,21 @@ const getRegionFill = (solver: RegionPathSolver, regionId: RegionId) => { return `rgba(${red}, ${green}, ${blue}, ${alpha.toFixed(3)})` } -const getRegionLabel = (solver: RegionPathSolver, regionId: RegionId) => { - const usage = solver.state.regionUsage[regionId] +const getRegionLabel = ( + solver: RegionPathSolver, + usage: RegionPathUsage, + regionId: RegionId, +) => { + const regionUsage = usage.regionUsage[regionId] const capacity = solver.regionGraph.regionCapacity[regionId] - const utilization = usage / capacity + const utilization = regionUsage / capacity const reservedNetId = solver.regionProblem.regionNetId[regionId] const assignedRoutes = solver.state.regionAssignedRoutes[regionId] return formatLabel( `region: ${getSerializedRegionId(solver.regionGraph, regionId)}`, `capacity: ${capacity.toFixed(3)}`, - `usage: ${usage}`, + `usage: ${regionUsage}`, `fill: ${(utilization * 100).toFixed(1)}%`, `net: ${reservedNetId === -1 ? "free" : reservedNetId.toString()}`, assignedRoutes.length > 0 @@ -148,6 +198,83 @@ const getRegionLabel = (solver: RegionPathSolver, regionId: RegionId) => { ) } +const pushRegionEdges = ( + solver: RegionPathSolver, + graphics: Required, + usage: RegionPathUsage, +) => { + for (const edge of solver.regionGraph.edges) { + const capacity = edge.portIds.length + const edgeUsage = usage.edgeUsage[edge.edgeId] + const isOverCapacity = edgeUsage > capacity + graphics.lines.push({ + points: [ + getRegionCenter(solver, edge.regionIdA), + getRegionCenter(solver, edge.regionIdB), + ], + strokeColor: isOverCapacity + ? "rgba(220, 38, 38, 0.95)" + : "rgba(100, 116, 139, 0.45)", + strokeWidth: isOverCapacity ? 0.06 : 0.025, + layer: getRegionLayer(solver, edge.regionIdA), + label: formatLabel( + `boundary: ${getSerializedRegionId(solver.regionGraph, edge.regionIdA)} ↔ ${getSerializedRegionId(solver.regionGraph, edge.regionIdB)}`, + `lane capacity: ${capacity}`, + `distinct-net demand: ${edgeUsage}`, + `status: ${isOverCapacity ? "overbooked" : "within capacity"}`, + ), + }) + if (solver.regionGraph.regionCount <= 32) { + const start = getRegionCenter(solver, edge.regionIdA) + const end = getRegionCenter(solver, edge.regionIdB) + graphics.texts.push({ + x: (start.x + end.x) / 2, + y: (start.y + end.y) / 2 + 0.12, + text: `${edgeUsage}/${capacity} nets`, + fontSize: 0.1, + color: isOverCapacity ? "rgb(185, 28, 28)" : "rgb(71, 85, 105)", + anchorSide: "bottom_center", + layer: getRegionLayer(solver, edge.regionIdA), + }) + } + } +} + +const pushSmallGraphRegionLabels = ( + solver: RegionPathSolver, + graphics: Required, + usage: RegionPathUsage, +) => { + if (solver.regionGraph.regionCount > 32) return + for ( + let regionId = 0; + regionId < solver.regionGraph.regionCount; + regionId++ + ) { + const center = getRegionCenter(solver, regionId) + graphics.texts.push( + { + x: center.x, + y: center.y + solver.regionGraph.regionHeight[regionId] / 2 + 0.12, + text: getSerializedRegionId(solver.regionGraph, regionId), + fontSize: 0.12, + color: "rgb(15, 23, 42)", + anchorSide: "bottom_center", + layer: getRegionLayer(solver, regionId), + }, + { + x: center.x, + y: center.y, + text: `${usage.regionUsage[regionId]}/${solver.regionGraph.regionCapacity[regionId]} nets`, + fontSize: 0.11, + color: "rgb(15, 23, 42)", + anchorSide: "center", + layer: getRegionLayer(solver, regionId), + }, + ) + } +} + const pushRouteHints = ( solver: RegionPathSolver, graphics: Required, @@ -258,6 +385,7 @@ const pushActiveFrontier = ( export const visualizeRegionGraph = ( solver: RegionPathSolver, ): GraphicsObject => { + const usage = getRegionPathUsage(solver) const graphics: Required = { arrows: [], circles: [], @@ -282,7 +410,7 @@ export const visualizeRegionGraph = ( center, width: Math.max(0.05, bounds.maxX - bounds.minX - REGION_RECT_GAP), height: Math.max(0.05, bounds.maxY - bounds.minY - REGION_RECT_GAP), - fill: getRegionFill(solver, regionId), + fill: getRegionFill(solver, usage, regionId), stroke: solver.state.currentRouteId !== undefined && (solver.regionProblem.routeStartRegion[solver.state.currentRouteId] === @@ -292,13 +420,15 @@ export const visualizeRegionGraph = ( ? "rgba(17, 24, 39, 0.9)" : "rgba(148, 163, 184, 0.5)", layer: getRegionLayer(solver, regionId), - label: getRegionLabel(solver, regionId), + label: getRegionLabel(solver, usage, regionId), }) } + pushRegionEdges(solver, graphics, usage) pushRouteHints(solver, graphics) pushSolvedRoutes(solver, graphics) pushRouteEndpoints(solver, graphics) + pushSmallGraphRegionLabels(solver, graphics, usage) if (solver.state.currentRouteId !== undefined) { pushActiveFrontier(solver, graphics) diff --git a/tests/fixtures/negotiated-region-path.fixture.ts b/tests/fixtures/negotiated-region-path.fixture.ts new file mode 100644 index 0000000..e441a41 --- /dev/null +++ b/tests/fixtures/negotiated-region-path.fixture.ts @@ -0,0 +1,69 @@ +import type { TinyHyperGraphProblem, TinyHyperGraphTopology } from "lib/index" + +export const negotiatedRegionPathTopology: TinyHyperGraphTopology = { + portCount: 9, + regionCount: 9, + regionIncidentPorts: [ + [0, 5], + [1], + [0, 1, 2], + [2, 3, 4], + [3, 8], + [4], + [5, 6], + [6, 7], + [7, 8], + ], + incidentPortRegion: [ + [0, 2], + [1, 2], + [2, 3], + [3, 4], + [3, 5], + [0, 6], + [6, 7], + [7, 8], + [8, 4], + ], + regionWidth: new Float64Array(9).fill(1), + regionHeight: new Float64Array(9).fill(1), + regionCenterX: new Float64Array([0, 0, 2, 4, 6, 6, 1, 3, 5]), + regionCenterY: new Float64Array([1, -1, 0, 0, 1, -1, 3, 3, 3]), + regionMetadata: [ + { serializedRegionId: "flexible-start" }, + { serializedRegionId: "constrained-start" }, + { serializedRegionId: "shared-left" }, + { serializedRegionId: "shared-right" }, + { serializedRegionId: "flexible-end" }, + { serializedRegionId: "constrained-end" }, + { serializedRegionId: "detour-left" }, + { serializedRegionId: "detour-center" }, + { serializedRegionId: "detour-right" }, + ], + portAngleForRegion1: new Int32Array(9), + portAngleForRegion2: new Int32Array(9), + portX: new Float64Array([1, 1, 3, 5, 5, 0.5, 2, 4, 5.5]), + portY: new Float64Array([0.5, -0.5, 0, 0.5, -0.5, 2, 3, 3, 2]), + portZ: new Int32Array(9), +} + +export const negotiatedRegionPathProblem: TinyHyperGraphProblem = { + routeCount: 2, + portSectionMask: new Int8Array(9).fill(1), + routeMetadata: [ + { + connectionId: "flexible-net", + startRegionId: "flexible-start", + endRegionId: "flexible-end", + }, + { + connectionId: "constrained-net", + startRegionId: "constrained-start", + endRegionId: "constrained-end", + }, + ], + routeStartPort: new Int32Array([0, 1]), + routeEndPort: new Int32Array([3, 4]), + routeNet: new Int32Array([0, 1]), + regionNetId: new Int32Array([0, 1, -1, -1, 0, 1, -1, -1, -1]), +} diff --git a/tests/solver/__snapshots__/region-path-capacity-negotiation.snap.svg b/tests/solver/__snapshots__/region-path-capacity-negotiation.snap.svg new file mode 100644 index 0000000..84f6819 --- /dev/null +++ b/tests/solver/__snapshots__/region-path-capacity-negotiation.snap.svg @@ -0,0 +1,127 @@ +1/1 nets1/1 nets2/1 nets1/1 nets1/1 nets0/1 nets0/1 nets0/1 nets0/1 netsflexible-start1/1 netsconstrained-start1/1 netsshared-left2/1 netsshared-right2/1 netsflexible-end1/1 netsconstrained-end1/1 netsdetour-left0/1 netsdetour-center0/1 netsdetour-right0/1 nets \ No newline at end of file diff --git a/tests/solver/region-path-capacity-negotiation.test.ts b/tests/solver/region-path-capacity-negotiation.test.ts new file mode 100644 index 0000000..929bf37 --- /dev/null +++ b/tests/solver/region-path-capacity-negotiation.test.ts @@ -0,0 +1,23 @@ +import "bun-match-svg" +import { expect, test } from "bun:test" +import { getSvgFromGraphicsObject } from "graphics-debug" +import { RegionPathSolver } from "lib/index" +import { + negotiatedRegionPathProblem, + negotiatedRegionPathTopology, +} from "tests/fixtures/negotiated-region-path.fixture" + +test("region path planner assigns a constrained one-lane boundary", () => { + const solver = new RegionPathSolver( + negotiatedRegionPathTopology, + negotiatedRegionPathProblem, + ) + + solver.solve() + + expect(solver.solved).toBe(true) + expect(solver.failed).toBe(false) + expect(getSvgFromGraphicsObject(solver.visualize())).toMatchSvgSnapshot( + import.meta.path, + ) +}) From b3b19aa94764c3aaefad21f8987bec93b4f25140 Mon Sep 17 00:00:00 2001 From: ShiboSoftwareDev Date: Fri, 7 Aug 2026 15:09:02 +0200 Subject: [PATCH 2/2] Expose topology capacity mode in repro --- lib/region-graph/region-path-solver.ts | 6 ++++++ tests/solver/region-path-capacity-negotiation.test.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/lib/region-graph/region-path-solver.ts b/lib/region-graph/region-path-solver.ts index bcdc66f..a3a76a2 100644 --- a/lib/region-graph/region-path-solver.ts +++ b/lib/region-graph/region-path-solver.ts @@ -16,6 +16,8 @@ import { visualizeRegionGraph } from "./visualizeRegionGraph" export interface RegionPathSolverOptions { MM_COST_FOR_FULL_REGION?: number MAX_ITERATIONS?: number + /** Enables topology-derived capacity in implementations that support it. */ + USE_TOPOLOGY_CAPACITY?: boolean } export interface RegionPathCandidate { @@ -65,6 +67,7 @@ export class RegionPathSolver extends BaseSolver { MM_COST_FOR_FULL_REGION = 20 override MAX_ITERATIONS = 1e6 + USE_TOPOLOGY_CAPACITY = false state: RegionPathWorkingState @@ -84,6 +87,9 @@ export class RegionPathSolver extends BaseSolver { if (options?.MAX_ITERATIONS !== undefined) { this.MAX_ITERATIONS = options.MAX_ITERATIONS } + if (options?.USE_TOPOLOGY_CAPACITY !== undefined) { + this.USE_TOPOLOGY_CAPACITY = options.USE_TOPOLOGY_CAPACITY + } this.state = { regionUsage: new Int32Array(this.regionGraph.regionCount), diff --git a/tests/solver/region-path-capacity-negotiation.test.ts b/tests/solver/region-path-capacity-negotiation.test.ts index 929bf37..c3c3b0f 100644 --- a/tests/solver/region-path-capacity-negotiation.test.ts +++ b/tests/solver/region-path-capacity-negotiation.test.ts @@ -11,6 +11,7 @@ test("region path planner assigns a constrained one-lane boundary", () => { const solver = new RegionPathSolver( negotiatedRegionPathTopology, negotiatedRegionPathProblem, + { USE_TOPOLOGY_CAPACITY: true }, ) solver.solve()