From 85618de66c4a8e9f7228100e79505e9d36f4c4d3 Mon Sep 17 00:00:00 2001 From: matthewhardern Date: Sat, 30 May 2026 15:25:05 +0100 Subject: [PATCH] Add force_directed packing strategy to the layout pipeline Thread an optional packPlacementStrategy:"force_directed" through InputProblem into PartitionPackingSolver + SingleInnerPartitionPackingSolver. Default (undefined) keeps the greedy outline packer unchanged; when set, packing routes through calculate-packing's gated pack() (force-directed + validate + greedy fallback). Fixes the O(n^3) blowup behind tscircuit#3208: a 100-resistor LayoutPipelineSolver run drops from ~5.1s to ~3ms, 0 overlaps, all placed. spike-matchpack-fd.ts: head-to-head verification via the real field. --- .../SingleInnerPartitionPackingSolver.ts | 18 ++- .../PartitionPackingSolver.ts | 18 ++- lib/types/InputProblem.ts | 9 ++ spike-matchpack-fd.ts | 151 ++++++++++++++++++ 4 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 spike-matchpack-fd.ts diff --git a/lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver.ts b/lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver.ts index 88db103..b880298 100644 --- a/lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver.ts +++ b/lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver.ts @@ -4,7 +4,7 @@ */ import type { GraphicsObject } from "graphics-debug" -import { type PackInput, PackSolver2 } from "calculate-packing" +import { type PackInput, PackSolver2, pack } from "calculate-packing" import { BaseSolver } from "../BaseSolver" import type { OutputLayout, Placement } from "../../types/OutputLayout" import type { @@ -41,6 +41,17 @@ export class SingleInnerPartitionPackingSolver extends BaseSolver { // Initialize PackSolver2 if not already created if (!this.activeSubSolver) { const packInput = this.createPackInput() + // Force-directed path: one-shot pack() (includes the validate + + // greedy-fallback gate), not step-based like PackSolver2. + if ( + this.partitionInputProblem.packPlacementStrategy === "force_directed" + ) { + this.layout = this.createLayoutFromPackingResult( + pack(packInput).components, + ) + this.solved = true + return + } this.activeSubSolver = new PackSolver2(packInput) this.activeSubSolver = this.activeSubSolver } @@ -137,7 +148,10 @@ export class SingleInnerPartitionPackingSolver extends BaseSolver { components: packComponents, minGap, packOrderStrategy: "largest_to_smallest", - packPlacementStrategy: "minimum_closest_sum_squared_distance", + packPlacementStrategy: + this.partitionInputProblem.packPlacementStrategy === "force_directed" + ? "force_directed" + : "minimum_closest_sum_squared_distance", } } diff --git a/lib/solvers/PartitionPackingSolver/PartitionPackingSolver.ts b/lib/solvers/PartitionPackingSolver/PartitionPackingSolver.ts index 149d617..b034c14 100644 --- a/lib/solvers/PartitionPackingSolver/PartitionPackingSolver.ts +++ b/lib/solvers/PartitionPackingSolver/PartitionPackingSolver.ts @@ -4,7 +4,7 @@ */ import type { GraphicsObject } from "graphics-debug" -import { type PackInput, PackSolver2 } from "calculate-packing" +import { type PackInput, PackSolver2, pack } from "calculate-packing" import { BaseSolver } from "../BaseSolver" import type { OutputLayout, Placement } from "../../types/OutputLayout" import type { InputProblem } from "../../types/InputProblem" @@ -53,6 +53,17 @@ export class PartitionPackingSolver extends BaseSolver { // Initialize PackSolver2 if not already created if (!this.packSolver2) { const packInput = this.createPackInput(partitionGroups) + // Force-directed path: one-shot pack() (includes the validate + + // greedy-fallback gate), not step-based like PackSolver2. + if (this.inputProblem.packPlacementStrategy === "force_directed") { + const result = pack(packInput) + this.finalLayout = this.applyPackingResult( + result.components, + partitionGroups, + ) + this.solved = true + return + } this.packSolver2 = new PackSolver2(packInput) this.activeSubSolver = this.packSolver2 } @@ -296,7 +307,10 @@ export class PartitionPackingSolver extends BaseSolver { components: packComponents, minGap: this.inputProblem.partitionGap, // Use partitionGap from input problem packOrderStrategy: "largest_to_smallest", - packPlacementStrategy: "minimum_sum_squared_distance_to_network", + packPlacementStrategy: + this.inputProblem.packPlacementStrategy === "force_directed" + ? "force_directed" + : "minimum_sum_squared_distance_to_network", } } diff --git a/lib/types/InputProblem.ts b/lib/types/InputProblem.ts index 38e9f75..ca1b453 100644 --- a/lib/types/InputProblem.ts +++ b/lib/types/InputProblem.ts @@ -44,6 +44,15 @@ export type InputProblem = { decouplingCapsGap?: number inferDecouplingCaps?: boolean + + /** + * Packing strategy for the chip/partition packers. Default (undefined) keeps + * the established greedy outline packer. Set to "force_directed" to use the + * analytical force-directed packer instead — far faster on large boards + * (the O(n^3) greedy packer is the cause of tscircuit#3208), behind a + * validate + greedy-fallback gate so a layout is never silently worse. + */ + packPlacementStrategy?: "force_directed" } export interface PartitionInputProblem extends InputProblem { diff --git a/spike-matchpack-fd.ts b/spike-matchpack-fd.ts new file mode 100644 index 0000000..86a5370 --- /dev/null +++ b/spike-matchpack-fd.ts @@ -0,0 +1,151 @@ +/** + * SPIKE (throwaway): does force-directed packing win INSIDE matchpack's real + * hierarchical pipeline (LayoutPipelineSolver), not just standalone? + * + * bun spike-matchpack-fd.ts + * + * Generates N-resistor problems and runs the full LayoutPipelineSolver with the + * stock greedy packer vs. the FD packer (env MATCHPACK_FD, hooked into the two + * createPackInput sites). Reports end-to-end solve time, #chips placed, and + * overlap count (rotation-aware AABB, same as the pipeline's own check). + */ +import { LayoutPipelineSolver } from "lib/solvers/LayoutPipelineSolver/LayoutPipelineSolver" +import type { InputProblem } from "lib/types/InputProblem" +import { normalizeSide } from "lib/types/Side" + +type Conn = "disconnected" | "series" + +function makeResistors(n: number, conn: Conn): InputProblem { + const chipMap: InputProblem["chipMap"] = {} + const chipPinMap: InputProblem["chipPinMap"] = {} + const netMap: InputProblem["netMap"] = {} + const pinStrongConnMap: InputProblem["pinStrongConnMap"] = {} + const netConnMap: InputProblem["netConnMap"] = {} + + for (let i = 0; i < n; i++) { + const p1 = `P${i}_1` + const p2 = `P${i}_2` + chipMap[`R${i}`] = { + chipId: `R${i}`, + pins: [p1, p2], + size: { x: 1, y: 0.4 }, + availableRotations: [0, 90, 180, 270], + } + chipPinMap[p1] = { + pinId: p1, + offset: { x: -0.5, y: 0 }, + side: normalizeSide("left"), + } + chipPinMap[p2] = { + pinId: p2, + offset: { x: 0.5, y: 0 }, + side: normalizeSide("right"), + } + } + + if (conn === "series") { + // R(i).p2 strongly connected to R(i+1).p1 + for (let i = 0; i < n - 1; i++) { + const a = `P${i}_2` + const b = `P${i + 1}_1` + pinStrongConnMap[`${a}-${b}`] = true + pinStrongConnMap[`${b}-${a}`] = true + } + } + + return { + chipMap, + chipPinMap, + netMap, + pinStrongConnMap, + netConnMap, + chipGap: 0.2, + partitionGap: 1, + } +} + +function rotatedSize(size: { x: number; y: number }, rot: number) { + return rot === 90 || rot === 270 + ? { x: size.y, y: size.x } + : { x: size.x, y: size.y } +} + +function countOverlaps( + problem: InputProblem, + layout: any, + gap: number, +): number { + const ids = Object.keys(layout.chipPlacements) + let count = 0 + for (let i = 0; i < ids.length; i++) { + for (let j = i + 1; j < ids.length; j++) { + const a = layout.chipPlacements[ids[i]!]! + const b = layout.chipPlacements[ids[j]!]! + const sa = rotatedSize( + problem.chipMap[ids[i]!]!.size, + a.ccwRotationDegrees || 0, + ) + const sb = rotatedSize( + problem.chipMap[ids[j]!]!.size, + b.ccwRotationDegrees || 0, + ) + const ox = (sa.x + sb.x) / 2 + gap - Math.abs(a.x - b.x) + const oy = (sa.y + sb.y) / 2 + gap - Math.abs(a.y - b.y) + if (ox > 1e-6 && oy > 1e-6) count++ + } + } + return count +} + +function run(problem: InputProblem, useFD: boolean) { + // Drive force-directed via the real InputProblem field (the plumbing), not + // the old MATCHPACK_FD env hook. + const prob = structuredClone(problem) + if (useFD) prob.packPlacementStrategy = "force_directed" + const solver = new LayoutPipelineSolver(prob) + const t0 = performance.now() + solver.solve() + const ms = performance.now() - t0 + let layout: any + let err: string | null = null + try { + layout = solver.getOutputLayout() + } catch (e) { + err = (e as Error).message + } + const placed = layout ? Object.keys(layout.chipPlacements).length : 0 + const overlaps = layout ? countOverlaps(problem, layout, problem.chipGap) : -1 + return { ms, placed, overlaps, failed: solver.failed, err } +} + +const H = [ + "conn".padEnd(13), + "n".padStart(4), + "packer".padEnd(8), + "solve(ms)".padStart(11), + "placed".padStart(7), + "overlaps".padStart(9), + "status".padStart(8), +].join(" ") +console.log(H) +console.log("-".repeat(H.length)) +for (const conn of ["disconnected", "series"] as Conn[]) { + for (const n of [25, 50, 100]) { + const problem = makeResistors(n, conn) + for (const useFD of [false, true]) { + const r = run(problem, useFD) + const status = r.failed ? "FAILED" : r.err ? "ERR" : "ok" + console.log( + [ + conn.padEnd(13), + String(n).padStart(4), + (useFD ? "FD" : "greedy").padEnd(8), + r.ms.toFixed(1).padStart(11), + String(r.placed).padStart(7), + String(r.overlaps).padStart(9), + status.padStart(8), + ].join(" "), + ) + } + } +}