diff --git a/lib/HyperGraphPartialRipping.ts b/lib/HyperGraphPartialRipping.ts new file mode 100644 index 0000000..af9fbcf --- /dev/null +++ b/lib/HyperGraphPartialRipping.ts @@ -0,0 +1,304 @@ +// NOTE: add this file to the repo (lib/) and export it from your package entry if you want it available externally. + +import { HyperGraphSolver } from "./HyperGraphSolver" +import type { + Candidate, + Connection, + HyperGraph, + Region, + RegionPort, + SerializedConnection, + SerializedHyperGraph, + SolvedRoute, +} from "./types" + +export interface PartialRippingOptions { + ripCostThreshold?: number + maxRoutesToRip?: number + ripStrategy?: "none" | "cheapest" | "all" +} + +export interface HyperGraphPartialRippingInput< + RegionType extends Region = Region, + RegionPortType extends RegionPort = RegionPort, +> { + inputGraph: HyperGraph | SerializedHyperGraph + inputConnections: (Connection | SerializedConnection)[] + greedyMultiplier?: number + rippingEnabled?: boolean + ripCost?: number + ripCostThreshold?: number + maxRoutesToRip?: number + ripStrategy?: "none" | "cheapest" | "all" +} + +export class HyperGraphPartialRipping< + RegionType extends Region = Region, + RegionPortType extends RegionPort = RegionPort, + CandidateType extends Candidate = Candidate< + RegionType, + RegionPortType + >, +> extends HyperGraphSolver { + override getSolverName(): string { + return "HyperGraphPartialRipping" + } + + ripCostThreshold: number = Infinity + maxRoutesToRip: number = Infinity + ripStrategy: "none" | "cheapest" | "all" = "all" + + totalRipsPerformed: number = 0 + ripsSkippedDueToThreshold: number = 0 + + constructor( + input: HyperGraphPartialRippingInput, + ) { + super({ + inputGraph: input.inputGraph, + inputConnections: input.inputConnections, + greedyMultiplier: input.greedyMultiplier, + rippingEnabled: input.rippingEnabled ?? true, + ripCost: input.ripCost, + }) + + if (input.ripCostThreshold !== undefined) { + this.ripCostThreshold = input.ripCostThreshold + } + if (input.maxRoutesToRip !== undefined) { + this.maxRoutesToRip = input.maxRoutesToRip + } + if (input.ripStrategy !== undefined) { + this.ripStrategy = input.ripStrategy + } + } + + override getConstructorParams() { + return { + ...super.getConstructorParams(), + ripCostThreshold: this.ripCostThreshold, + maxRoutesToRip: this.maxRoutesToRip, + ripStrategy: this.ripStrategy, + } + } + + calculateRipCost(route: SolvedRoute): number { + // Default: ripCost * number of ports in the route + return this.ripCost * route.path.length + } + + calculateTotalRipCost(routes: Set): number { + let totalCost = 0 + for (const route of routes) { + totalCost += this.calculateRipCost(route) + } + return totalCost + } + + shouldRipRoutes( + candidateRoutes: Set, + _currentSolvedRoute?: SolvedRoute, + ): Set { + if (candidateRoutes.size === 0) { + return candidateRoutes + } + + if (this.ripStrategy === "all") { + return this.applyMaxRoutesLimit(candidateRoutes) + } + + const totalCost = this.calculateTotalRipCost(candidateRoutes) + + if (totalCost <= this.ripCostThreshold) { + return this.applyMaxRoutesLimit(candidateRoutes) + } + + if (this.ripStrategy === "none") { + this.ripsSkippedDueToThreshold += candidateRoutes.size + return new Set() + } + + if (this.ripStrategy === "cheapest") { + return this.selectCheapestRoutes(candidateRoutes) + } + + return this.applyMaxRoutesLimit(candidateRoutes) + } + + private selectCheapestRoutes(routes: Set): Set { + const routesWithCost = Array.from(routes).map((route) => ({ + route, + cost: this.calculateRipCost(route), + })) + + routesWithCost.sort((a, b) => a.cost - b.cost) + + const selectedRoutes = new Set() + let accumulatedCost = 0 + + for (const { route, cost } of routesWithCost) { + if ( + accumulatedCost + cost <= this.ripCostThreshold && + selectedRoutes.size < this.maxRoutesToRip + ) { + selectedRoutes.add(route) + accumulatedCost += cost + } else { + this.ripsSkippedDueToThreshold++ + } + } + + return selectedRoutes + } + + private applyMaxRoutesLimit(routes: Set): Set { + if (routes.size <= this.maxRoutesToRip) return routes + + const routesWithCost = Array.from(routes).map((route) => ({ + route, + cost: this.calculateRipCost(route), + })) + routesWithCost.sort((a, b) => a.cost - b.cost) + + const selectedRoutes = new Set() + for (let i = 0; i < this.maxRoutesToRip && i < routesWithCost.length; i++) { + selectedRoutes.add(routesWithCost[i].route) + } + + this.ripsSkippedDueToThreshold += routes.size - selectedRoutes.size + return selectedRoutes + } + + override ripSolvedRoute(solvedRoute: SolvedRoute) { + this.totalRipsPerformed++ + super.ripSolvedRoute(solvedRoute) + } + + getRipDiagnostics() { + return { + totalRipsPerformed: this.totalRipsPerformed, + ripsSkippedDueToThreshold: this.ripsSkippedDueToThreshold, + ripCostThreshold: this.ripCostThreshold, + maxRoutesToRip: this.maxRoutesToRip, + ripStrategy: this.ripStrategy, + } + } + + /** + * IMPORTANT: override processSolvedRoute so we can use shouldRipRoutes() + * This is mostly a copy of HyperGraphSolver.processSolvedRoute with the + * ripping loop replaced by a call to this.shouldRipRoutes. + */ + override processSolvedRoute(finalCandidate: CandidateType) { + // Build the solvedRoute as in the base impl + const solvedRoute: SolvedRoute = { + path: [], + connection: this.currentConnection!, + requiredRip: false, + } + + let cursorCandidate: CandidateType | undefined = finalCandidate + let anyRipsRequired = false + while (cursorCandidate) { + anyRipsRequired ||= !!cursorCandidate.ripRequired + solvedRoute.path.unshift(cursorCandidate) + cursorCandidate = cursorCandidate.parent as CandidateType | undefined + } + + // Determine routes that would be ripped by the normal algorithm + const routesToRip: Set = new Set() + if (anyRipsRequired) { + solvedRoute.requiredRip = true + for (const candidate of solvedRoute.path) { + if ( + candidate.port.assignment && + candidate.port.assignment.connection.mutuallyConnectedNetworkId !== + this.currentConnection!.mutuallyConnectedNetworkId + ) { + routesToRip.add(candidate.port.assignment.solvedRoute) + } + } + } + + for (const candidate of solvedRoute.path) { + if (!candidate.lastPort || !candidate.lastRegion) continue + const ripsRequired = this.getRipsRequiredForPortUsage( + candidate.lastRegion as RegionType, + candidate.lastPort as RegionPortType, + candidate.port as RegionPortType, + ) + for (const assignment of ripsRequired) { + routesToRip.add(assignment.solvedRoute) + } + } + + // NEW: pick routes to actually rip based on policy + const chosenRoutesToRip = this.shouldRipRoutes(routesToRip, solvedRoute) + + if (chosenRoutesToRip.size > 0) { + solvedRoute.requiredRip = true + for (const route of chosenRoutesToRip) { + this.ripSolvedRoute(route) + } + } else { + // If we skipped ripping entirely, keep requiredRip=false (or leave as set above) + // No further action needed here + } + + // Finally assign ports / region assignments for the solvedRoute + for (const candidate of solvedRoute.path) { + candidate.port.assignment = { + solvedRoute, + connection: this.currentConnection!, + } + if (!candidate.lastPort) continue + const regionPortAssignment = { + regionPort1: candidate.lastPort, + regionPort2: candidate.port, + region: candidate.lastRegion!, + connection: this.currentConnection!, + solvedRoute, + } as any + candidate.lastRegion!.assignments?.push(regionPortAssignment) + } + + this.solvedRoutes.push(solvedRoute) + this.routeSolvedHook(solvedRoute) + } +} + +export function createPartialRippingSolver( + input: HyperGraphPartialRippingInput & { + preset?: "conservative" | "moderate" | "aggressive" + }, +) { + const presets: Record> = { + conservative: { + ripCostThreshold: 50, + maxRoutesToRip: 2, + ripStrategy: "cheapest", + }, + moderate: { + ripCostThreshold: 150, + maxRoutesToRip: 5, + ripStrategy: "cheapest", + }, + aggressive: { + ripCostThreshold: Infinity, + maxRoutesToRip: Infinity, + ripStrategy: "all", + }, + } + + const preset: Partial = input.preset + ? presets[input.preset] + : {} + + return new HyperGraphPartialRipping({ + ...input, + ...preset, + ripCostThreshold: input.ripCostThreshold ?? preset.ripCostThreshold, + maxRoutesToRip: input.maxRoutesToRip ?? preset.maxRoutesToRip, + ripStrategy: input.ripStrategy ?? preset.ripStrategy, + }) +} diff --git a/lib/index.ts b/lib/index.ts index ad823d2..e76b5cc 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,3 +1,10 @@ +export * from "./JumperGraphSolver/jumper-graph-generator/generateJumperX4Grid" +export * from "./JumperGraphSolver/jumper-graph-generator/generateJumperGrid" +export * from "./JumperGraphSolver/jumper-graph-generator/createGraphWithConnectionsFromBaseGraph" +export * from "./JumperGraphSolver/JumperGraphSolver" +export * from "./HyperGraphSolver" +export * from "./HyperGraphPartialRipping" +export * from "./convertHyperGraphToSerializedHyperGraph" export * from "./convertConnectionsToSerializedConnections" export * from "./convertHyperGraphToSerializedHyperGraph" export * from "./HyperGraphSolver" diff --git a/package.json b/package.json index 93ecf5a..f8462b7 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@tscircuit/find-convex-regions": "^0.0.7", "@tscircuit/jumper-topology-generator": "^0.0.4", "@tscircuit/math-utils": "^0.0.29", - "@types/bun": "latest", + "@types/bun": "^1.3.9", "bun-match-svg": "^0.0.15", "graphics-debug": "^0.0.83", "react-cosmos": "^7.1.0", diff --git a/tests/jumper-graph-solver/HyperGraphPartialRipping.test.ts b/tests/jumper-graph-solver/HyperGraphPartialRipping.test.ts new file mode 100644 index 0000000..77ec34a --- /dev/null +++ b/tests/jumper-graph-solver/HyperGraphPartialRipping.test.ts @@ -0,0 +1,680 @@ +/// +import { describe, it, expect } from "bun:test" +import "graphics-debug/matcher" +import type { GraphicsObject } from "graphics-debug" +import { + HyperGraphPartialRipping, + createPartialRippingSolver, +} from "../../lib/HyperGraphPartialRipping" +import type { + HyperGraph, + Connection, + SolvedRoute, + Region, + RegionPort, +} from "../../lib/types" + +function visualizeSolver(solver: HyperGraphPartialRipping): GraphicsObject { + const regionPositions = new Map() + const portPositions = new Map() + + const cols = 2 + const spacing = 200 + solver.graph.regions.forEach((region, i) => { + const col = i % cols + const row = Math.floor(i / cols) + regionPositions.set(region.regionId, { + x: col * spacing, + y: row * spacing, + }) + }) + + for (const port of solver.graph.ports) { + const r1Pos = regionPositions.get(port.region1.regionId)! + const r2Pos = regionPositions.get(port.region2.regionId)! + portPositions.set(port.portId, { + x: (r1Pos.x + r2Pos.x) / 2, + y: (r1Pos.y + r2Pos.y) / 2, + }) + } + + const points: NonNullable = [] + const lines: NonNullable = [] + + // Region labels (blue dots) + for (const region of solver.graph.regions) { + const pos = regionPositions.get(region.regionId)! + const assignmentCount = region.assignments?.length ?? 0 + points.push({ + x: pos.x, + y: pos.y, + label: `${region.regionId} (${assignmentCount} asgn)`, + color: "blue", + }) + } + + // Port labels (green=assigned, gray=free) + for (const port of solver.graph.ports) { + const pos = portPositions.get(port.portId)! + points.push({ + x: pos.x, + y: pos.y, + label: `${port.portId}${port.assignment ? " ✓" : ""}`, + color: port.assignment ? "green" : "gray", + }) + } + + // Graph topology edges (light gray) + for (const port of solver.graph.ports) { + const r1Pos = regionPositions.get(port.region1.regionId)! + const r2Pos = regionPositions.get(port.region2.regionId)! + lines.push({ + points: [ + { x: r1Pos.x, y: r1Pos.y }, + { x: r2Pos.x, y: r2Pos.y }, + ], + strokeColor: "lightgray", + }) + } + + // Solved routes as colored polylines + const routeColors = ["red", "blue", "green", "orange", "purple", "cyan"] + solver.solvedRoutes.forEach((route, routeIdx) => { + const color = routeColors[routeIdx % routeColors.length] + const routePoints: { x: number; y: number }[] = [] + + for (const candidate of route.path) { + const pos = portPositions.get(candidate.port.portId) + if (pos) routePoints.push({ x: pos.x, y: pos.y }) + } + + if (routePoints.length >= 2) { + const offset = (routeIdx - solver.solvedRoutes.length / 2) * 8 + lines.push({ + points: routePoints.map((p) => ({ + x: p.x + offset, + y: p.y + offset, + })), + strokeColor: color, + }) + } + + if (routePoints.length > 0) { + const mid = routePoints[Math.floor(routePoints.length / 2)] + points.push({ + x: mid.x + 15, + y: mid.y + 15, + label: `${route.connection.connectionId}${route.requiredRip ? " (ripped)" : ""}`, + color, + }) + } + }) + + const diag = solver.getRipDiagnostics() + const title = [ + solver.solved ? "SOLVED" : solver.failed ? "FAILED" : "IN PROGRESS", + `Routes: ${solver.solvedRoutes.length}/${solver.connections.length}`, + `Rips: ${diag.totalRipsPerformed}`, + `Skipped: ${diag.ripsSkippedDueToThreshold}`, + `Strategy: ${diag.ripStrategy}`, + ].join(" | ") + + return { points, lines, title } +} + +function createTestGraph(): { graph: HyperGraph; connections: Connection[] } { + const r1: Region = { regionId: "r1", ports: [], d: {} } + const r2: Region = { regionId: "r2", ports: [], d: {} } + const r3: Region = { regionId: "r3", ports: [], d: {} } + const r4: Region = { regionId: "r4", ports: [], d: {} } + + const p1: RegionPort = { portId: "p1", region1: r1, region2: r2, d: {} } + const p2: RegionPort = { portId: "p2", region1: r2, region2: r3, d: {} } + const p3: RegionPort = { portId: "p3", region1: r1, region2: r4, d: {} } + const p4: RegionPort = { portId: "p4", region1: r4, region2: r3, d: {} } + const p5: RegionPort = { portId: "p5", region1: r2, region2: r4, d: {} } + + r1.ports = [p1, p3] + r2.ports = [p1, p2, p5] + r3.ports = [p2, p4] + r4.ports = [p3, p4, p5] + + const graph: HyperGraph = { + ports: [p1, p2, p3, p4, p5], + regions: [r1, r2, r3, r4], + } + + const conn1: Connection = { + connectionId: "conn1", + mutuallyConnectedNetworkId: "net1", + startRegion: r1, + endRegion: r3, + } + const conn2: Connection = { + connectionId: "conn2", + mutuallyConnectedNetworkId: "net2", + startRegion: r2, + endRegion: r4, + } + + return { graph, connections: [conn1, conn2] } +} + +function createRipCandidateGraph(): { + graph: HyperGraph + connections: Connection[] +} { + const r1: Region = { regionId: "r1", ports: [], d: {} } + const r2: Region = { regionId: "r2", ports: [], d: {} } + const r3: Region = { regionId: "r3", ports: [], d: {} } + const r4: Region = { regionId: "r4", ports: [], d: {} } + const r5: Region = { regionId: "r5", ports: [], d: {} } + + const p1: RegionPort = { portId: "p1", region1: r1, region2: r2, d: {} } + const p2: RegionPort = { portId: "p2", region1: r2, region2: r3, d: {} } + const p3: RegionPort = { portId: "p3", region1: r1, region2: r4, d: {} } + const p4: RegionPort = { portId: "p4", region1: r4, region2: r3, d: {} } + const p5: RegionPort = { portId: "p5", region1: r5, region2: r2, d: {} } + + r1.ports = [p1, p3] + r2.ports = [p1, p2, p5] + r3.ports = [p2, p4] + r4.ports = [p3, p4] + r5.ports = [p5] + + const graph: HyperGraph = { + ports: [p1, p2, p3, p4, p5], + regions: [r1, r2, r3, r4, r5], + } + + const conn1: Connection = { + connectionId: "conn1", + mutuallyConnectedNetworkId: "net1", + startRegion: r1, + endRegion: r3, + } + const conn2: Connection = { + connectionId: "conn2", + mutuallyConnectedNetworkId: "net2", + startRegion: r5, + endRegion: r3, + } + + return { graph, connections: [conn1, conn2] } +} + +/** Run solver to completion with a safety cap. */ +function runSolverToCompletion( + solver: HyperGraphPartialRipping, + maxSteps = 1000, +): HyperGraphPartialRipping { + let steps = 0 + while (!solver.solved && !solver.failed && steps < maxSteps) { + solver.step() + steps++ + } + return solver +} + +describe("HyperGraphPartialRipping", () => { + describe("shouldRipRoutes", () => { + it("should return all routes when strategy is 'all'", () => { + const { graph, connections } = createTestGraph() + const solver = new HyperGraphPartialRipping({ + inputGraph: graph, + inputConnections: connections, + ripStrategy: "all", + ripCost: 10, + }) + + const mockRoute1: SolvedRoute = { + path: [{ port: graph.ports[0] }] as any, + connection: connections[0], + requiredRip: false, + } + const mockRoute2: SolvedRoute = { + path: [{ port: graph.ports[1] }] as any, + connection: connections[1], + requiredRip: false, + } + + const candidateRoutes = new Set([mockRoute1, mockRoute2]) + const result = solver.shouldRipRoutes(candidateRoutes) + + expect(result.size).toBe(2) + expect(result.has(mockRoute1)).toBe(true) + expect(result.has(mockRoute2)).toBe(true) + }) + + it("should return empty set when strategy is 'none' and threshold exceeded", () => { + const { graph, connections } = createTestGraph() + const solver = new HyperGraphPartialRipping({ + inputGraph: graph, + inputConnections: connections, + ripStrategy: "none", + ripCost: 100, + ripCostThreshold: 50, + }) + + const mockRoute1: SolvedRoute = { + path: [{ port: graph.ports[0] }] as any, + connection: connections[0], + requiredRip: false, + } + + const candidateRoutes = new Set([mockRoute1]) + const result = solver.shouldRipRoutes(candidateRoutes) + + expect(result.size).toBe(0) + expect(solver.ripsSkippedDueToThreshold).toBe(1) + }) + + it("should return all routes when under threshold with 'none' strategy", () => { + const { graph, connections } = createTestGraph() + const solver = new HyperGraphPartialRipping({ + inputGraph: graph, + inputConnections: connections, + ripStrategy: "none", + ripCost: 10, + ripCostThreshold: 100, + }) + + const mockRoute1: SolvedRoute = { + path: [{ port: graph.ports[0] }] as any, + connection: connections[0], + requiredRip: false, + } + + const candidateRoutes = new Set([mockRoute1]) + const result = solver.shouldRipRoutes(candidateRoutes) + + expect(result.size).toBe(1) + }) + + it("should select cheapest routes when strategy is 'cheapest'", () => { + const { graph, connections } = createTestGraph() + const solver = new HyperGraphPartialRipping({ + inputGraph: graph, + inputConnections: connections, + ripStrategy: "cheapest", + ripCost: 10, + ripCostThreshold: 25, + }) + + const cheapRoute: SolvedRoute = { + path: [{ port: graph.ports[0] }] as any, + connection: connections[0], + requiredRip: false, + } + const expensiveRoute: SolvedRoute = { + path: [ + { port: graph.ports[1] }, + { port: graph.ports[2] }, + { port: graph.ports[3] }, + ] as any, + connection: connections[1], + requiredRip: false, + } + + const candidateRoutes = new Set([cheapRoute, expensiveRoute]) + const result = solver.shouldRipRoutes(candidateRoutes) + + expect(result.size).toBe(1) + expect(result.has(cheapRoute)).toBe(true) + expect(result.has(expensiveRoute)).toBe(false) + }) + + it("should respect maxRoutesToRip limit", () => { + const { graph, connections } = createTestGraph() + const solver = new HyperGraphPartialRipping({ + inputGraph: graph, + inputConnections: connections, + ripStrategy: "all", + ripCost: 10, + maxRoutesToRip: 1, + }) + + const mockRoute1: SolvedRoute = { + path: [{ port: graph.ports[0] }] as any, + connection: connections[0], + requiredRip: false, + } + const mockRoute2: SolvedRoute = { + path: [{ port: graph.ports[1] }] as any, + connection: connections[1], + requiredRip: false, + } + + const candidateRoutes = new Set([mockRoute1, mockRoute2]) + const result = solver.shouldRipRoutes(candidateRoutes) + + expect(result.size).toBe(1) + }) + + it("should return empty set for empty input", () => { + const { graph, connections } = createTestGraph() + const solver = new HyperGraphPartialRipping({ + inputGraph: graph, + inputConnections: connections, + }) + + const result = solver.shouldRipRoutes(new Set()) + expect(result.size).toBe(0) + }) + }) + + describe("calculateRipCost", () => { + it("should calculate cost based on path length and ripCost", () => { + const { graph, connections } = createTestGraph() + const solver = new HyperGraphPartialRipping({ + inputGraph: graph, + inputConnections: connections, + ripCost: 15, + }) + + const route: SolvedRoute = { + path: [ + { port: graph.ports[0] }, + { port: graph.ports[1] }, + { port: graph.ports[2] }, + ] as any, + connection: connections[0], + requiredRip: false, + } + + expect(solver.calculateRipCost(route)).toBe(45) + }) + }) + + describe("ripSolvedRoute", () => { + it("should increment totalRipsPerformed when a route is ripped", () => { + const { graph, connections } = createTestGraph() + const solver = new HyperGraphPartialRipping({ + inputGraph: graph, + inputConnections: connections, + rippingEnabled: true, + ripCost: 10, + }) + + // Run solver to get at least one solved route + runSolverToCompletion(solver) + expect(solver.solvedRoutes.length).toBeGreaterThanOrEqual(1) + + const routeToRip = solver.solvedRoutes[0] + const routeCountBefore = solver.solvedRoutes.length + const ripCountBefore = solver.totalRipsPerformed + + // Manually rip the route + solver.ripSolvedRoute(routeToRip) + + expect(solver.totalRipsPerformed).toBe(ripCountBefore + 1) + expect(solver.solvedRoutes.length).toBe(routeCountBefore - 1) + }) + }) + + describe("getRipDiagnostics", () => { + it("should return correct diagnostics", () => { + const { graph, connections } = createTestGraph() + const solver = new HyperGraphPartialRipping({ + inputGraph: graph, + inputConnections: connections, + ripCostThreshold: 100, + maxRoutesToRip: 5, + ripStrategy: "cheapest", + }) + + expect(solver.getRipDiagnostics()).toEqual({ + totalRipsPerformed: 0, + ripsSkippedDueToThreshold: 0, + ripCostThreshold: 100, + maxRoutesToRip: 5, + ripStrategy: "cheapest", + }) + }) + }) + + describe("createPartialRippingSolver factory", () => { + it("should create solver with conservative preset", () => { + const { graph, connections } = createTestGraph() + const solver = createPartialRippingSolver({ + inputGraph: graph, + inputConnections: connections, + preset: "conservative", + }) + + expect(solver.ripCostThreshold).toBe(50) + expect(solver.maxRoutesToRip).toBe(2) + expect(solver.ripStrategy).toBe("cheapest") + }) + + it("should create solver with moderate preset", () => { + const { graph, connections } = createTestGraph() + const solver = createPartialRippingSolver({ + inputGraph: graph, + inputConnections: connections, + preset: "moderate", + }) + + expect(solver.ripCostThreshold).toBe(150) + expect(solver.maxRoutesToRip).toBe(5) + expect(solver.ripStrategy).toBe("cheapest") + }) + + it("should create solver with aggressive preset", () => { + const { graph, connections } = createTestGraph() + const solver = createPartialRippingSolver({ + inputGraph: graph, + inputConnections: connections, + preset: "aggressive", + }) + + expect(solver.ripCostThreshold).toBe(Infinity) + expect(solver.maxRoutesToRip).toBe(Infinity) + expect(solver.ripStrategy).toBe("all") + }) + + it("should allow explicit overrides of preset values", () => { + const { graph, connections } = createTestGraph() + const solver = createPartialRippingSolver({ + inputGraph: graph, + inputConnections: connections, + preset: "conservative", + ripCostThreshold: 200, + }) + + expect(solver.ripCostThreshold).toBe(200) + expect(solver.maxRoutesToRip).toBe(2) + }) + }) +}) + +describe("Integration: HyperGraphPartialRipping solving", () => { + it("should solve simple graph without ripping", async () => { + const r1: Region = { regionId: "r1", ports: [], d: {} } + const r2: Region = { regionId: "r2", ports: [], d: {} } + + const p1: RegionPort = { portId: "p1", region1: r1, region2: r2, d: {} } + + r1.ports = [p1] + r2.ports = [p1] + + const graph: HyperGraph = { + ports: [p1], + regions: [r1, r2], + } + + const conn: Connection = { + connectionId: "conn1", + mutuallyConnectedNetworkId: "net1", + startRegion: r1, + endRegion: r2, + } + + const solver = new HyperGraphPartialRipping({ + inputGraph: graph, + inputConnections: [conn], + }) + + runSolverToCompletion(solver) + + expect(solver.solved).toBe(true) + expect(solver.solvedRoutes.length).toBe(1) + expect(solver.totalRipsPerformed).toBe(0) + + // Visual snapshot + await expect(visualizeSolver(solver)).toMatchGraphicsSvg(import.meta.path, { + svgName: "simple-graph-no-ripping", + }) + + // Data snapshots + expect( + solver.solvedRoutes.map((r) => ({ + connectionId: r.connection.connectionId, + pathLength: r.path.length, + requiredRip: r.requiredRip, + })), + ).toMatchSnapshot() + + expect(solver.getRipDiagnostics()).toMatchSnapshot() + }) + + it("should solve multi-connection graph without conflicts", async () => { + const { graph, connections } = createTestGraph() + + const solver = new HyperGraphPartialRipping({ + inputGraph: graph, + inputConnections: connections, + ripStrategy: "all", + ripCost: 10, + }) + + runSolverToCompletion(solver) + + expect(solver.solved).toBe(true) + expect(solver.solvedRoutes.length).toBe(2) + + // Visual snapshot + await expect(visualizeSolver(solver)).toMatchGraphicsSvg(import.meta.path, { + svgName: "multi-connection-no-conflict", + }) + + // Data snapshots + expect( + solver.solvedRoutes.map((r) => ({ + connectionId: r.connection.connectionId, + networkId: r.connection.mutuallyConnectedNetworkId, + pathPortIds: r.path.map((c) => c.port.portId), + requiredRip: r.requiredRip, + })), + ).toMatchSnapshot() + + expect(solver.getRipDiagnostics()).toMatchSnapshot() + }) + + it("should solve rip-candidate graph with aggressive strategy", async () => { + const { graph, connections } = createRipCandidateGraph() + + const solver = new HyperGraphPartialRipping({ + inputGraph: graph, + inputConnections: connections, + rippingEnabled: true, + ripStrategy: "all", + ripCost: 10, + }) + + runSolverToCompletion(solver) + + expect(solver.solved).toBe(true) + expect(solver.solvedRoutes.length).toBe(2) + + // Visual snapshot — shows whether ripping occurred via route labels + await expect(visualizeSolver(solver)).toMatchGraphicsSvg(import.meta.path, { + svgName: "rip-candidate-aggressive", + }) + + // Data snapshot captures exact paths, rip flags, and diagnostics + expect({ + solvedRoutes: solver.solvedRoutes.map((r) => ({ + connectionId: r.connection.connectionId, + pathPortIds: r.path.map((c) => c.port.portId), + requiredRip: r.requiredRip, + })), + diagnostics: solver.getRipDiagnostics(), + }).toMatchSnapshot() + }) + + it("should solve rip-candidate graph with conservative preset", async () => { + const { graph, connections } = createRipCandidateGraph() + + const solver = createPartialRippingSolver({ + inputGraph: graph, + inputConnections: connections, + preset: "conservative", + }) + + runSolverToCompletion(solver) + + const finalState = solver.solved ? "solved" : "failed" + + // Visual snapshot + await expect(visualizeSolver(solver)).toMatchGraphicsSvg(import.meta.path, { + svgName: "rip-candidate-conservative", + }) + + // Outcome + diagnostics snapshot + expect({ + finalState, + routeCount: solver.solvedRoutes.length, + diagnostics: solver.getRipDiagnostics(), + }).toMatchSnapshot() + }) + + it("should compare aggressive vs conservative on same graph", async () => { + const { graph: g1, connections: c1 } = createRipCandidateGraph() + const { graph: g2, connections: c2 } = createRipCandidateGraph() + + const aggressive = runSolverToCompletion( + new HyperGraphPartialRipping({ + inputGraph: g1, + inputConnections: c1, + rippingEnabled: true, + ripStrategy: "all", + ripCost: 10, + }), + ) + + const conservative = runSolverToCompletion( + createPartialRippingSolver({ + inputGraph: g2, + inputConnections: c2, + preset: "conservative", + }), + ) + + // Side-by-side visual snapshots for PR review + await expect(visualizeSolver(aggressive)).toMatchGraphicsSvg( + import.meta.path, + { svgName: "comparison-aggressive" }, + ) + await expect(visualizeSolver(conservative)).toMatchGraphicsSvg( + import.meta.path, + { svgName: "comparison-conservative" }, + ) + + // Summary comparison snapshot + expect({ + aggressive: { + solved: aggressive.solved, + routeCount: aggressive.solvedRoutes.length, + totalRips: aggressive.totalRipsPerformed, + diagnostics: aggressive.getRipDiagnostics(), + }, + conservative: { + solved: conservative.solved, + routeCount: conservative.solvedRoutes.length, + totalRips: conservative.totalRipsPerformed, + diagnostics: conservative.getRipDiagnostics(), + }, + }).toMatchSnapshot() + }) +}) diff --git a/tests/jumper-graph-solver/__snapshots__/HyperGraphPartialRipping.test.ts.snap b/tests/jumper-graph-solver/__snapshots__/HyperGraphPartialRipping.test.ts.snap new file mode 100644 index 0000000..91f0b54 --- /dev/null +++ b/tests/jumper-graph-solver/__snapshots__/HyperGraphPartialRipping.test.ts.snap @@ -0,0 +1,127 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`Integration: HyperGraphPartialRipping solving should solve simple graph without ripping 1`] = ` +[ + { + "connectionId": "conn1", + "pathLength": 1, + "requiredRip": false, + }, +] +`; + +exports[`Integration: HyperGraphPartialRipping solving should solve simple graph without ripping 2`] = ` +{ + "maxRoutesToRip": Infinity, + "ripCostThreshold": Infinity, + "ripStrategy": "all", + "ripsSkippedDueToThreshold": 0, + "totalRipsPerformed": 0, +} +`; + +exports[`Integration: HyperGraphPartialRipping solving should solve multi-connection graph without conflicts 1`] = ` +[ + { + "connectionId": "conn1", + "networkId": "net1", + "pathPortIds": [ + "p1", + "p5", + "p4", + ], + "requiredRip": false, + }, + { + "connectionId": "conn2", + "networkId": "net2", + "pathPortIds": [ + "p5", + ], + "requiredRip": false, + }, +] +`; + +exports[`Integration: HyperGraphPartialRipping solving should solve multi-connection graph without conflicts 2`] = ` +{ + "maxRoutesToRip": Infinity, + "ripCostThreshold": Infinity, + "ripStrategy": "all", + "ripsSkippedDueToThreshold": 0, + "totalRipsPerformed": 0, +} +`; + +exports[`Integration: HyperGraphPartialRipping solving should solve rip-candidate graph with aggressive strategy 1`] = ` +{ + "diagnostics": { + "maxRoutesToRip": Infinity, + "ripCostThreshold": Infinity, + "ripStrategy": "all", + "ripsSkippedDueToThreshold": 0, + "totalRipsPerformed": 0, + }, + "solvedRoutes": [ + { + "connectionId": "conn1", + "pathPortIds": [ + "p3", + "p4", + ], + "requiredRip": false, + }, + { + "connectionId": "conn2", + "pathPortIds": [ + "p5", + "p2", + ], + "requiredRip": false, + }, + ], +} +`; + +exports[`Integration: HyperGraphPartialRipping solving should solve rip-candidate graph with conservative preset 1`] = ` +{ + "diagnostics": { + "maxRoutesToRip": 2, + "ripCostThreshold": 50, + "ripStrategy": "cheapest", + "ripsSkippedDueToThreshold": 0, + "totalRipsPerformed": 0, + }, + "finalState": "solved", + "routeCount": 2, +} +`; + +exports[`Integration: HyperGraphPartialRipping solving should compare aggressive vs conservative on same graph 1`] = ` +{ + "aggressive": { + "diagnostics": { + "maxRoutesToRip": Infinity, + "ripCostThreshold": Infinity, + "ripStrategy": "all", + "ripsSkippedDueToThreshold": 0, + "totalRipsPerformed": 0, + }, + "routeCount": 2, + "solved": true, + "totalRips": 0, + }, + "conservative": { + "diagnostics": { + "maxRoutesToRip": 2, + "ripCostThreshold": 50, + "ripStrategy": "cheapest", + "ripsSkippedDueToThreshold": 0, + "totalRipsPerformed": 0, + }, + "routeCount": 2, + "solved": true, + "totalRips": 0, + }, +} +`; diff --git a/tests/jumper-graph-solver/__snapshots__/comparison-aggressive.snap.svg b/tests/jumper-graph-solver/__snapshots__/comparison-aggressive.snap.svg new file mode 100644 index 0000000..e7032de --- /dev/null +++ b/tests/jumper-graph-solver/__snapshots__/comparison-aggressive.snap.svg @@ -0,0 +1,44 @@ + \ No newline at end of file diff --git a/tests/jumper-graph-solver/__snapshots__/comparison-conservative.snap.svg b/tests/jumper-graph-solver/__snapshots__/comparison-conservative.snap.svg new file mode 100644 index 0000000..e7032de --- /dev/null +++ b/tests/jumper-graph-solver/__snapshots__/comparison-conservative.snap.svg @@ -0,0 +1,44 @@ + \ No newline at end of file diff --git a/tests/jumper-graph-solver/__snapshots__/jumper-graph-solver05.snap.svg b/tests/jumper-graph-solver/__snapshots__/jumper-graph-solver05.snap.svg deleted file mode 100644 index 1e9099b..0000000 --- a/tests/jumper-graph-solver/__snapshots__/jumper-graph-solver05.snap.svg +++ /dev/null @@ -1,76 +0,0 @@ - \ No newline at end of file diff --git a/tests/jumper-graph-solver/__snapshots__/multi-connection-no-conflict.snap.svg b/tests/jumper-graph-solver/__snapshots__/multi-connection-no-conflict.snap.svg new file mode 100644 index 0000000..4845e50 --- /dev/null +++ b/tests/jumper-graph-solver/__snapshots__/multi-connection-no-conflict.snap.svg @@ -0,0 +1,44 @@ + \ No newline at end of file diff --git a/tests/jumper-graph-solver/__snapshots__/rip-candidate-aggressive.snap.svg b/tests/jumper-graph-solver/__snapshots__/rip-candidate-aggressive.snap.svg new file mode 100644 index 0000000..e7032de --- /dev/null +++ b/tests/jumper-graph-solver/__snapshots__/rip-candidate-aggressive.snap.svg @@ -0,0 +1,44 @@ + \ No newline at end of file diff --git a/tests/jumper-graph-solver/__snapshots__/rip-candidate-conservative.snap.svg b/tests/jumper-graph-solver/__snapshots__/rip-candidate-conservative.snap.svg new file mode 100644 index 0000000..e7032de --- /dev/null +++ b/tests/jumper-graph-solver/__snapshots__/rip-candidate-conservative.snap.svg @@ -0,0 +1,44 @@ + \ No newline at end of file diff --git a/tests/jumper-graph-solver/__snapshots__/simple-graph-no-ripping.snap.svg b/tests/jumper-graph-solver/__snapshots__/simple-graph-no-ripping.snap.svg new file mode 100644 index 0000000..f7a593a --- /dev/null +++ b/tests/jumper-graph-solver/__snapshots__/simple-graph-no-ripping.snap.svg @@ -0,0 +1,44 @@ + \ No newline at end of file diff --git a/tests/jumper-graph-solver/jumper-graph-solver01.test.ts b/tests/jumper-graph-solver/jumper-graph-solver01.test.ts index 457f17d..695aa06 100644 --- a/tests/jumper-graph-solver/jumper-graph-solver01.test.ts +++ b/tests/jumper-graph-solver/jumper-graph-solver01.test.ts @@ -6,8 +6,7 @@ import { generateJumperX4Grid } from "lib/JumperGraphSolver/jumper-graph-generat test( "jumper-graph-solver01: solve 1x1 X4 grid with external connections", - // @ts-expect-error bun:test types don't include timeout option - { timeout: 30000 }, + () => { const baseGraph = generateJumperX4Grid({ cols: 1, @@ -64,4 +63,5 @@ test( import.meta.path, ) }, + { timeout: 30000 }, ) diff --git a/tests/jumper-graph-solver/jumper-graph-solver02.test.ts b/tests/jumper-graph-solver/jumper-graph-solver02.test.ts index a6e436e..5b6a6a5 100644 --- a/tests/jumper-graph-solver/jumper-graph-solver02.test.ts +++ b/tests/jumper-graph-solver/jumper-graph-solver02.test.ts @@ -6,8 +6,7 @@ import { generateJumperX4Grid } from "lib/JumperGraphSolver/jumper-graph-generat test( "jumper-graph-solver02: solve 1x1 X4 grid with 5 external connections", - // @ts-expect-error bun:test types don't include timeout option - { timeout: 30000 }, + () => { const baseGraph = generateJumperX4Grid({ cols: 1, @@ -74,4 +73,5 @@ test( import.meta.path, ) }, + { timeout: 30000 }, ) diff --git a/tsconfig.json b/tsconfig.json index 7289cf8..49b0ba3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,9 +12,9 @@ "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "noEmit": true, - "baseUrl": ".", + // "baseUrl": ".", "paths": { - "lib/*": ["lib/*"] + "lib/*": ["./lib/*"] }, // Best practices "strict": true,