diff --git a/fixtures/via-graph-solver/via-graph-convex-dataset02.fixture.tsx b/fixtures/via-graph-solver/via-graph-convex-dataset02.fixture.tsx new file mode 100644 index 0000000..a8b2c2f --- /dev/null +++ b/fixtures/via-graph-solver/via-graph-convex-dataset02.fixture.tsx @@ -0,0 +1,193 @@ +import { GenericSolverDebugger } from "@tscircuit/solver-utils/react" +import viasByNet from "assets/ViaGraphSolver/vias-by-net.json" +import type { XYConnection } from "lib/JumperGraphSolver/jumper-graph-generator/createGraphWithConnectionsFromBaseGraph" +import { ViaGraphSolver } from "lib/ViaGraphSolver/ViaGraphSolver" +import { createConvexViaGraphFromXYConnections } from "lib/ViaGraphSolver/via-graph-generator/createConvexViaGraphFromXYConnections" +import { useMemo, useState } from "react" +import dataset from "../../datasets/jumper-graph-solver/dataset02.json" + +interface DatasetSample { + config: { + numCrossings: number + seed: number + rows: number + cols: number + orientation: "vertical" | "horizontal" + } + connections: { + connectionId: string + startRegionId: string + endRegionId: string + }[] + connectionRegions: { + regionId: string + pointIds: string[] + d: { + bounds: { minX: number; maxX: number; minY: number; maxY: number } + center: { x: number; y: number } + isPad: boolean + isConnectionRegion: boolean + } + }[] +} + +const typedDataset = dataset as DatasetSample[] + +const extractXYConnections = (sample: DatasetSample): XYConnection[] => { + const regionMap = new Map( + sample.connectionRegions.map((r) => [r.regionId, r.d.center]), + ) + + return sample.connections.map((conn) => { + const start = regionMap.get(conn.startRegionId) + const end = regionMap.get(conn.endRegionId) + + if (!start || !end) { + throw new Error( + `Missing region for connection ${conn.connectionId}: start=${conn.startRegionId}, end=${conn.endRegionId}`, + ) + } + + return { + connectionId: conn.connectionId, + start, + end, + } + }) +} + +export default () => { + const [selectedIndex, setSelectedIndex] = useState(0) + const [key, setKey] = useState(0) + + const entry = typedDataset[selectedIndex] + + const problem = useMemo(() => { + if (!entry) return null + + const xyConnections = extractXYConnections(entry) + const result = createConvexViaGraphFromXYConnections( + xyConnections, + viasByNet, + ) + + return { + graph: result, + connections: result.connections, + tileCount: result.tileCount, + tiledViasByNet: result.tiledViasByNet, + } + }, [selectedIndex]) + + if (!entry || !problem) { + return ( +
+ No dataset loaded. Ensure dataset02.json exists at: +
datasets/jumper-graph-solver/dataset02.json
+
+ ) + } + + const { config } = entry + const { tileCount } = problem + + // Count region types + const convexRegions = problem.graph.regions.filter((r) => + r.regionId.startsWith("convex:"), + ) + const viaRegions = problem.graph.regions.filter((r) => r.d.isViaRegion) + + return ( +
+
+
+ + + + +
+
+ + Config: {config.rows}x{config.cols}{" "} + {config.orientation}, {config.numCrossings} crossings, seed= + {config.seed} + + + Tiles: {tileCount.cols}x{tileCount.rows} + + + Convex regions: {convexRegions.length} + + + Via regions: {viaRegions.length} + +
+
+
+ + new ViaGraphSolver({ + inputGraph: { + regions: problem.graph.regions, + ports: problem.graph.ports, + }, + inputConnections: problem.connections, + viasByNet: problem.tiledViasByNet, + }) + } + /> +
+
+ ) +} diff --git a/lib/ViaGraphSolver/via-graph-generator/createConvexViaGraphFromXYConnections.ts b/lib/ViaGraphSolver/via-graph-generator/createConvexViaGraphFromXYConnections.ts new file mode 100644 index 0000000..8a954de --- /dev/null +++ b/lib/ViaGraphSolver/via-graph-generator/createConvexViaGraphFromXYConnections.ts @@ -0,0 +1,99 @@ +import defaultViasByNet from "assets/ViaGraphSolver/vias-by-net.json" +import type { XYConnection } from "../../JumperGraphSolver/jumper-graph-generator/createGraphWithConnectionsFromBaseGraph" +import type { JumperGraph } from "../../JumperGraphSolver/jumper-types" +import type { Connection } from "../../types" +import type { ViasByNet } from "../ViaGraphSolver" +import { createViaGraphWithConnections } from "./createViaGraphWithConnections" +import { generateConvexViaTopologyRegions } from "./generateConvexViaTopologyRegions" + +export type ConvexViaGraphFromXYConnectionsResult = JumperGraph & { + connections: Connection[] + tiledViasByNet: ViasByNet + tileCount: { rows: number; cols: number } +} + +/** + * Calculate the bounds from XY connections with no margin. + * The bounds go edge-to-edge with the connection points. + */ +function calculateBoundsFromConnections(xyConnections: XYConnection[]): { + minX: number + maxX: number + minY: number + maxY: number +} { + if (xyConnections.length === 0) { + throw new Error("Cannot calculate bounds from empty connections array") + } + + let minX = Infinity + let maxX = -Infinity + let minY = Infinity + let maxY = -Infinity + + for (const conn of xyConnections) { + minX = Math.min(minX, conn.start.x, conn.end.x) + maxX = Math.max(maxX, conn.start.x, conn.end.x) + minY = Math.min(minY, conn.start.y, conn.end.y) + maxY = Math.max(maxY, conn.start.y, conn.end.y) + } + + return { minX, maxX, minY, maxY } +} + +/** + * Creates a complete via topology graph from XY connections using convex regions. + * + * This function uses ConvexRegionsSolver to compute convex regions around + * via region obstacles, instead of the manual T/B/L/R outer regions. + * + * It: + * 1. Calculates bounds from connection XY coordinates (no margin) + * 2. Generates per-net via region polygons on a tiled grid + * 3. Uses ConvexRegionsSolver to compute convex regions around via regions + * 4. Creates ports between adjacent convex regions and via regions + * 5. Attaches connection regions to the graph + * + * @param xyConnections - Array of connections with start/end XY coordinates + * @param viasByNet - Via positions grouped by net name (defaults to built-in vias-by-net.json) + * @param opts - Optional configuration + */ +export function createConvexViaGraphFromXYConnections( + xyConnections: XYConnection[], + viasByNet: ViasByNet = defaultViasByNet as ViasByNet, + opts?: { + tileSize?: number + portPitch?: number + clearance?: number + concavityTolerance?: number + }, +): ConvexViaGraphFromXYConnectionsResult { + // Calculate bounds from connections (no margin) + const bounds = calculateBoundsFromConnections(xyConnections) + + // Generate the via topology with convex regions + const { regions, ports, tiledViasByNet, tileCount } = + generateConvexViaTopologyRegions({ + viasByNet, + bounds, + tileSize: opts?.tileSize, + portPitch: opts?.portPitch, + clearance: opts?.clearance, + concavityTolerance: opts?.concavityTolerance, + }) + + // Create base graph from regions + const baseGraph: JumperGraph = { regions, ports } + + // Add connections to the graph + const graphWithConnections = createViaGraphWithConnections( + baseGraph, + xyConnections, + ) + + return { + ...graphWithConnections, + tiledViasByNet, + tileCount, + } +} diff --git a/lib/ViaGraphSolver/via-graph-generator/findSharedEdges.ts b/lib/ViaGraphSolver/via-graph-generator/findSharedEdges.ts new file mode 100644 index 0000000..08e65d7 --- /dev/null +++ b/lib/ViaGraphSolver/via-graph-generator/findSharedEdges.ts @@ -0,0 +1,187 @@ +type Point = { x: number; y: number } + +type SharedEdge = { + from: Point + to: Point +} + +/** + * Check if two line segments are collinear (lie on the same line). + */ +function areCollinear( + a1: Point, + a2: Point, + b1: Point, + b2: Point, + tolerance: number, +): boolean { + // Direction vectors + const dax = a2.x - a1.x + const day = a2.y - a1.y + const dbx = b2.x - b1.x + const dby = b2.y - b1.y + + // Check if parallel (cross product ~ 0) + const cross = dax * dby - day * dbx + const lenA = Math.sqrt(dax * dax + day * day) + const lenB = Math.sqrt(dbx * dbx + dby * dby) + + if (lenA < tolerance || lenB < tolerance) return false + + // Normalize cross product + const normalizedCross = Math.abs(cross) / (lenA * lenB) + if (normalizedCross > tolerance) return false + + // Check if b1 lies on the line defined by a1-a2 + // Distance from b1 to line a1-a2 + const vx = b1.x - a1.x + const vy = b1.y - a1.y + const crossToB1 = Math.abs(dax * vy - day * vx) / lenA + + return crossToB1 < tolerance +} + +/** + * Project a point onto a line segment and return the parameter t (0-1 if on segment). + */ +function projectOntoSegment( + p: Point, + a: Point, + b: Point, +): { t: number; distance: number } { + const dx = b.x - a.x + const dy = b.y - a.y + const lenSq = dx * dx + dy * dy + + if (lenSq < 1e-12) { + // Degenerate segment + const dist = Math.sqrt((p.x - a.x) ** 2 + (p.y - a.y) ** 2) + return { t: 0, distance: dist } + } + + const t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq + + // Closest point on infinite line + const closestX = a.x + t * dx + const closestY = a.y + t * dy + const distance = Math.sqrt((p.x - closestX) ** 2 + (p.y - closestY) ** 2) + + return { t, distance } +} + +/** + * Compute the overlap of two 1D ranges, returns null if no overlap. + */ +function rangeOverlap( + t1Start: number, + t1End: number, + t2Start: number, + t2End: number, +): { from: number; to: number } | null { + const min1 = Math.min(t1Start, t1End) + const max1 = Math.max(t1Start, t1End) + const min2 = Math.min(t2Start, t2End) + const max2 = Math.max(t2Start, t2End) + + const overlapStart = Math.max(min1, min2) + const overlapEnd = Math.min(max1, max2) + + if (overlapEnd - overlapStart < 1e-6) return null + return { from: overlapStart, to: overlapEnd } +} + +/** + * Find shared edges between two polygons. + * + * Returns an array of line segments that are shared (overlapping collinear edges) + * between the two polygons. + * + * @param polygon1 - First polygon as array of points (closed loop, last connects to first) + * @param polygon2 - Second polygon as array of points + * @param tolerance - Distance tolerance for considering edges as shared (default 0.01) + * @returns Array of shared edge segments + */ +export function findSharedEdges( + polygon1: Point[], + polygon2: Point[], + tolerance = 0.01, +): SharedEdge[] { + const sharedEdges: SharedEdge[] = [] + + // Iterate all edges of polygon1 + for (let i = 0; i < polygon1.length; i++) { + const a1 = polygon1[i] + const a2 = polygon1[(i + 1) % polygon1.length] + + // Iterate all edges of polygon2 + for (let j = 0; j < polygon2.length; j++) { + const b1 = polygon2[j] + const b2 = polygon2[(j + 1) % polygon2.length] + + // Check if edges are collinear + if (!areCollinear(a1, a2, b1, b2, tolerance)) continue + + // Project polygon2 edge endpoints onto polygon1 edge + const proj1 = projectOntoSegment(b1, a1, a2) + const proj2 = projectOntoSegment(b2, a1, a2) + + // Check if both endpoints are close to the line + if (proj1.distance > tolerance || proj2.distance > tolerance) continue + + // Find overlap in parameter space of edge a1-a2 + const overlap = rangeOverlap(0, 1, proj1.t, proj2.t) + if (!overlap) continue + + // Convert back to world coordinates + const dx = a2.x - a1.x + const dy = a2.y - a1.y + const from: Point = { + x: a1.x + overlap.from * dx, + y: a1.y + overlap.from * dy, + } + const to: Point = { + x: a1.x + overlap.to * dx, + y: a1.y + overlap.to * dy, + } + + // Only add if the edge has non-trivial length + const edgeLen = Math.sqrt((to.x - from.x) ** 2 + (to.y - from.y) ** 2) + if (edgeLen > tolerance) { + sharedEdges.push({ from, to }) + } + } + } + + return sharedEdges +} + +/** + * Create evenly-spaced ports along a shared edge. + * + * @param edge - The shared edge segment + * @param portPitch - Distance between ports (default 0.4mm) + * @returns Array of port positions + */ +export function createPortsAlongEdge( + edge: SharedEdge, + portPitch = 0.4, +): Point[] { + const dx = edge.to.x - edge.from.x + const dy = edge.to.y - edge.from.y + const length = Math.sqrt(dx * dx + dy * dy) + + if (length < 0.001) return [] + + const count = Math.max(1, Math.floor(length / portPitch)) + const ports: Point[] = [] + + for (let i = 0; i < count; i++) { + const t = (i + 0.5) / count + ports.push({ + x: edge.from.x + t * dx, + y: edge.from.y + t * dy, + }) + } + + return ports +} diff --git a/lib/ViaGraphSolver/via-graph-generator/generateConvexViaTopologyRegions.ts b/lib/ViaGraphSolver/via-graph-generator/generateConvexViaTopologyRegions.ts new file mode 100644 index 0000000..d7b76da --- /dev/null +++ b/lib/ViaGraphSolver/via-graph-generator/generateConvexViaTopologyRegions.ts @@ -0,0 +1,374 @@ +import { ConvexRegionsSolver } from "@tscircuit/find-convex-regions" +import type { + JPort, + JRegion, + JumperGraph, +} from "../../JumperGraphSolver/jumper-types" +import type { ViasByNet } from "../ViaGraphSolver" +import { createPortsAlongEdge, findSharedEdges } from "./findSharedEdges" + +/** + * Default port pitch (mm) for distributing ports along shared boundaries. + */ +const DEFAULT_PORT_PITCH = 0.4 + +/** + * Default tile size (mm) for via placement. + */ +const DEFAULT_TILE_SIZE = 5 + +/** + * Default clearance (mm) around via regions for convex region computation. + */ +const DEFAULT_CLEARANCE = 0.1 + +type Point = { x: number; y: number } +type Bounds = { minX: number; maxX: number; minY: number; maxY: number } + +type HorizontalSegment = { xStart: number; xEnd: number; y: number } +type VerticalSegment = { x: number; yStart: number; yEnd: number } + +/** + * Remove consecutive duplicate points from a polygon. + * Points are considered duplicates if they are within tolerance distance. + */ +function deduplicateConsecutivePoints( + points: Point[], + tolerance = 0.001, +): Point[] { + if (points.length <= 1) return points + + const result: Point[] = [points[0]] + for (let i = 1; i < points.length; i++) { + const prev = result[result.length - 1] + const curr = points[i] + const dist = Math.sqrt((curr.x - prev.x) ** 2 + (curr.y - prev.y) ** 2) + if (dist > tolerance) { + result.push(curr) + } + } + + // Also check if last point equals first point (for closed polygons) + if (result.length > 1) { + const first = result[0] + const last = result[result.length - 1] + const dist = Math.sqrt((last.x - first.x) ** 2 + (last.y - first.y) ** 2) + if (dist < tolerance) { + result.pop() + } + } + + return result +} + +/** + * Compute bounding box from polygon points. + */ +function boundsFromPolygon(points: Point[]): Bounds { + let minX = Infinity + let maxX = -Infinity + let minY = Infinity + let maxY = -Infinity + for (const p of points) { + if (p.x < minX) minX = p.x + if (p.x > maxX) maxX = p.x + if (p.y < minY) minY = p.y + if (p.y > maxY) maxY = p.y + } + return { minX, maxX, minY, maxY } +} + +/** + * Compute centroid of polygon. + */ +function centroid(points: Point[]): Point { + let cx = 0 + let cy = 0 + for (const p of points) { + cx += p.x + cy += p.y + } + return { x: cx / points.length, y: cy / points.length } +} + +/** + * Create a JRegion from a polygon. + */ +function createRegionFromPolygon( + regionId: string, + polygon: Point[], + opts?: { isViaRegion?: boolean }, +): JRegion { + const bounds = boundsFromPolygon(polygon) + return { + regionId, + ports: [], + d: { + bounds, + center: centroid(polygon), + polygon, + isPad: false, + isViaRegion: opts?.isViaRegion, + }, + } +} + +/** + * Generate a via region polygon for a single net's vias. + * The polygon wraps around all vias for that net. + */ +function generateViaRegionPolygon( + vias: Array<{ viaId: string; diameter: number; position: Point }>, +): Point[] { + if (vias.length === 0) return [] + + // Find extreme vias + const topVia = vias.reduce((best, v) => + v.position.y > best.position.y ? v : best, + ) + const bottomVia = vias.reduce((best, v) => + v.position.y < best.position.y ? v : best, + ) + const leftVia = vias.reduce((best, v) => + v.position.x < best.position.x ? v : best, + ) + const rightVia = vias.reduce((best, v) => + v.position.x > best.position.x ? v : best, + ) + + // Compute edge segments + const topSeg: HorizontalSegment = { + xStart: topVia.position.x - topVia.diameter / 2, + xEnd: topVia.position.x + topVia.diameter / 2, + y: topVia.position.y + topVia.diameter / 2, + } + const botSeg: HorizontalSegment = { + xStart: bottomVia.position.x - bottomVia.diameter / 2, + xEnd: bottomVia.position.x + bottomVia.diameter / 2, + y: bottomVia.position.y - bottomVia.diameter / 2, + } + const leftSeg: VerticalSegment = { + x: leftVia.position.x - leftVia.diameter / 2, + yStart: leftVia.position.y - leftVia.diameter / 2, + yEnd: leftVia.position.y + leftVia.diameter / 2, + } + const rightSeg: VerticalSegment = { + x: rightVia.position.x + rightVia.diameter / 2, + yStart: rightVia.position.y - rightVia.diameter / 2, + yEnd: rightVia.position.y + rightVia.diameter / 2, + } + + // Build polygon (clockwise): + // top-left -> top-right -> right-top -> right-bottom -> + // bottom-right -> bottom-left -> left-bottom -> left-top -> close + const rawPolygon = [ + { x: topSeg.xStart, y: topSeg.y }, + { x: topSeg.xEnd, y: topSeg.y }, + { x: rightSeg.x, y: rightSeg.yEnd }, + { x: rightSeg.x, y: rightSeg.yStart }, + { x: botSeg.xEnd, y: botSeg.y }, + { x: botSeg.xStart, y: botSeg.y }, + { x: leftSeg.x, y: leftSeg.yStart }, + { x: leftSeg.x, y: leftSeg.yEnd }, + ] + + // Remove consecutive duplicate points (happens when same via is extreme in multiple directions) + return deduplicateConsecutivePoints(rawPolygon) +} + +/** + * Translate via positions by (dx, dy). + */ +function translateVias( + vias: Array<{ viaId: string; diameter: number; position: Point }>, + dx: number, + dy: number, + prefix: string, +): Array<{ viaId: string; diameter: number; position: Point }> { + return vias.map((v) => ({ + viaId: `${prefix}:${v.viaId}`, + diameter: v.diameter, + position: { + x: v.position.x + dx, + y: v.position.y + dy, + }, + })) +} + +/** + * Generates a via topology using convex regions computed by ConvexRegionsSolver. + * + * 1. Via tiles are placed on a grid (5mm tiles by default) + * 2. Per-net via region polygons are created within each tile + * 3. Convex regions are computed globally with via region polygons as obstacles + * 4. Ports are created between adjacent convex regions and between convex/via regions + */ +export function generateConvexViaTopologyRegions(opts: { + viasByNet: ViasByNet + bounds: Bounds + tileSize?: number + portPitch?: number + clearance?: number + concavityTolerance?: number +}): { + regions: JRegion[] + ports: JPort[] + tiledViasByNet: ViasByNet + tileCount: { rows: number; cols: number } +} { + const tileSize = opts.tileSize ?? DEFAULT_TILE_SIZE + const portPitch = opts.portPitch ?? DEFAULT_PORT_PITCH + const clearance = opts.clearance ?? DEFAULT_CLEARANCE + const concavityTolerance = opts.concavityTolerance ?? 0 + const { bounds, viasByNet } = opts + + const width = bounds.maxX - bounds.minX + const height = bounds.maxY - bounds.minY + + const cols = Math.floor(width / tileSize) + const rows = Math.floor(height / tileSize) + + const allRegions: JRegion[] = [] + const allPorts: JPort[] = [] + const tiledViasByNet: ViasByNet = {} + const viaRegions: JRegion[] = [] + + // Calculate tile grid position (centered within bounds) + const gridWidth = cols * tileSize + const gridHeight = rows * tileSize + const gridMinX = bounds.minX + (width - gridWidth) / 2 + const gridMinY = bounds.minY + (height - gridHeight) / 2 + const half = tileSize / 2 + + // Step 1: Generate tiled via regions + if (rows > 0 && cols > 0) { + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const tileCenterX = gridMinX + col * tileSize + half + const tileCenterY = gridMinY + row * tileSize + half + const prefix = `t${row}_${col}` + + // Create per-net via regions for this tile + for (const [netName, vias] of Object.entries(viasByNet)) { + if (vias.length === 0) continue + + // Translate vias to tile position + const translatedVias = translateVias( + vias, + tileCenterX, + tileCenterY, + prefix, + ) + + // Add to tiledViasByNet + if (!tiledViasByNet[netName]) tiledViasByNet[netName] = [] + tiledViasByNet[netName].push(...translatedVias) + + // Generate via region polygon + const polygon = generateViaRegionPolygon(translatedVias) + if (polygon.length === 0) continue + + const viaRegion = createRegionFromPolygon( + `${prefix}:v:${netName}`, + polygon, + { isViaRegion: true }, + ) + viaRegions.push(viaRegion) + allRegions.push(viaRegion) + } + } + } + } + + // Step 2: Compute convex regions using ConvexRegionsSolver + // Via region polygons are used as obstacles + const obstaclePolygons = viaRegions.map((r) => ({ + points: r.d.polygon!, + })) + + const solver = new ConvexRegionsSolver({ + bounds, + polygons: obstaclePolygons, + clearance, + concavityTolerance, + }) + + solver.solve() + const solverOutput = solver.getOutput() + + if (!solverOutput) { + throw new Error("ConvexRegionsSolver failed to compute regions") + } + + // Step 3: Convert solver output to JRegions + const convexRegions: JRegion[] = solverOutput.regions.map((polygon, i) => + createRegionFromPolygon(`convex:${i}`, polygon), + ) + allRegions.push(...convexRegions) + + // Step 4: Create ports between adjacent convex regions + let portIdCounter = 0 + + for (let i = 0; i < convexRegions.length; i++) { + for (let j = i + 1; j < convexRegions.length; j++) { + const region1 = convexRegions[i] + const region2 = convexRegions[j] + + const sharedEdges = findSharedEdges( + region1.d.polygon!, + region2.d.polygon!, + clearance * 2, // tolerance slightly larger than clearance + ) + + for (const edge of sharedEdges) { + const portPositions = createPortsAlongEdge(edge, portPitch) + + for (const pos of portPositions) { + const port: JPort = { + portId: `convex:${i}-${j}:${portIdCounter++}`, + region1, + region2, + d: { x: pos.x, y: pos.y }, + } + region1.ports.push(port) + region2.ports.push(port) + allPorts.push(port) + } + } + } + } + + // Step 5: Create ports between convex regions and via regions + for (const viaRegion of viaRegions) { + for (const convexRegion of convexRegions) { + const sharedEdges = findSharedEdges( + viaRegion.d.polygon!, + convexRegion.d.polygon!, + clearance * 2, + ) + + for (const edge of sharedEdges) { + const portPositions = createPortsAlongEdge(edge, portPitch) + + for (const pos of portPositions) { + const port: JPort = { + portId: `via-convex:${viaRegion.regionId}-${convexRegion.regionId}:${portIdCounter++}`, + region1: viaRegion, + region2: convexRegion, + d: { x: pos.x, y: pos.y }, + } + viaRegion.ports.push(port) + convexRegion.ports.push(port) + allPorts.push(port) + } + } + } + } + + return { + regions: allRegions, + ports: allPorts, + tiledViasByNet, + tileCount: { rows, cols }, + } +} diff --git a/lib/index.ts b/lib/index.ts index 3d260f7..ad823d2 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -12,7 +12,9 @@ export * from "./topology" export * from "./types" export * from "./ViaGraphSolver/defaultTopology" export * from "./ViaGraphSolver/ViaGraphSolver" +export * from "./ViaGraphSolver/via-graph-generator/createConvexViaGraphFromXYConnections" export * from "./ViaGraphSolver/via-graph-generator/createViaGraphFromXYConnections" export * from "./ViaGraphSolver/via-graph-generator/createViaGraphWithConnections" +export * from "./ViaGraphSolver/via-graph-generator/generateConvexViaTopologyRegions" export * from "./ViaGraphSolver/via-graph-generator/generateViaTopologyGrid" export * from "./ViaGraphSolver/via-graph-generator/generateViaTopologyRegions" diff --git a/package.json b/package.json index ffdd749..f140195 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "repository": "https://github.com/tscircuit/hypergraph", "devDependencies": { "@biomejs/biome": "^2.3.11", + "@tscircuit/find-convex-regions": "^0.0.7", "@tscircuit/math-utils": "^0.0.29", "@types/bun": "latest", "bun-match-svg": "^0.0.15", diff --git a/scripts/benchmark-via-graph-convex-dataset02-parallel.ts b/scripts/benchmark-via-graph-convex-dataset02-parallel.ts new file mode 100644 index 0000000..143b09d --- /dev/null +++ b/scripts/benchmark-via-graph-convex-dataset02-parallel.ts @@ -0,0 +1,598 @@ +/** + * Parallel benchmark for ViaGraphSolver using Dataset02 with convex regions. + * + * Usage: npx tsx scripts/benchmark-via-graph-convex-dataset02-parallel.ts [options] + * + * This script uses a single file approach where worker code is embedded as a string + * and executed via eval in the worker thread. + */ +import * as fs from "fs" +import { cpus } from "os" +import * as path from "path" +import { fileURLToPath } from "url" +import { Worker } from "worker_threads" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// Parse command line arguments +const args = process.argv.slice(2) +const limitArg = args.find((a) => a.startsWith("--limit=")) +const concurrencyArg = args.find((a) => a.startsWith("--concurrency=")) +const SAMPLE_LIMIT = limitArg ? parseInt(limitArg.split("=")[1], 10) : undefined +const CONCURRENCY = concurrencyArg + ? parseInt(concurrencyArg.split("=")[1], 10) + : cpus().length +const QUICK_MODE = args.includes("--quick") +const HELP = args.includes("--help") || args.includes("-h") + +if (HELP) { + console.log(` +Usage: npx tsx scripts/benchmark-via-graph-convex-dataset02-parallel.ts [options] + +Options: + --limit=N Only run first N samples (default: all 1000) + --concurrency=N Number of parallel workers (default: CPU count = ${cpus().length}) + --quick Use reduced MAX_ITERATIONS for faster but less accurate results + --help, -h Show this help message + +Examples: + npx tsx scripts/benchmark-via-graph-convex-dataset02-parallel.ts --limit=100 + npx tsx scripts/benchmark-via-graph-convex-dataset02-parallel.ts --concurrency=4 --quick + npx tsx scripts/benchmark-via-graph-convex-dataset02-parallel.ts --limit=200 --concurrency=8 +`) + process.exit(0) +} + +// Types for dataset03 structure +type DatasetSample = { + config: { + numCrossings: number + seed: number + rows: number + cols: number + orientation: "vertical" | "horizontal" + } + connections: { + connectionId: string + startRegionId: string + endRegionId: string + }[] + connectionRegions: { + regionId: string + pointIds: string[] + d: { + bounds: { minX: number; maxX: number; minY: number; maxY: number } + center: { x: number; y: number } + isPad: boolean + isConnectionRegion: boolean + } + }[] +} + +type ViasByNet = Record< + string, + { viaId: string; diameter: number; position: { x: number; y: number } }[] +> + +type BenchmarkResult = { + sampleIndex: number + numCrossings: number + seed: number + rows: number + cols: number + orientation: "vertical" | "horizontal" + solved: boolean + failed: boolean + iterations: number + duration: number + tileRows: number + tileCols: number + convexRegions: number + viaRegions: number + error?: string +} + +const median = (numbers: number[]): number | undefined => { + if (numbers.length === 0) return undefined + const sorted = numbers.slice().sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted[middle] +} + +const percentile = (numbers: number[], p: number): number | undefined => { + if (numbers.length === 0) return undefined + const sorted = numbers.slice().sort((a, b) => a - b) + const index = Math.floor((p / 100) * (sorted.length - 1)) + return sorted[index] +} + +const mean = (numbers: number[]): number | undefined => { + if (numbers.length === 0) return undefined + return numbers.reduce((sum, n) => sum + n, 0) / numbers.length +} + +// Worker code as a string that uses the built dist/index.js +const workerCode = ` +const { parentPort, workerData } = require('worker_threads'); +const { ViaGraphSolver, createConvexViaGraphFromXYConnections } = require(workerData.distPath); + +const { sample, sampleIndex, viasByNet, quickMode } = workerData; + +function extractXYConnections(sample) { + const regionMap = new Map( + sample.connectionRegions.map((r) => [r.regionId, r.d.center]) + ); + + return sample.connections.map((conn) => { + const start = regionMap.get(conn.startRegionId); + const end = regionMap.get(conn.endRegionId); + + if (!start || !end) { + throw new Error( + 'Missing region for connection ' + conn.connectionId + ); + } + + return { + connectionId: conn.connectionId, + start, + end, + }; + }); +} + +function solveSample() { + try { + const xyConnections = extractXYConnections(sample); + const result = createConvexViaGraphFromXYConnections(xyConnections, viasByNet); + + // Count region types + const convexRegions = result.regions.filter(r => r.regionId.startsWith('convex:')).length; + const viaRegions = result.regions.filter(r => r.d.isViaRegion).length; + + const solverOpts = { + inputGraph: { + regions: result.regions, + ports: result.ports, + }, + inputConnections: result.connections, + viasByNet: result.tiledViasByNet, + }; + + if (quickMode) { + solverOpts.baseMaxIterations = 50000; + } + + const solver = new ViaGraphSolver(solverOpts); + + const startTime = performance.now(); + solver.solve(); + const duration = performance.now() - startTime; + + return { + sampleIndex, + numCrossings: sample.config.numCrossings, + seed: sample.config.seed, + rows: sample.config.rows, + cols: sample.config.cols, + orientation: sample.config.orientation, + solved: solver.solved, + failed: solver.failed, + iterations: solver.iterations, + duration, + tileRows: result.tileCount.rows, + tileCols: result.tileCount.cols, + convexRegions, + viaRegions, + }; + } catch (e) { + return { + sampleIndex, + numCrossings: sample.config.numCrossings, + seed: sample.config.seed, + rows: sample.config.rows, + cols: sample.config.cols, + orientation: sample.config.orientation, + solved: false, + failed: true, + iterations: 0, + duration: 0, + tileRows: 0, + tileCols: 0, + convexRegions: 0, + viaRegions: 0, + error: e instanceof Error ? e.message : String(e), + }; + } +} + +const result = solveSample(); +parentPort.postMessage(result); +` + +/** + * Run a single sample in a worker thread + */ +function runSampleInWorker( + sampleIndex: number, + sample: DatasetSample, + viasByNet: ViasByNet, + quickMode: boolean, + distPath: string, +): Promise { + return new Promise((resolve) => { + const worker = new Worker(workerCode, { + eval: true, + workerData: { + sample, + sampleIndex, + viasByNet, + quickMode, + distPath, + }, + }) + + worker.on("message", (result: BenchmarkResult) => { + resolve(result) + }) + + worker.on("error", (err: Error) => { + resolve({ + sampleIndex, + numCrossings: sample.config.numCrossings, + seed: sample.config.seed, + rows: sample.config.rows, + cols: sample.config.cols, + orientation: sample.config.orientation, + solved: false, + failed: true, + iterations: 0, + duration: 0, + tileRows: 0, + tileCols: 0, + convexRegions: 0, + viaRegions: 0, + error: err.message, + }) + }) + + worker.on("exit", (code) => { + if (code !== 0) { + resolve({ + sampleIndex, + numCrossings: sample.config.numCrossings, + seed: sample.config.seed, + rows: sample.config.rows, + cols: sample.config.cols, + orientation: sample.config.orientation, + solved: false, + failed: true, + iterations: 0, + duration: 0, + tileRows: 0, + tileCols: 0, + convexRegions: 0, + viaRegions: 0, + error: `Worker exited with code ${code}`, + }) + } + }) + }) +} + +/** + * Process samples in parallel using worker threads with limited concurrency + */ +async function runParallelBenchmark( + samples: DatasetSample[], + viasByNet: ViasByNet, + concurrency: number, + quickMode: boolean, + distPath: string, + onProgress: (completed: number, results: BenchmarkResult[]) => void, +): Promise { + const results: BenchmarkResult[] = [] + let nextIndex = 0 + let completed = 0 + + const processNext = async (): Promise => { + while (nextIndex < samples.length) { + const currentIndex = nextIndex++ + const sample = samples[currentIndex] + + const result = await runSampleInWorker( + currentIndex, + sample, + viasByNet, + quickMode, + distPath, + ) + + results.push(result) + completed++ + onProgress(completed, results) + } + } + + // Start workers up to concurrency limit + const workers = Array(Math.min(concurrency, samples.length)) + .fill(null) + .map(() => processNext()) + + await Promise.all(workers) + + // Sort results by sample index + return results.sort((a, b) => a.sampleIndex - b.sampleIndex) +} + +// Load dataset02 +const datasetPath = path.join( + __dirname, + "../datasets/jumper-graph-solver/dataset02.json", +) +const dataset: DatasetSample[] = JSON.parse( + fs.readFileSync(datasetPath, "utf8"), +) + +// Load vias-by-net +const viasByNetPath = path.join( + __dirname, + "../assets/ViaGraphSolver/vias-by-net.json", +) +const viasByNet: ViasByNet = JSON.parse(fs.readFileSync(viasByNetPath, "utf8")) + +// Path to built dist +const distPath = path.join(__dirname, "../dist/index.js") + +// Check if dist exists +if (!fs.existsSync(distPath)) { + console.error( + "Error: dist/index.js not found. Please run 'npm run build' first.", + ) + process.exit(1) +} + +// Apply sample limit +const samplesToRun = SAMPLE_LIMIT ? dataset.slice(0, SAMPLE_LIMIT) : dataset +const totalSamples = samplesToRun.length + +console.log( + "Benchmark: ViaGraphSolver with Dataset02 (Convex Regions, Parallel)", +) +console.log("=".repeat(70)) +console.log(`Loaded ${dataset.length} samples from dataset02`) +console.log(`Via topology loaded from vias-by-net.json`) +console.log(`Concurrency: ${CONCURRENCY} workers`) +if (SAMPLE_LIMIT) { + console.log(`Sample limit: ${SAMPLE_LIMIT}`) +} +if (QUICK_MODE) { + console.log(`Quick mode: enabled (reduced MAX_ITERATIONS)`) +} +console.log() + +const startTime = Date.now() +let lastProgressTime = Date.now() + +const printProgress = (completed: number, results: BenchmarkResult[]) => { + const now = Date.now() + if (now - lastProgressTime >= 1000 || completed === totalSamples) { + const solvedCount = results.filter((r) => r.solved).length + const failedCount = results.filter((r) => r.failed && !r.solved).length + const elapsed = ((now - startTime) / 1000).toFixed(1) + const rate = + completed > 0 ? ((solvedCount / completed) * 100).toFixed(1) : "0.0" + const samplesPerSec = (completed / ((now - startTime) / 1000)).toFixed(1) + console.log( + `[${elapsed}s] ${completed}/${totalSamples} (${samplesPerSec}/s) | ` + + `Solved: ${solvedCount} | Failed: ${failedCount} | Rate: ${rate}%`, + ) + lastProgressTime = now + } +} + +// Run the benchmark +runParallelBenchmark( + samplesToRun, + viasByNet, + CONCURRENCY, + QUICK_MODE, + distPath, + printProgress, +).then((results) => { + const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(1) + console.log(`\nCompleted in ${totalElapsed}s\n`) + + // Calculate statistics + const solvedResults = results.filter((r) => r.solved) + const failedResults = results.filter((r) => r.failed && !r.solved) + const unsolved = results.filter((r) => !r.solved) + + const successRate = (solvedResults.length / results.length) * 100 + + console.log("=".repeat(70)) + console.log("Overall Results") + console.log("=".repeat(70)) + console.log(`Total samples: ${results.length}`) + console.log( + `Solved: ${solvedResults.length} (${successRate.toFixed(1)}%)`, + ) + console.log( + `Failed: ${failedResults.length} (${((failedResults.length / results.length) * 100).toFixed(1)}%)`, + ) + console.log( + `Unsolved: ${unsolved.length} (${((unsolved.length / results.length) * 100).toFixed(1)}%)`, + ) + + // Region statistics + const avgConvexRegions = mean(solvedResults.map((r) => r.convexRegions)) + const avgViaRegions = mean(solvedResults.map((r) => r.viaRegions)) + console.log(`\nAvg convex regions: ${avgConvexRegions?.toFixed(1) ?? "N/A"}`) + console.log(`Avg via regions: ${avgViaRegions?.toFixed(1) ?? "N/A"}`) + + // Iteration statistics for solved samples + const solvedIterations = solvedResults.map((r) => r.iterations) + const solvedDurations = solvedResults.map((r) => r.duration) + + console.log("\n" + "=".repeat(70)) + console.log("Performance Statistics (Solved Samples)") + console.log("=".repeat(70)) + console.log( + `Iterations - Mean: ${mean(solvedIterations)?.toFixed(0) ?? "N/A"}, ` + + `Median: ${median(solvedIterations)?.toFixed(0) ?? "N/A"}, ` + + `P90: ${percentile(solvedIterations, 90)?.toFixed(0) ?? "N/A"}, ` + + `P99: ${percentile(solvedIterations, 99)?.toFixed(0) ?? "N/A"}`, + ) + console.log( + `Duration (ms) - Mean: ${mean(solvedDurations)?.toFixed(1) ?? "N/A"}, ` + + `Median: ${median(solvedDurations)?.toFixed(1) ?? "N/A"}, ` + + `P90: ${percentile(solvedDurations, 90)?.toFixed(1) ?? "N/A"}, ` + + `P99: ${percentile(solvedDurations, 99)?.toFixed(1) ?? "N/A"}`, + ) + + // Tile distribution + const tileCounts = solvedResults.map((r) => `${r.tileCols}x${r.tileRows}`) + const tileCountMap = new Map() + for (const t of tileCounts) { + tileCountMap.set(t, (tileCountMap.get(t) || 0) + 1) + } + console.log("\nTile grid distribution (solved):") + const sortedTiles = Array.from(tileCountMap.entries()).sort((a, b) => { + const [aCols, aRows] = a[0].split("x").map(Number) + const [bCols, bRows] = b[0].split("x").map(Number) + return aCols * aRows - bCols * bRows + }) + for (const [tile, count] of sortedTiles) { + const pct = ((count / solvedResults.length) * 100).toFixed(1) + console.log(` ${tile}: ${count} (${pct}%)`) + } + + // Success rate by crossing count + console.log("\n" + "=".repeat(70)) + console.log("Success Rate by Crossing Count") + console.log("=".repeat(70)) + const crossingGroups = new Map< + number, + { + solved: number + total: number + iterations: number[] + durations: number[] + } + >() + for (const r of results) { + const crossings = r.numCrossings + if (!crossingGroups.has(crossings)) { + crossingGroups.set(crossings, { + solved: 0, + total: 0, + iterations: [], + durations: [], + }) + } + const group = crossingGroups.get(crossings)! + group.total++ + if (r.solved) { + group.solved++ + group.iterations.push(r.iterations) + group.durations.push(r.duration) + } + } + const sortedCrossings = Array.from(crossingGroups.entries()).sort( + (a, b) => a[0] - b[0], + ) + for (const [crossings, { solved, total, iterations }] of sortedCrossings) { + const pct = ((solved / total) * 100).toFixed(0) + const medIters = median(iterations)?.toFixed(0) ?? "N/A" + console.log( + ` ${crossings.toString().padStart(2)} crossings: ${solved.toString().padStart(3)}/${total.toString().padStart(3)} (${pct.padStart(3)}%) | Median iters: ${medIters}`, + ) + } + + // Success rate by grid size (rows x cols from config) + console.log("\n" + "=".repeat(70)) + console.log("Success Rate by Problem Grid Size (rows x cols)") + console.log("=".repeat(70)) + const gridGroups = new Map< + string, + { solved: number; total: number; iterations: number[] } + >() + for (const r of results) { + const grid = `${r.rows}x${r.cols}` + if (!gridGroups.has(grid)) { + gridGroups.set(grid, { solved: 0, total: 0, iterations: [] }) + } + const group = gridGroups.get(grid)! + group.total++ + if (r.solved) { + group.solved++ + group.iterations.push(r.iterations) + } + } + const sortedGrids = Array.from(gridGroups.entries()).sort((a, b) => { + const [aRows, aCols] = a[0].split("x").map(Number) + const [bRows, bCols] = b[0].split("x").map(Number) + return aRows * aCols - bRows * bCols + }) + for (const [grid, { solved, total, iterations }] of sortedGrids) { + const pct = ((solved / total) * 100).toFixed(0) + const medIters = median(iterations)?.toFixed(0) ?? "N/A" + console.log( + ` ${grid.padStart(4)}: ${solved.toString().padStart(3)}/${total.toString().padStart(3)} (${pct.padStart(3)}%) | Median iters: ${medIters}`, + ) + } + + // Success rate by orientation + console.log("\n" + "=".repeat(70)) + console.log("Success Rate by Orientation") + console.log("=".repeat(70)) + const orientationGroups = new Map< + string, + { solved: number; total: number; iterations: number[] } + >() + for (const r of results) { + const orient = r.orientation + if (!orientationGroups.has(orient)) { + orientationGroups.set(orient, { solved: 0, total: 0, iterations: [] }) + } + const group = orientationGroups.get(orient)! + group.total++ + if (r.solved) { + group.solved++ + group.iterations.push(r.iterations) + } + } + for (const [orient, { solved, total, iterations }] of orientationGroups) { + const pct = ((solved / total) * 100).toFixed(0) + const medIters = median(iterations)?.toFixed(0) ?? "N/A" + console.log( + ` ${orient.padEnd(10)}: ${solved.toString().padStart(3)}/${total.toString().padStart(3)} (${pct.padStart(3)}%) | Median iters: ${medIters}`, + ) + } + + // Show some unsolved samples + if (unsolved.length > 0 && unsolved.length <= 30) { + console.log("\n" + "=".repeat(70)) + console.log("Unsolved Samples") + console.log("=".repeat(70)) + for (const r of unsolved) { + console.log( + ` Sample ${r.sampleIndex}: ${r.numCrossings} crossings, ${r.rows}x${r.cols} ${r.orientation}, seed=${r.seed}${r.error ? ` (error: ${r.error})` : ""}`, + ) + } + } else if (unsolved.length > 30) { + console.log( + `\n${unsolved.length} samples could not be solved (showing first 30):`, + ) + for (const r of unsolved.slice(0, 30)) { + console.log( + ` Sample ${r.sampleIndex}: ${r.numCrossings} crossings, ${r.rows}x${r.cols} ${r.orientation}, seed=${r.seed}`, + ) + } + } + + console.log("\n" + "=".repeat(70)) + console.log("Benchmark Complete") + console.log("=".repeat(70)) +}) diff --git a/scripts/benchmark-via-graph-convex-dataset02.ts b/scripts/benchmark-via-graph-convex-dataset02.ts new file mode 100644 index 0000000..a132860 --- /dev/null +++ b/scripts/benchmark-via-graph-convex-dataset02.ts @@ -0,0 +1,434 @@ +import * as fs from "fs" +import * as path from "path" +import type { XYConnection } from "../lib/JumperGraphSolver/jumper-graph-generator/createGraphWithConnectionsFromBaseGraph" +import { ViaGraphSolver } from "../lib/ViaGraphSolver/ViaGraphSolver" +import { createConvexViaGraphFromXYConnections } from "../lib/ViaGraphSolver/via-graph-generator/createConvexViaGraphFromXYConnections" + +const args = process.argv.slice(2) +const limitArg = args.find((a) => a.startsWith("--limit=")) +const SAMPLE_LIMIT = limitArg ? parseInt(limitArg.split("=")[1], 10) : undefined +const QUICK_MODE = args.includes("--quick") +const HELP = args.includes("--help") || args.includes("-h") + +if (HELP) { + console.log(` +Usage: bun run scripts/benchmark-via-graph-convex-dataset02.ts [options] + +Options: + --limit=N Only run first N samples (default: all 1000) + --quick Use reduced MAX_ITERATIONS for faster but less accurate results + --help, -h Show this help message + +Examples: + bun run scripts/benchmark-via-graph-convex-dataset02.ts --limit=50 + bun run scripts/benchmark-via-graph-convex-dataset02.ts --quick --limit=100 +`) + process.exit(0) +} + +type DatasetSample = { + config: { + numCrossings: number + seed: number + rows: number + cols: number + orientation: "vertical" | "horizontal" + } + connections: { + connectionId: string + startRegionId: string + endRegionId: string + }[] + connectionRegions: { + regionId: string + pointIds: string[] + d: { + bounds: { minX: number; maxX: number; minY: number; maxY: number } + center: { x: number; y: number } + isPad: boolean + isConnectionRegion: boolean + } + }[] +} + +type ViasByNet = Record< + string, + { viaId: string; diameter: number; position: { x: number; y: number } }[] +> + +const median = (numbers: number[]): number | undefined => { + if (numbers.length === 0) return undefined + const sorted = numbers.slice().sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted[middle] +} + +const percentile = (numbers: number[], p: number): number | undefined => { + if (numbers.length === 0) return undefined + const sorted = numbers.slice().sort((a, b) => a - b) + const index = Math.floor((p / 100) * (sorted.length - 1)) + return sorted[index] +} + +const mean = (numbers: number[]): number | undefined => { + if (numbers.length === 0) return undefined + return numbers.reduce((sum, n) => sum + n, 0) / numbers.length +} + +const extractXYConnections = (sample: DatasetSample): XYConnection[] => { + const regionMap = new Map( + sample.connectionRegions.map((r) => [r.regionId, r.d.center]), + ) + + return sample.connections.map((conn) => { + const start = regionMap.get(conn.startRegionId) + const end = regionMap.get(conn.endRegionId) + + if (!start || !end) { + throw new Error( + `Missing region for connection ${conn.connectionId}: start=${conn.startRegionId}, end=${conn.endRegionId}`, + ) + } + + return { + connectionId: conn.connectionId, + start, + end, + } + }) +} + +const tryToSolve = ( + xyConnections: XYConnection[], + quickMode: boolean, +): { + solved: boolean + failed: boolean + iterations: number + duration: number + tileRows: number + tileCols: number + convexRegions: number + viaRegions: number + error?: string +} => { + try { + const result = createConvexViaGraphFromXYConnections(xyConnections) + + const convexRegions = result.regions.filter((r) => + r.regionId.startsWith("convex:"), + ).length + const viaRegions = result.regions.filter((r) => r.d.isViaRegion).length + + const solverOpts: ConstructorParameters[0] = { + inputGraph: { + regions: result.regions, + ports: result.ports, + }, + inputConnections: result.connections, + viasByNet: result.tiledViasByNet, + } + + if (quickMode) { + solverOpts.baseMaxIterations = 50000 + } + + const solver = new ViaGraphSolver(solverOpts) + + const startTime = performance.now() + solver.solve() + const duration = performance.now() - startTime + + return { + solved: solver.solved, + failed: solver.failed, + iterations: solver.iterations, + duration, + tileRows: result.tileCount.rows, + tileCols: result.tileCount.cols, + convexRegions, + viaRegions, + } + } catch (e) { + return { + solved: false, + failed: true, + iterations: 0, + duration: 0, + tileRows: 0, + tileCols: 0, + convexRegions: 0, + viaRegions: 0, + error: e instanceof Error ? e.message : String(e), + } + } +} + +const datasetPath = path.join( + __dirname, + "../datasets/jumper-graph-solver/dataset02.json", +) +const dataset: DatasetSample[] = JSON.parse( + fs.readFileSync(datasetPath, "utf8"), +) + +console.log("Benchmark: ViaGraphSolver with Dataset02 (Convex Regions)") +console.log("=".repeat(70)) +console.log(`Loaded ${dataset.length} samples from dataset02`) +if (SAMPLE_LIMIT) { + console.log(`Sample limit: ${SAMPLE_LIMIT}`) +} +if (QUICK_MODE) { + console.log(`Quick mode: enabled (reduced MAX_ITERATIONS)`) +} +console.log() + +type BenchmarkResult = { + sampleIndex: number + numCrossings: number + seed: number + rows: number + cols: number + orientation: "vertical" | "horizontal" + solved: boolean + failed: boolean + iterations: number + duration: number + tileRows: number + tileCols: number + convexRegions: number + viaRegions: number + error?: string +} + +const results: BenchmarkResult[] = [] + +let lastProgressTime = Date.now() +const startTime = Date.now() + +const printProgress = (sampleIndex: number, total: number) => { + const solvedSoFar = results.filter((r) => r.solved).length + const failedSoFar = results.filter((r) => r.failed && !r.solved).length + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1) + console.log( + `[${elapsed}s] Sample ${sampleIndex + 1}/${total} | ` + + `Solved: ${solvedSoFar} | Failed: ${failedSoFar} | ` + + `Rate: ${((solvedSoFar / results.length) * 100).toFixed(1)}%`, + ) +} + +const samplesToRun = SAMPLE_LIMIT ? dataset.slice(0, SAMPLE_LIMIT) : dataset +const totalSamples = samplesToRun.length + +for (let i = 0; i < totalSamples; i++) { + const sample = samplesToRun[i] + const xyConnections = extractXYConnections(sample) + + const now = Date.now() + if (now - lastProgressTime >= 1000) { + printProgress(i, totalSamples) + lastProgressTime = now + } + + const result = tryToSolve(xyConnections, QUICK_MODE) + + results.push({ + sampleIndex: i, + numCrossings: sample.config.numCrossings, + seed: sample.config.seed, + rows: sample.config.rows, + cols: sample.config.cols, + orientation: sample.config.orientation, + solved: result.solved, + failed: result.failed, + iterations: result.iterations, + duration: result.duration, + tileRows: result.tileRows, + tileCols: result.tileCols, + convexRegions: result.convexRegions, + viaRegions: result.viaRegions, + error: result.error, + }) +} + +const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(1) +console.log(`\nCompleted in ${totalElapsed}s\n`) + +const solvedResults = results.filter((r) => r.solved) +const failedResults = results.filter((r) => r.failed && !r.solved) +const unsolved = results.filter((r) => !r.solved) + +const successRate = (solvedResults.length / results.length) * 100 + +console.log("=".repeat(70)) +console.log("Overall Results") +console.log("=".repeat(70)) +console.log(`Total samples: ${results.length}`) +console.log( + `Solved: ${solvedResults.length} (${successRate.toFixed(1)}%)`, +) +console.log( + `Failed: ${failedResults.length} (${((failedResults.length / results.length) * 100).toFixed(1)}%)`, +) +console.log( + `Unsolved: ${unsolved.length} (${((unsolved.length / results.length) * 100).toFixed(1)}%)`, +) + +const avgConvexRegions = mean(solvedResults.map((r) => r.convexRegions)) +const avgViaRegions = mean(solvedResults.map((r) => r.viaRegions)) +console.log(`\nAvg convex regions: ${avgConvexRegions?.toFixed(1) ?? "N/A"}`) +console.log(`Avg via regions: ${avgViaRegions?.toFixed(1) ?? "N/A"}`) + +const solvedIterations = solvedResults.map((r) => r.iterations) +const solvedDurations = solvedResults.map((r) => r.duration) + +console.log("\n" + "=".repeat(70)) +console.log("Performance Statistics (Solved Samples)") +console.log("=".repeat(70)) +console.log( + `Iterations - Mean: ${mean(solvedIterations)?.toFixed(0) ?? "N/A"}, ` + + `Median: ${median(solvedIterations)?.toFixed(0) ?? "N/A"}, ` + + `P90: ${percentile(solvedIterations, 90)?.toFixed(0) ?? "N/A"}, ` + + `P99: ${percentile(solvedIterations, 99)?.toFixed(0) ?? "N/A"}`, +) +console.log( + `Duration (ms) - Mean: ${mean(solvedDurations)?.toFixed(1) ?? "N/A"}, ` + + `Median: ${median(solvedDurations)?.toFixed(1) ?? "N/A"}, ` + + `P90: ${percentile(solvedDurations, 90)?.toFixed(1) ?? "N/A"}, ` + + `P99: ${percentile(solvedDurations, 99)?.toFixed(1) ?? "N/A"}`, +) + +const tileCounts = solvedResults.map((r) => `${r.tileCols}x${r.tileRows}`) +const tileCountMap = new Map() +for (const t of tileCounts) { + tileCountMap.set(t, (tileCountMap.get(t) || 0) + 1) +} +console.log("\nTile grid distribution (solved):") +const sortedTiles = Array.from(tileCountMap.entries()).sort((a, b) => { + const [aCols, aRows] = a[0].split("x").map(Number) + const [bCols, bRows] = b[0].split("x").map(Number) + return aCols * aRows - bCols * bRows +}) +for (const [tile, count] of sortedTiles) { + const pct = ((count / solvedResults.length) * 100).toFixed(1) + console.log(` ${tile}: ${count} (${pct}%)`) +} + +console.log("\n" + "=".repeat(70)) +console.log("Success Rate by Crossing Count") +console.log("=".repeat(70)) +const crossingGroups = new Map< + number, + { solved: number; total: number; iterations: number[]; durations: number[] } +>() +for (const r of results) { + const crossings = r.numCrossings + if (!crossingGroups.has(crossings)) { + crossingGroups.set(crossings, { + solved: 0, + total: 0, + iterations: [], + durations: [], + }) + } + const group = crossingGroups.get(crossings)! + group.total++ + if (r.solved) { + group.solved++ + group.iterations.push(r.iterations) + group.durations.push(r.duration) + } +} +const sortedCrossings = Array.from(crossingGroups.entries()).sort( + (a, b) => a[0] - b[0], +) +for (const [crossings, { solved, total, iterations }] of sortedCrossings) { + const pct = ((solved / total) * 100).toFixed(0) + const medIters = median(iterations)?.toFixed(0) ?? "N/A" + console.log( + ` ${crossings.toString().padStart(2)} crossings: ${solved.toString().padStart(3)}/${total.toString().padStart(3)} (${pct.padStart(3)}%) | Median iters: ${medIters}`, + ) +} + +console.log("\n" + "=".repeat(70)) +console.log("Success Rate by Problem Grid Size (rows x cols)") +console.log("=".repeat(70)) +const gridGroups = new Map< + string, + { solved: number; total: number; iterations: number[] } +>() +for (const r of results) { + const grid = `${r.rows}x${r.cols}` + if (!gridGroups.has(grid)) { + gridGroups.set(grid, { solved: 0, total: 0, iterations: [] }) + } + const group = gridGroups.get(grid)! + group.total++ + if (r.solved) { + group.solved++ + group.iterations.push(r.iterations) + } +} +const sortedGrids = Array.from(gridGroups.entries()).sort((a, b) => { + const [aRows, aCols] = a[0].split("x").map(Number) + const [bRows, bCols] = b[0].split("x").map(Number) + return aRows * aCols - bRows * bCols +}) +for (const [grid, { solved, total, iterations }] of sortedGrids) { + const pct = ((solved / total) * 100).toFixed(0) + const medIters = median(iterations)?.toFixed(0) ?? "N/A" + console.log( + ` ${grid.padStart(4)}: ${solved.toString().padStart(3)}/${total.toString().padStart(3)} (${pct.padStart(3)}%) | Median iters: ${medIters}`, + ) +} + +console.log("\n" + "=".repeat(70)) +console.log("Success Rate by Orientation") +console.log("=".repeat(70)) +const orientationGroups = new Map< + string, + { solved: number; total: number; iterations: number[] } +>() +for (const r of results) { + const orient = r.orientation + if (!orientationGroups.has(orient)) { + orientationGroups.set(orient, { solved: 0, total: 0, iterations: [] }) + } + const group = orientationGroups.get(orient)! + group.total++ + if (r.solved) { + group.solved++ + group.iterations.push(r.iterations) + } +} +for (const [orient, { solved, total, iterations }] of orientationGroups) { + const pct = ((solved / total) * 100).toFixed(0) + const medIters = median(iterations)?.toFixed(0) ?? "N/A" + console.log( + ` ${orient.padEnd(10)}: ${solved.toString().padStart(3)}/${total.toString().padStart(3)} (${pct.padStart(3)}%) | Median iters: ${medIters}`, + ) +} + +if (unsolved.length > 0 && unsolved.length <= 30) { + console.log("\n" + "=".repeat(70)) + console.log("Unsolved Samples") + console.log("=".repeat(70)) + for (const r of unsolved) { + console.log( + ` Sample ${r.sampleIndex}: ${r.numCrossings} crossings, ${r.rows}x${r.cols} ${r.orientation}, seed=${r.seed}${r.error ? ` (error: ${r.error})` : ""}`, + ) + } +} else if (unsolved.length > 30) { + console.log( + `\n${unsolved.length} samples could not be solved (showing first 30):`, + ) + for (const r of unsolved.slice(0, 30)) { + console.log( + ` Sample ${r.sampleIndex}: ${r.numCrossings} crossings, ${r.rows}x${r.cols} ${r.orientation}, seed=${r.seed}`, + ) + } +} + +console.log("\n" + "=".repeat(70)) +console.log("Benchmark Complete") +console.log("=".repeat(70)) diff --git a/tests/via-graph-solver/__snapshots__/via-graph-convex-dataset02.snap.svg b/tests/via-graph-solver/__snapshots__/via-graph-convex-dataset02.snap.svg new file mode 100644 index 0000000..7a42fd3 --- /dev/null +++ b/tests/via-graph-solver/__snapshots__/via-graph-convex-dataset02.snap.svg @@ -0,0 +1,74 @@ + \ No newline at end of file diff --git a/tests/via-graph-solver/via-graph-convex-dataset02.test.ts b/tests/via-graph-solver/via-graph-convex-dataset02.test.ts new file mode 100644 index 0000000..403bf73 --- /dev/null +++ b/tests/via-graph-solver/via-graph-convex-dataset02.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from "bun:test" +import viasByNet from "assets/ViaGraphSolver/vias-by-net.json" +import { getSvgFromGraphicsObject } from "graphics-debug" +import type { XYConnection } from "lib/JumperGraphSolver/jumper-graph-generator/createGraphWithConnectionsFromBaseGraph" +import { ViaGraphSolver } from "lib/ViaGraphSolver/ViaGraphSolver" +import { createConvexViaGraphFromXYConnections } from "lib/ViaGraphSolver/via-graph-generator/createConvexViaGraphFromXYConnections" +import dataset02 from "../../datasets/jumper-graph-solver/dataset02.json" + +interface DatasetSample { + config: { + numCrossings: number + seed: number + rows: number + cols: number + orientation: "vertical" | "horizontal" + } + connections: { + connectionId: string + startRegionId: string + endRegionId: string + }[] + connectionRegions: { + regionId: string + pointIds: string[] + d: { + bounds: { minX: number; maxX: number; minY: number; maxY: number } + center: { x: number; y: number } + isPad: boolean + isConnectionRegion: boolean + } + }[] +} + +const typedDataset = dataset02 as DatasetSample[] + +const extractXYConnections = (sample: DatasetSample): XYConnection[] => { + const regionMap = new Map( + sample.connectionRegions.map((r) => [r.regionId, r.d.center]), + ) + + return sample.connections.map((conn) => { + const start = regionMap.get(conn.startRegionId) + const end = regionMap.get(conn.endRegionId) + + if (!start || !end) { + throw new Error( + `Missing region for connection ${conn.connectionId}: start=${conn.startRegionId}, end=${conn.endRegionId}`, + ) + } + + return { + connectionId: conn.connectionId, + start, + end, + } + }) +} + +test("via-graph-convex-dataset02: solve sample 0 with convex regions", () => { + const sample = typedDataset[0] + const xyConnections = extractXYConnections(sample) + + const result = createConvexViaGraphFromXYConnections(xyConnections, viasByNet) + + // Verify tiling occurred + expect(result.tileCount.rows).toBeGreaterThanOrEqual(0) + expect(result.tileCount.cols).toBeGreaterThanOrEqual(0) + + // Verify we have convex regions (regionId starts with "convex:") + const convexRegions = result.regions.filter((r) => + r.regionId.startsWith("convex:"), + ) + expect(convexRegions.length).toBeGreaterThan(0) + + // Verify we have via regions + const viaRegions = result.regions.filter((r) => r.d.isViaRegion) + expect(viaRegions.length).toBeGreaterThan(0) + + const solver = new ViaGraphSolver({ + inputGraph: { + regions: result.regions, + ports: result.ports, + }, + inputConnections: result.connections, + viasByNet: result.tiledViasByNet, + }) + + solver.solve() + + expect(solver.solved).toBe(true) + + expect(getSvgFromGraphicsObject(solver.visualize())).toMatchSvgSnapshot( + import.meta.path, + ) +}, 30000)