From b74fd7dcc86550fc916fdf78baccf14efd0ab4df Mon Sep 17 00:00:00 2001 From: seveibar Date: Sun, 9 Aug 2026 18:35:04 -0700 Subject: [PATCH 1/6] Add outside-in partial-rip autorouting --- README.md | 31 + experiments/outside-in-partial-rip.md | 229 ++++ lib/core.ts | 63 +- lib/index.ts | 1 + ...e-in-partial-rip-tiny-hypergraph-solver.ts | 1051 +++++++++++++++++ ...selective-rerip-tiny-hyper-graph-solver.ts | 9 +- ...partial-rip-tiny-hypergraph-solver.test.ts | 130 ++ tests/solver/on-all-routes-routed.test.ts | 12 + 8 files changed, 1519 insertions(+), 7 deletions(-) create mode 100644 experiments/outside-in-partial-rip.md create mode 100644 lib/outside-in-partial-rip-tiny-hypergraph-solver.ts create mode 100644 tests/outside-in-partial-rip-tiny-hypergraph-solver.test.ts diff --git a/README.md b/README.md index e334c93..beb84c0 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,37 @@ existing ports and contribute to region congestion immediately, but remain eligible for the normal rip-and-reroute process. They do not create regions or otherwise change the hypergraph topology. +### Outside-in partial reripping + +`SelectiveReripTinyHyperGraphSolver` preserves the unaffected prefix and +suffix of each route that crosses a hot region. Only a bounded window around +the route's hottest affected segment is reopened. The reopened span is searched +from both retained ends, with a hard geometric travel limit on each frontier; +if the frontiers cannot meet within that limit, the span safely falls back to +the regular one-ended search. + +The behavior can be tuned through `TinyHyperGraphSolverOptions`: + +```ts +const solver = new SelectiveReripTinyHyperGraphSolver(topology, problem, { + PARTIAL_RIP_ENABLED: true, + PARTIAL_RIP_MAX_DISTANCE: 12, + PARTIAL_RIP_QUALITY_MAX_DISTANCE: 24, + PARTIAL_RIP_MAX_ATTEMPTS: 10, + OUTSIDE_IN_ROUTING: true, + OUTSIDE_IN_MAX_DISTANCE: 24, +}) +``` + +Set `PARTIAL_RIP_ENABLED` or `OUTSIDE_IN_ROUTING` to `false` to use the legacy +whole-route or one-ended behavior respectively. The solver exposes aggregate +partial-rip, retained-segment, frontier-expansion, distance-prune, and fallback +counts through `solver.stats`. When the first completed solution is already +within 1.5 times the configured final rip threshold, the solver uses the +quality-recovery distance (twice the normal distance when unspecified) for the +entire partial-rip run; this gives low-cost solutions enough room to remove a +last hotspot without slowing heavily congested cases. + ### Export a solved solver back to `SerializedHyperGraph` `solver.getOutput()` now returns a `SerializedHyperGraph` for a solved diff --git a/experiments/outside-in-partial-rip.md b/experiments/outside-in-partial-rip.md new file mode 100644 index 0000000..ed6cac1 --- /dev/null +++ b/experiments/outside-in-partial-rip.md @@ -0,0 +1,229 @@ +# Outside-in partial-rip experiments + +All timings are from the same Apple Silicon workstation and the committed +SRJ18 Pipeline7 inputs used by `./benchmark.sh`. Benchmark runs use the default +concurrency reported by the harness. Region-cost comparisons use the harness' +average maximum region cost. + +## Acceptance target + +- At least 1.5x faster than unmodified `main`. +- Prefer 5-10x if correctness and cost remain stable. +- Average maximum region cost no more than roughly 20% above the original + score (2.333), i.e. at most about 2.800. +- All 8 SRJ18 cases and all 2,001 routes must complete. + +## Trial 0 - unmodified main (`b617b67`) + +Command: `./benchmark.sh` + +- Result directory: `results/run001` +- Success: 8/8 cases, 2,001/2,001 routes. +- Total completion time: 95.441 s. +- Average duration: 11.930 s; P50 11.000 s; P95 18.759 s. +- Average maximum region cost: 2.333. +- Average solver iterations: 1,335,770.1. +- Per-case duration: 8.129, 5.359, 6.061, 15.945, 12.904, 11.000, + 17.283, and 18.759 s. + +The solver performs ten full-graph rerips on these committed inputs. A focused +sample001 diagnostic measured 1,390,418 iterations, 10 rerips, and 8.049 s. + +## Diagnostic - accept the first complete route set + +This was a read-only parameter probe (`RIP_THRESHOLD_RAMP_ATTEMPTS=0`), not an +implementation trial. It establishes the optimization ceiling by removing all +post-solve rerips while leaving initial routing unchanged. + +- Aggregate solve time across all eight cases: 7.285 s (13.10x faster). +- Average maximum region cost: 1.910 (18.1% better than the main baseline). +- All eight cases completed. + +This confirms that repeated whole-trace rerouting is both the dominant runtime +cost and unnecessary for preserving the benchmark's region-cost envelope. + +## Trial 1 - bounded central partial rip (12 mm per retained end) + +Implementation: retain every unaffected prefix/suffix, select the hottest +segment on each affected route, reopen at most 12 mm of its old path on either +side, and route only between the resulting temporary endpoints. Restore the +best completed state at termination. + +Command: `./benchmark.sh` + +- Success: 8/8 cases, 2,001/2,001 routes. +- Total completion time: 48.460 s (1.97x faster). +- Average duration: 6.057 s; P50 4.497 s; P95 14.112 s. +- Average maximum region cost: 1.701 (27.1% better than main). +- Average solver iterations: 676,204.1 (49.4% fewer than main). +- Per-case duration: 3.335, 3.053, 2.141, 8.779, 14.112, 4.497, + 7.229, and 5.314 s. + +This clears the minimum speed target with substantial aggregate cost headroom. +The remaining issues are sample008 reaching its two-million-iteration cap and +sample011 ending at 5.117 versus main's 3.217. The next trials target those two +tails and add the required two-ended search. + +## Trial 2 - outside-in partial spans, 24 post-meeting expansions + +Implementation: initial whole routes retain the established one-ended A* for +quality. Every partially reopened span receives two independent frontiers, one +from each retained end. Each frontier has a hard 24 mm travel limit. Once the +frontiers meet, the solver considers 24 additional expansions and commits the +lowest-cost valid join. A span that cannot meet within the bound falls back to +the regular route search. Live region caches are canonicalized to the same +route order used during output replay, eliminating score drift after +serialization. + +Command: `./benchmark.sh` + +- Success: 8/8 cases, 2,001/2,001 routes. +- Total completion time: 17.792 s (5.36x faster). +- Average duration: 2.224 s; P50 1.707 s; P95 5.166 s. +- Average maximum region cost: 1.488 (36.2% better than main). +- Average solver iterations: 260,978.9 (80.5% fewer than main). +- Per-case duration: 1.389, 0.926, 0.788, 2.745, 5.166, 1.707, + 1.924, and 3.146 s. +- Per-case max cost: 1.237, 1.543, 0.797, 1.133, 2.834, 1.492, + 1.278, and 1.590. + +This reaches the requested 5-10x range while improving the aggregate region +score. Focused sample008 and sample011 checks also eliminated Trial 1's cost +tails (2.834 and 1.590 respectively). + +## Trial 3 - reduce post-meeting search from 24 to 16 (rejected) + +Focused command: `./benchmark.sh --sample NAME --concurrency 1` for samples +001, 007, 008, and 011. + +- sample001: 2.938 s, cost 1.375, 453,560 iterations. +- sample007: 3.140 s, cost 1.133, 220,971 iterations. +- sample008: 10.138 s, cost 3.285, 1,567,708 iterations. +- sample011: 3.594 s, cost 1.835, 367,853 iterations. + +Although each individual join did less work, the weaker join choices caused +substantially more work in later partial-rip rounds. Reverted to 24. + +## Trial 4 - raise per-frontier travel limit from 24 mm to 32 mm (rejected) + +The same four focused cases produced identical costs and iteration counts to +Trial 2. No selected route needed the additional frontier reach; elapsed time +was slightly noisier/slower. Reverted to 24 mm. + +## Trial 5 - partial-rip window sweep (8, 14, and 16 mm; rejected) + +Focused samples 001/007/008/011 were run at each distance. + +- 8 mm was faster on some cases but regressed costs to 3.130 on sample001 and + 3.400 on sample008. +- 14 mm regressed sample001 to 1.746 and sample008 to 3.400. +- 16 mm improved costs (0.963/0.764/2.224/1.590) but increased the four-case + iteration total by roughly 24% versus 12 mm and dropped the projected suite + speed below the requested 5x range. + +The 12 mm window remains the best speed/quality balance. + +## Trial 6 - cap partial-rip exploration at six rounds (accepted) + +Command: `./benchmark.sh` + +- Success: 8/8 cases, 2,001/2,001 routes. +- Total completion time: 16.871 s (5.66x faster). +- Average duration: 2.109 s; P50 1.598 s; P95 4.065 s. +- Average maximum region cost: 1.530 (34.4% better than main and 45.4% + below the allowed 2.800 aggregate ceiling). +- Average solver iterations: 219,249.0 (83.6% fewer than main). +- Per-case duration: 1.387, 1.598, 0.858, 2.495, 4.065, 1.456, + 1.741, and 3.271 s. +- Per-case max cost: 1.237, 1.543, 0.797, 1.133, 3.313, 1.267, + 1.359, and 1.590. + +The sample008 score is 18.1% above its original 2.806 and therefore remains +inside the requested per-case tolerance as well as the aggregate tolerance. + +## Trial 7 - cap partial-rip exploration at five rounds (rejected) + +Focused samples were slightly faster, but sample001 regressed to 1.461, 33.9% +above its original 1.091 score. Reverted to six rounds. + +## Trial 8 - bounded connector distance (accepted aggregate setting) + +Added an explicit combined-distance check to ensure that a joined path can be +split between the two frontiers without either side exceeding its 24 mm travel +budget. A deliberately over-budget regression fixture confirms that the solver +falls back safely to its established one-ended search. + +Command: `./benchmark.sh` + +- Success: 8/8 cases, 2,001/2,001 routes. +- Total completion time: 15.725 s (6.07x faster than main's 95.441 s). +- Average duration: 1.966 s; P50 1.396 s; P95 4.138 s. +- Average maximum region cost: 1.530 (34.4% better than main's 2.333 and + 45.4% below the allowed 2.800 aggregate ceiling). +- Average solver iterations: 219,249.0 (83.6% fewer than main's 1,335,770.1). +- Per-case duration: 1.376, 1.396, 0.687, 2.416, 4.138, 1.273, + 1.534, and 2.906 s. +- Per-case max cost: 1.237, 1.543, 0.797, 1.133, 3.313, 1.267, + 1.359, and 1.590. + +Verification: `bun run typecheck`, `bun run build`, `git diff --check`, and +all 100 non-image-snapshot tests pass. The repository's three image-snapshot +files remain unavailable in this environment because the pre-existing optional +Sharp Darwin ARM64 native binary is absent; the failures occur while importing +the snapshot helper, before solver code executes. + +## Trial 9 - strict per-case quality sweep + +Although Trial 8 exceeded the aggregate quality target, sample007's 1.133 cost +was more than 20% above its original 0.527. Additional focused trials treated +the tolerance as a per-case requirement: + +- Fixed 20 mm partial windows: sample007 cost 0.838 in 2.682 s. +- Fixed 24 mm partial windows: sample007 cost 0.775 in 2.877 s. +- Fixed 32 mm partial windows: sample007 cost 0.834 in 3.016 s (rejected). +- Fixed 24 mm windows with ten rounds: sample007 cost 0.629 in 3.121 s, + inside its 0.632 tolerance ceiling. Across the suite this took 21.294 s + (4.48x faster) and averaged 1.404, but sample001 regressed to 1.685, so the + fixed setting was rejected. +- Staging 12 mm windows before switching to 24 mm preserved sample001 but left + sample007 between 0.717 and 0.730, outside its strict ceiling (rejected). + +The useful signal was the first completed solution's relationship to the final +rip threshold: sample007 begins near the target and needs a wider repair from +the first round, while highly congested cases benefit from local repairs. + +## Final verification - threshold-relative quality recovery + +Implementation: latch a 24 mm quality-recovery window when the first completed +solution's maximum hot-region cost is no more than 1.5 times the configured +final rip threshold. Otherwise retain the fast 12 mm window. Both modes use at +most ten partial-rip rounds and always restore the best complete snapshot. + +Command: `./benchmark.sh` + +- Success: 8/8 cases, 2,001/2,001 routes. +- Total completion time: 18.717 s (5.10x faster than main's 95.441 s). +- Average duration: 2.340 s; P50 1.473 s; P95 5.364 s. +- Average maximum region cost: 1.425 (38.9% better than main's 2.333). +- Average solver iterations: 270,910.0 (79.7% fewer than main's 1,335,770.1). +- Per-case duration: 1.465, 0.984, 1.021, 3.369, 5.364, 1.473, + 1.716, and 3.326 s. + +| Sample | Original cost | Final cost | Change | +| --- | ---: | ---: | ---: | +| sample001 | 1.091 | 1.237 | +13.4% | +| sample003 | 6.353 | 1.543 | -75.7% | +| sample005 | 1.492 | 0.797 | -46.6% | +| sample007 | 0.527 | 0.629 | +19.4% | +| sample008 | 2.806 | 2.834 | +1.0% | +| sample009 | 1.492 | 1.492 | 0.0% | +| sample010 | 1.686 | 1.278 | -24.2% | +| sample011 | 3.217 | 1.590 | -50.6% | + +Every case is now within 20% of its own original score or better, in addition +to the stronger aggregate result. + +Verification: `bun run typecheck`, `bun run build`, `git diff --check`, and all +101 non-image-snapshot tests pass. As noted in Trial 8, the three remaining +snapshot files cannot import their pre-existing optional Sharp native binary in +this environment; they fail before any solver code runs. diff --git a/lib/core.ts b/lib/core.ts index c224a31..34b0566 100644 --- a/lib/core.ts +++ b/lib/core.ts @@ -254,6 +254,18 @@ export interface TinyHyperGraphSolverOptions { STATIC_REACHABILITY_PRECHECK_MAX_HOPS?: number ACCEPT_BEST_SOLUTION_ON_TIMEOUT?: boolean GREEDY_FINAL_ROUTE_ITERS?: number + /** Preserve route prefixes/suffixes and reopen only a bounded hot span. */ + PARTIAL_RIP_ENABLED?: boolean + /** Maximum old-route distance reopened on either side of a hot segment. */ + PARTIAL_RIP_MAX_DISTANCE?: number + /** Larger partial-rip window used when the initial solution is near target. */ + PARTIAL_RIP_QUALITY_MAX_DISTANCE?: number + /** Maximum completed partial-rip rounds before restoring the best state. */ + PARTIAL_RIP_MAX_ATTEMPTS?: number + /** Search from both active route ends instead of only the start end. */ + OUTSIDE_IN_ROUTING?: boolean + /** Maximum geometric distance explored by either outside-in frontier. */ + OUTSIDE_IN_MAX_DISTANCE?: number } export interface TinyHyperGraphSolverOptionTarget { @@ -271,6 +283,12 @@ export interface TinyHyperGraphSolverOptionTarget { STATIC_REACHABILITY_PRECHECK_MAX_HOPS: number ACCEPT_BEST_SOLUTION_ON_TIMEOUT: boolean GREEDY_FINAL_ROUTE_ITERS: number + PARTIAL_RIP_ENABLED?: boolean + PARTIAL_RIP_MAX_DISTANCE?: number + PARTIAL_RIP_QUALITY_MAX_DISTANCE?: number + PARTIAL_RIP_MAX_ATTEMPTS?: number + OUTSIDE_IN_ROUTING?: boolean + OUTSIDE_IN_MAX_DISTANCE?: number } export const applyTinyHyperGraphSolverOptions = ( @@ -326,6 +344,25 @@ export const applyTinyHyperGraphSolverOptions = ( if (options.GREEDY_FINAL_ROUTE_ITERS !== undefined) { solver.GREEDY_FINAL_ROUTE_ITERS = options.GREEDY_FINAL_ROUTE_ITERS } + if (options.PARTIAL_RIP_ENABLED !== undefined) { + solver.PARTIAL_RIP_ENABLED = options.PARTIAL_RIP_ENABLED + } + if (options.PARTIAL_RIP_MAX_DISTANCE !== undefined) { + solver.PARTIAL_RIP_MAX_DISTANCE = options.PARTIAL_RIP_MAX_DISTANCE + } + if (options.PARTIAL_RIP_QUALITY_MAX_DISTANCE !== undefined) { + solver.PARTIAL_RIP_QUALITY_MAX_DISTANCE = + options.PARTIAL_RIP_QUALITY_MAX_DISTANCE + } + if (options.PARTIAL_RIP_MAX_ATTEMPTS !== undefined) { + solver.PARTIAL_RIP_MAX_ATTEMPTS = options.PARTIAL_RIP_MAX_ATTEMPTS + } + if (options.OUTSIDE_IN_ROUTING !== undefined) { + solver.OUTSIDE_IN_ROUTING = options.OUTSIDE_IN_ROUTING + } + if (options.OUTSIDE_IN_MAX_DISTANCE !== undefined) { + solver.OUTSIDE_IN_MAX_DISTANCE = options.OUTSIDE_IN_MAX_DISTANCE + } } export const getTinyHyperGraphSolverOptions = ( @@ -346,6 +383,12 @@ export const getTinyHyperGraphSolverOptions = ( solver.STATIC_REACHABILITY_PRECHECK_MAX_HOPS, ACCEPT_BEST_SOLUTION_ON_TIMEOUT: solver.ACCEPT_BEST_SOLUTION_ON_TIMEOUT, GREEDY_FINAL_ROUTE_ITERS: solver.GREEDY_FINAL_ROUTE_ITERS, + PARTIAL_RIP_ENABLED: solver.PARTIAL_RIP_ENABLED, + PARTIAL_RIP_MAX_DISTANCE: solver.PARTIAL_RIP_MAX_DISTANCE, + PARTIAL_RIP_QUALITY_MAX_DISTANCE: solver.PARTIAL_RIP_QUALITY_MAX_DISTANCE, + PARTIAL_RIP_MAX_ATTEMPTS: solver.PARTIAL_RIP_MAX_ATTEMPTS, + OUTSIDE_IN_ROUTING: solver.OUTSIDE_IN_ROUTING, + OUTSIDE_IN_MAX_DISTANCE: solver.OUTSIDE_IN_MAX_DISTANCE, }) const compareCandidatesByF = (left: Candidate, right: Candidate) => @@ -391,6 +434,12 @@ export class TinyHyperGraphSolver extends BaseSolver { STATIC_REACHABILITY_PRECHECK_MAX_HOPS = 16 ACCEPT_BEST_SOLUTION_ON_TIMEOUT = true GREEDY_FINAL_ROUTE_ITERS = 4 + PARTIAL_RIP_ENABLED = false + PARTIAL_RIP_MAX_DISTANCE = 12 + PARTIAL_RIP_QUALITY_MAX_DISTANCE?: number + PARTIAL_RIP_MAX_ATTEMPTS = Number.POSITIVE_INFINITY + OUTSIDE_IN_ROUTING = false + OUTSIDE_IN_MAX_DISTANCE = 24 constructor( public topology: TinyHyperGraphTopology, @@ -541,7 +590,7 @@ export class TinyHyperGraphSolver extends BaseSolver { this.routeAttemptCountByRouteId[state.currentRouteId!] += 1 this.resetCandidateBestCosts() - const startingPortId = problem.routeStartPort[state.currentRouteId!] + const startingPortId = this.getRouteStartPortId(state.currentRouteId!) state.candidateQueue.clear() const startingNextRegionId = this.getStartingNextRegionId( state.currentRouteId!, @@ -565,7 +614,7 @@ export class TinyHyperGraphSolver extends BaseSolver { g: 0, h: 0, }) - state.goalPortId = problem.routeEndPort[state.currentRouteId!] + state.goalPortId = this.getRouteEndPortId(state.currentRouteId!) } const currentCandidate = state.candidateQueue.dequeue() @@ -721,6 +770,14 @@ export class TinyHyperGraphSolver extends BaseSolver { ) } + protected getRouteStartPortId(routeId: RouteId): PortId { + return this.problem.routeStartPort[routeId]! + } + + protected getRouteEndPortId(routeId: RouteId): PortId { + return this.problem.routeEndPort[routeId]! + } + isPortReservedForDifferentNet(portId: PortId): boolean { const reservedNetId = this.problemSetup.portEndpointReservationNetId[portId] ?? -1 @@ -1516,7 +1573,7 @@ export class TinyHyperGraphSolver extends BaseSolver { ] } - const endPortId = this.problem.routeEndPort[this.state.currentRouteId!] + const endPortId = this.getRouteEndPortId(this.state.currentRouteId!) const dx = this.topology.portX[neighborPortId] - this.topology.portX[endPortId] const dy = diff --git a/lib/index.ts b/lib/index.ts index 4b90197..713b4ed 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,5 +1,6 @@ export * from "./core" export * from "./distance-aware-tiny-hypergraph-solver" +export * from "./outside-in-partial-rip-tiny-hypergraph-solver" export * from "./DuplicateCongestedPortSolver" export * from "./find-distinct-owner-blocker-path" export * from "./indexed-candidate-heap" diff --git a/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts b/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts new file mode 100644 index 0000000..5f63d1d --- /dev/null +++ b/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts @@ -0,0 +1,1051 @@ +import { + type Candidate, + createEmptyRegionIntersectionCache, + type RegionCostSummary, + type TinyHyperGraphProblem, + type TinyHyperGraphSolverOptions, + type TinyHyperGraphTopology, +} from "./core" +import { DistanceAwareTinyHyperGraphSolver } from "./distance-aware-tiny-hypergraph-solver" +import { MinHeap } from "./MinHeap" +import type { PortId, RegionId, RouteId } from "./types" + +type CommittedRouteSegment = { + regionId: RegionId + fromPortId: PortId + toPortId: PortId +} + +type PartialRipRoutePlan = { + routeId: RouteId + activeStartPortId: PortId + activeEndPortId: PortId + forcedStartRegionId: RegionId + forcedEndRegionId: RegionId + rippedSegmentCount: number + retainedSegmentCount: number +} + +type OutsideInCandidate = Candidate & { + travelDistance: number +} + +type OutsideInFrontier = { + queue: MinHeap + bestCostByHopId: Map + settledByPortId: Map + settledByRegionId: Map + targetPortId: PortId +} + +type OutsideInRouteSearch = { + routeId: RouteId + forward: OutsideInFrontier + reverse: OutsideInFrontier + expandForwardNext: boolean + distanceLimitHit: boolean + bestJoinedCandidate?: Candidate + bestJoinedCost: number + remainingPostMeetingExpansions?: number +} + +type JoinedOutsideInCandidate = { + candidate: Candidate + cost: number +} + +type IndexedCommittedRouteSegment = CommittedRouteSegment & { + segmentIndex: number +} + +/** + * Retains the two outside portions of a completed route and only reroutes a + * bounded window around a congested region. The active window is represented + * as a normal route with temporary endpoints, so all existing cost and hard + * constraint checks continue to apply. + * + * Outside-in frontier search is implemented by this class separately from the + * partial-rip state transition. Initial whole routes retain the established + * one-ended search; the bounded two-ended search applies to reopened spans. + */ +export class OutsideInPartialRipTinyHyperGraphSolver extends DistanceAwareTinyHyperGraphSolver { + protected partialRipRoutePlans = new Map() + private outsideInRouteSearch?: OutsideInRouteSearch + private oneSidedFallbackRouteId?: RouteId + private partialRipWindowDistance?: number + private partialRipCount = 0 + private partiallyRippedRouteCount = 0 + private partiallyRippedSegmentCount = 0 + private retainedPartialRipSegmentCount = 0 + private outsideInRouteCount = 0 + private outsideInCompletedRouteCount = 0 + private outsideInFallbackRouteCount = 0 + private outsideInForwardExpansionCount = 0 + private outsideInReverseExpansionCount = 0 + private outsideInDistancePruneCount = 0 + + constructor( + topology: TinyHyperGraphTopology, + problem: TinyHyperGraphProblem, + options?: TinyHyperGraphSolverOptions, + ) { + super(topology, problem, options) + if (options?.PARTIAL_RIP_ENABLED === undefined) { + this.PARTIAL_RIP_ENABLED = true + } + if (options?.OUTSIDE_IN_ROUTING === undefined) { + this.OUTSIDE_IN_ROUTING = true + } + if ( + this.PARTIAL_RIP_ENABLED && + options?.PARTIAL_RIP_MAX_ATTEMPTS === undefined + ) { + this.PARTIAL_RIP_MAX_ATTEMPTS = 10 + } + } + + protected override getRouteStartPortId(routeId: RouteId): PortId { + return ( + this.partialRipRoutePlans.get(routeId)?.activeStartPortId ?? + super.getRouteStartPortId(routeId) + ) + } + + protected override getRouteEndPortId(routeId: RouteId): PortId { + return ( + this.partialRipRoutePlans.get(routeId)?.activeEndPortId ?? + super.getRouteEndPortId(routeId) + ) + } + + override getStartingNextRegionId( + routeId: RouteId, + startingPortId: PortId, + ): RegionId | undefined { + const partialRipRoutePlan = this.partialRipRoutePlans.get(routeId) + if ( + partialRipRoutePlan && + partialRipRoutePlan.activeStartPortId === startingPortId + ) { + return partialRipRoutePlan.forcedStartRegionId + } + + return super.getStartingNextRegionId(routeId, startingPortId) + } + + private getEndingNextRegionId( + routeId: RouteId, + endingPortId: PortId, + ): RegionId | undefined { + const partialRipRoutePlan = this.partialRipRoutePlans.get(routeId) + if ( + partialRipRoutePlan && + partialRipRoutePlan.activeEndPortId === endingPortId + ) { + return partialRipRoutePlan.forcedEndRegionId + } + + return super.getStartingNextRegionId(routeId, endingPortId) + } + + override computeH(neighborPortId: PortId): number { + const routeId = this.state.currentRouteId + if (routeId === undefined || !this.partialRipRoutePlans.has(routeId)) { + return super.computeH(neighborPortId) + } + + const endPortId = this.getRouteEndPortId(routeId) + return ( + Math.hypot( + this.topology.portX[neighborPortId]! - this.topology.portX[endPortId]!, + this.topology.portY[neighborPortId]! - this.topology.portY[endPortId]!, + ) * this.DISTANCE_TO_COST + ) + } + + override onPathFound(finalCandidate: Candidate): void { + const routeId = this.state.currentRouteId + const completedOutsideInRoute = + routeId !== undefined && this.outsideInRouteSearch?.routeId === routeId + super.onPathFound(finalCandidate) + if (routeId !== undefined && this.state.currentRouteId === undefined) { + this.partialRipRoutePlans.delete(routeId) + if (completedOutsideInRoute) this.outsideInCompletedRouteCount += 1 + if (this.oneSidedFallbackRouteId === routeId) { + this.oneSidedFallbackRouteId = undefined + } + } + this.outsideInRouteSearch = undefined + this.publishOutsideInStats() + } + + override resetRoutingStateForRerip(): void { + this.partialRipRoutePlans.clear() + this.outsideInRouteSearch = undefined + this.oneSidedFallbackRouteId = undefined + super.resetRoutingStateForRerip() + } + + protected clearPartialRipPlans(routeIds: ReadonlySet): void { + for (const routeId of routeIds) { + this.partialRipRoutePlans.delete(routeId) + } + } + + private getCommittedRouteSegments( + routeId: RouteId, + ): CommittedRouteSegment[] | undefined { + const indexedSegments: IndexedCommittedRouteSegment[] = [] + for ( + let regionId = 0; + regionId < this.state.regionSegments.length; + regionId++ + ) { + for (const [segmentRouteId, fromPortId, toPortId] of this.state + .regionSegments[regionId] ?? []) { + if (segmentRouteId !== routeId) continue + indexedSegments.push({ + segmentIndex: indexedSegments.length, + regionId, + fromPortId, + toPortId, + }) + } + } + + if (indexedSegments.length === 0) return undefined + + const segmentIndicesByPortId = new Map() + for (const segment of indexedSegments) { + for (const portId of [segment.fromPortId, segment.toPortId]) { + const segmentIndices = segmentIndicesByPortId.get(portId) ?? [] + segmentIndices.push(segment.segmentIndex) + segmentIndicesByPortId.set(portId, segmentIndices) + } + } + + const endPortId = this.problem.routeEndPort[routeId]! + const orderedSegments: CommittedRouteSegment[] = [] + const usedSegmentIndices = new Set() + const visitedPortIds = new Set([ + this.problem.routeStartPort[routeId]!, + ]) + + const appendPathToEnd = (portId: PortId): boolean => { + if (portId === endPortId) return true + + for (const segmentIndex of segmentIndicesByPortId.get(portId) ?? []) { + if (usedSegmentIndices.has(segmentIndex)) continue + const segment = indexedSegments[segmentIndex]! + const nextPortId = + segment.fromPortId === portId ? segment.toPortId : segment.fromPortId + if (visitedPortIds.has(nextPortId)) continue + + usedSegmentIndices.add(segmentIndex) + visitedPortIds.add(nextPortId) + orderedSegments.push({ + regionId: segment.regionId, + fromPortId: portId, + toPortId: nextPortId, + }) + + if (appendPathToEnd(nextPortId)) return true + + orderedSegments.pop() + visitedPortIds.delete(nextPortId) + usedSegmentIndices.delete(segmentIndex) + } + + return false + } + + if ( + !appendPathToEnd(this.problem.routeStartPort[routeId]!) || + usedSegmentIndices.size !== indexedSegments.length + ) { + return undefined + } + + return orderedSegments + } + + private getSegmentDistance(segment: CommittedRouteSegment): number { + return Math.hypot( + this.topology.portX[segment.fromPortId]! - + this.topology.portX[segment.toPortId]!, + this.topology.portY[segment.fromPortId]! - + this.topology.portY[segment.toPortId]!, + ) + } + + private getPartialRipWindow( + orderedSegments: readonly CommittedRouteSegment[], + hotRegionIds: ReadonlySet, + regionCosts: Float64Array, + ): { startIndex: number; endIndex: number } | undefined { + let hottestSegmentIndex: number | undefined + let hottestSegmentCost = Number.NEGATIVE_INFINITY + + for ( + let segmentIndex = 0; + segmentIndex < orderedSegments.length; + segmentIndex++ + ) { + const segment = orderedSegments[segmentIndex]! + if (!hotRegionIds.has(segment.regionId)) continue + const regionCost = regionCosts[segment.regionId] ?? 0 + if (regionCost > hottestSegmentCost) { + hottestSegmentCost = regionCost + hottestSegmentIndex = segmentIndex + } + } + + if (hottestSegmentIndex === undefined) return undefined + + const maxDistance = Math.max( + 0, + this.partialRipWindowDistance ?? this.PARTIAL_RIP_MAX_DISTANCE, + ) + let startIndex = hottestSegmentIndex + let endIndex = hottestSegmentIndex + let startDistance = + this.getSegmentDistance(orderedSegments[hottestSegmentIndex]!) / 2 + let endDistance = startDistance + + while (startIndex > 0) { + const nextDistance = this.getSegmentDistance( + orderedSegments[startIndex - 1]!, + ) + if (startDistance + nextDistance > maxDistance) break + startIndex -= 1 + startDistance += nextDistance + } + + while (endIndex + 1 < orderedSegments.length) { + const nextDistance = this.getSegmentDistance( + orderedSegments[endIndex + 1]!, + ) + if (endDistance + nextDistance > maxDistance) break + endIndex += 1 + endDistance += nextDistance + } + + return { startIndex, endIndex } + } + + private appendRetainedSegment( + retainedSegmentsByRegion: Array<[RouteId, PortId, PortId][]>, + routeId: RouteId, + segment: CommittedRouteSegment, + ): void { + retainedSegmentsByRegion[segment.regionId]!.push([ + routeId, + segment.fromPortId, + segment.toPortId, + ]) + } + + protected preparePartialRip( + hotRegionIds: readonly RegionId[], + regionCosts: Float64Array, + ): boolean { + if (!this.PARTIAL_RIP_ENABLED || hotRegionIds.length === 0) return false + + const hotRegionIdSet = new Set(hotRegionIds) + if (this.partialRipWindowDistance === undefined) { + // A near-target solution usually has one stubborn local minimum. Give + // that run more topology to work with, and latch the choice so window + // sizes do not oscillate as congestion changes between rounds. + let maxHotRegionCost = 0 + for (const regionId of hotRegionIds) { + maxHotRegionCost = Math.max( + maxHotRegionCost, + regionCosts[regionId] ?? 0, + ) + } + const baseDistance = Math.max(0, this.PARTIAL_RIP_MAX_DISTANCE) + const qualityDistance = Math.max( + baseDistance, + this.PARTIAL_RIP_QUALITY_MAX_DISTANCE ?? baseDistance * 2, + ) + this.partialRipWindowDistance = + maxHotRegionCost <= this.RIP_THRESHOLD_END * 1.5 + ? qualityDistance + : baseDistance + } + const routeIdsTouchingHotRegions = new Set() + for (const regionId of hotRegionIds) { + for (const [routeId] of this.state.regionSegments[regionId] ?? []) { + routeIdsTouchingHotRegions.add(routeId) + } + } + if (routeIdsTouchingHotRegions.size === 0) return false + + const retainedSegmentsByRegion = Array.from( + { length: this.topology.regionCount }, + () => [] as [RouteId, PortId, PortId][], + ) + const nextPlans = new Map() + let rippedSegmentCount = 0 + let retainedSegmentCount = 0 + + for (let routeId = 0; routeId < this.problem.routeCount; routeId++) { + const orderedSegments = this.getCommittedRouteSegments(routeId) + if (!orderedSegments) return false + + if (!routeIdsTouchingHotRegions.has(routeId)) { + for (const segment of orderedSegments) { + this.appendRetainedSegment(retainedSegmentsByRegion, routeId, segment) + retainedSegmentCount += 1 + } + continue + } + + const window = this.getPartialRipWindow( + orderedSegments, + hotRegionIdSet, + regionCosts, + ) + if (!window) return false + + for ( + let segmentIndex = 0; + segmentIndex < orderedSegments.length; + segmentIndex++ + ) { + const segment = orderedSegments[segmentIndex]! + if ( + segmentIndex >= window.startIndex && + segmentIndex <= window.endIndex + ) { + rippedSegmentCount += 1 + continue + } + this.appendRetainedSegment(retainedSegmentsByRegion, routeId, segment) + retainedSegmentCount += 1 + } + + const firstRippedSegment = orderedSegments[window.startIndex]! + const lastRippedSegment = orderedSegments[window.endIndex]! + nextPlans.set(routeId, { + routeId, + activeStartPortId: firstRippedSegment.fromPortId, + activeEndPortId: lastRippedSegment.toPortId, + forcedStartRegionId: firstRippedSegment.regionId, + forcedEndRegionId: lastRippedSegment.regionId, + rippedSegmentCount: window.endIndex - window.startIndex + 1, + retainedSegmentCount: + orderedSegments.length - (window.endIndex - window.startIndex + 1), + }) + } + + if (nextPlans.size === 0 || rippedSegmentCount === 0) return false + + this.partialRipRoutePlans = nextPlans + this.rebuildRetainedRoutingState(retainedSegmentsByRegion, [ + ...nextPlans.keys(), + ]) + this.partialRipCount += 1 + this.partiallyRippedRouteCount += nextPlans.size + this.partiallyRippedSegmentCount += rippedSegmentCount + this.retainedPartialRipSegmentCount += retainedSegmentCount + this.publishPartialRipStats() + return true + } + + private rebuildRetainedRoutingState( + retainedSegmentsByRegion: Array<[RouteId, PortId, PortId][]>, + unroutedRouteIds: RouteId[], + ): void { + this.state.portAssignment.fill(-1) + this.state.regionSegments = Array.from( + { length: this.topology.regionCount }, + () => [], + ) + this.state.regionIntersectionCaches = Array.from( + { length: this.topology.regionCount }, + () => createEmptyRegionIntersectionCache(), + ) + this.state.currentRouteId = undefined + this.state.currentRouteNetId = undefined + this.state.unroutedRoutes = unroutedRouteIds + this.state.candidateQueue.clear() + this.resetCandidateBestCosts() + this.state.goalPortId = -1 + this.outsideInRouteSearch = undefined + this.oneSidedFallbackRouteId = undefined + + for ( + let regionId = 0; + regionId < retainedSegmentsByRegion.length; + regionId++ + ) { + for (const [routeId, fromPortId, toPortId] of retainedSegmentsByRegion[ + regionId + ] ?? []) { + const routeNetId = this.problem.routeNet[routeId]! + this.state.currentRouteNetId = routeNetId + for (const portId of [fromPortId, toPortId]) { + const assignedNetId = this.state.portAssignment[portId]! + if (assignedNetId !== -1 && assignedNetId !== routeNetId) { + throw new Error( + `OutsideInPartialRipTinyHyperGraphSolver: retained port ${portId} belongs to multiple nets`, + ) + } + this.state.portAssignment[portId] = routeNetId + } + this.state.regionSegments[regionId]!.push([ + routeId, + fromPortId, + toPortId, + ]) + this.appendSegmentToRegionCache(regionId, fromPortId, toPortId) + } + } + this.state.currentRouteNetId = undefined + } + + private publishPartialRipStats(): void { + this.stats = { + ...this.stats, + partialRipCount: this.partialRipCount, + partiallyRippedRouteCount: this.partiallyRippedRouteCount, + partiallyRippedSegmentCount: this.partiallyRippedSegmentCount, + retainedPartialRipSegmentCount: this.retainedPartialRipSegmentCount, + partialRipMaxDistance: + this.partialRipWindowDistance ?? this.PARTIAL_RIP_MAX_DISTANCE, + partialRipBaseMaxDistance: this.PARTIAL_RIP_MAX_DISTANCE, + partialRipQualityMaxDistance: + this.PARTIAL_RIP_QUALITY_MAX_DISTANCE ?? + this.PARTIAL_RIP_MAX_DISTANCE * 2, + partialRipMaxAttempts: this.PARTIAL_RIP_MAX_ATTEMPTS, + } + } + + private publishOutsideInStats(): void { + this.stats = { + ...this.stats, + outsideInRouteCount: this.outsideInRouteCount, + outsideInCompletedRouteCount: this.outsideInCompletedRouteCount, + outsideInFallbackRouteCount: this.outsideInFallbackRouteCount, + outsideInForwardExpansionCount: this.outsideInForwardExpansionCount, + outsideInReverseExpansionCount: this.outsideInReverseExpansionCount, + outsideInDistancePruneCount: this.outsideInDistancePruneCount, + outsideInMaxDistance: this.OUTSIDE_IN_MAX_DISTANCE, + } + } + + private createOutsideInFrontier( + startPortId: PortId, + startRegionId: RegionId, + targetPortId: PortId, + ): OutsideInFrontier { + const queue = new MinHeap([], (left, right) => + left.f === right.f ? left.g - right.g : left.f - right.f, + ) + const h = + Math.hypot( + this.topology.portX[startPortId]! - this.topology.portX[targetPortId]!, + this.topology.portY[startPortId]! - this.topology.portY[targetPortId]!, + ) * this.DISTANCE_TO_COST + const root: OutsideInCandidate = { + portId: startPortId, + nextRegionId: startRegionId, + f: h, + g: 0, + h, + travelDistance: 0, + } + queue.queue(root) + return { + queue, + bestCostByHopId: new Map([ + [this.getHopId(startPortId, startRegionId), 0], + ]), + settledByPortId: new Map(), + settledByRegionId: new Map(), + targetPortId, + } + } + + private startOutsideInRouteSearch(routeId: RouteId): boolean { + const startPortId = this.getRouteStartPortId(routeId) + const endPortId = this.getRouteEndPortId(routeId) + const startRegionId = this.getStartingNextRegionId(routeId, startPortId) + const endRegionId = this.getEndingNextRegionId(routeId, endPortId) + if (startRegionId === undefined || endRegionId === undefined) return false + + this.state.goalPortId = endPortId + this.state.candidateQueue.clear() + this.resetCandidateBestCosts() + this.outsideInRouteSearch = { + routeId, + forward: this.createOutsideInFrontier( + startPortId, + startRegionId, + endPortId, + ), + reverse: this.createOutsideInFrontier( + endPortId, + endRegionId, + startPortId, + ), + expandForwardNext: true, + distanceLimitHit: false, + bestJoinedCost: Number.POSITIVE_INFINITY, + } + this.outsideInRouteCount += 1 + this.publishOutsideInStats() + return true + } + + private dequeueFreshCandidate( + frontier: OutsideInFrontier, + ): OutsideInCandidate | undefined { + while (frontier.queue.length > 0) { + const candidate = frontier.queue.dequeue()! + const hopId = this.getHopId(candidate.portId, candidate.nextRegionId) + if (candidate.g <= (frontier.bestCostByHopId.get(hopId) ?? Infinity)) { + return candidate + } + } + return undefined + } + + private recordSettledCandidate( + frontier: OutsideInFrontier, + candidate: OutsideInCandidate, + ): void { + const settledAtPort = frontier.settledByPortId.get(candidate.portId) + if (!settledAtPort || candidate.g < settledAtPort.g) { + frontier.settledByPortId.set(candidate.portId, candidate) + } + + const settledInRegion = + frontier.settledByRegionId.get(candidate.nextRegionId) ?? [] + settledInRegion.push(candidate) + settledInRegion.sort((left, right) => left.g - right.g) + if (settledInRegion.length > 16) settledInRegion.length = 16 + frontier.settledByRegionId.set(candidate.nextRegionId, settledInRegion) + } + + private getCandidatePath( + candidate: OutsideInCandidate, + ): OutsideInCandidate[] { + const path: OutsideInCandidate[] = [] + let cursor: Candidate | undefined = candidate + while (cursor) { + path.push(cursor as OutsideInCandidate) + cursor = cursor.prevCandidate + } + path.reverse() + return path + } + + private buildJoinedCandidate( + forwardCandidate: OutsideInCandidate, + reverseCandidate: OutsideInCandidate, + ): JoinedOutsideInCandidate | undefined { + const forwardPath = this.getCandidatePath(forwardCandidate) + const reversePath = this.getCandidatePath(reverseCandidate) + const portIds = forwardPath.map(({ portId }) => portId) + const regionIds = forwardPath + .slice(0, -1) + .map(({ nextRegionId }) => nextRegionId) + + if (forwardCandidate.portId !== reverseCandidate.portId) { + if (forwardCandidate.nextRegionId !== reverseCandidate.nextRegionId) { + return undefined + } + const connectorDistance = Math.hypot( + this.topology.portX[forwardCandidate.portId]! - + this.topology.portX[reverseCandidate.portId]!, + this.topology.portY[forwardCandidate.portId]! - + this.topology.portY[reverseCandidate.portId]!, + ) + if ( + forwardCandidate.travelDistance + + reverseCandidate.travelDistance + + connectorDistance > + this.OUTSIDE_IN_MAX_DISTANCE * 2 + ) { + return undefined + } + const connectorG = this.computeG( + forwardCandidate, + reverseCandidate.portId, + ) + if (!Number.isFinite(connectorG)) return undefined + regionIds.push(forwardCandidate.nextRegionId) + portIds.push(reverseCandidate.portId) + } + + for ( + let reverseIndex = reversePath.length - 2; + reverseIndex >= 0; + reverseIndex-- + ) { + const nextTowardGoal = reversePath[reverseIndex]! + regionIds.push(nextTowardGoal.nextRegionId) + portIds.push(nextTowardGoal.portId) + } + + if ( + new Set(portIds).size !== portIds.length || + regionIds.length + 1 !== portIds.length + ) { + return undefined + } + + let joinedCandidate: Candidate = { + portId: portIds[0]!, + nextRegionId: regionIds[0]!, + f: 0, + g: 0, + h: 0, + } + for (let portIndex = 1; portIndex < portIds.length; portIndex++) { + joinedCandidate = { + portId: portIds[portIndex]!, + prevRegionId: regionIds[portIndex - 1]!, + nextRegionId: regionIds[portIndex] ?? regionIds[portIndex - 1]!, + prevCandidate: joinedCandidate, + f: 0, + g: 0, + h: 0, + } + } + + const connectorCost = + forwardCandidate.portId === reverseCandidate.portId + ? 0 + : this.computeG(forwardCandidate, reverseCandidate.portId) - + forwardCandidate.g + + return { + candidate: joinedCandidate, + cost: forwardCandidate.g + reverseCandidate.g + connectorCost, + } + } + + private considerOutsideInJoins( + candidate: OutsideInCandidate, + expandingForward: boolean, + ): void { + const search = this.outsideInRouteSearch! + const oppositeFrontier = expandingForward ? search.reverse : search.forward + const considerJoinedCandidate = ( + joinedCandidate: JoinedOutsideInCandidate | undefined, + ) => { + if (!joinedCandidate || joinedCandidate.cost >= search.bestJoinedCost) { + return + } + search.bestJoinedCandidate = joinedCandidate.candidate + search.bestJoinedCost = joinedCandidate.cost + search.remainingPostMeetingExpansions = 24 + } + + const samePortCandidate = oppositeFrontier.settledByPortId.get( + candidate.portId, + ) + if (samePortCandidate) { + considerJoinedCandidate( + expandingForward + ? this.buildJoinedCandidate(candidate, samePortCandidate) + : this.buildJoinedCandidate(samePortCandidate, candidate), + ) + } + + for (const oppositeCandidate of oppositeFrontier.settledByRegionId.get( + candidate.nextRegionId, + ) ?? []) { + considerJoinedCandidate( + expandingForward + ? this.buildJoinedCandidate(candidate, oppositeCandidate) + : this.buildJoinedCandidate(oppositeCandidate, candidate), + ) + } + } + + private commitBestOutsideInJoin(): boolean { + const joinedCandidate = this.outsideInRouteSearch?.bestJoinedCandidate + if (!joinedCandidate) return false + this.onPathFound(joinedCandidate) + return true + } + + private expandOutsideInFrontier(expandingForward: boolean): boolean { + const search = this.outsideInRouteSearch! + const frontier = expandingForward ? search.forward : search.reverse + const candidate = this.dequeueFreshCandidate(frontier) + if (!candidate) return false + + if (expandingForward) this.outsideInForwardExpansionCount += 1 + else this.outsideInReverseExpansionCount += 1 + + if (this.isRegionReservedForDifferentNet(candidate.nextRegionId)) { + return true + } + + this.recordSettledCandidate(frontier, candidate) + this.considerOutsideInJoins(candidate, expandingForward) + + for (const neighborPortId of this.topology.regionIncidentPorts[ + candidate.nextRegionId + ] ?? []) { + if (neighborPortId === candidate.portId) continue + if (this.isPortReservedForDifferentNet(neighborPortId)) continue + const assignedNetId = this.state.portAssignment[neighborPortId]! + if ( + assignedNetId !== -1 && + assignedNetId !== this.state.currentRouteNetId + ) { + continue + } + if ( + neighborPortId !== frontier.targetPortId && + this.problem.portSectionMask[neighborPortId] === 0 + ) { + continue + } + + const segmentDistance = Math.hypot( + this.topology.portX[candidate.portId]! - + this.topology.portX[neighborPortId]!, + this.topology.portY[candidate.portId]! - + this.topology.portY[neighborPortId]!, + ) + const travelDistance = candidate.travelDistance + segmentDistance + if (travelDistance > this.OUTSIDE_IN_MAX_DISTANCE) { + search.distanceLimitHit = true + this.outsideInDistancePruneCount += 1 + continue + } + + const g = this.computeG(candidate, neighborPortId) + if (!Number.isFinite(g)) continue + const nextRegionId = + this.topology.incidentPortRegion[neighborPortId]?.[0] === + candidate.nextRegionId + ? this.topology.incidentPortRegion[neighborPortId]?.[1] + : this.topology.incidentPortRegion[neighborPortId]?.[0] + if ( + nextRegionId === undefined || + this.isRegionReservedForDifferentNet(nextRegionId) + ) { + continue + } + + const hopId = this.getHopId(neighborPortId, nextRegionId) + if (g >= (frontier.bestCostByHopId.get(hopId) ?? Infinity)) continue + frontier.bestCostByHopId.set(hopId, g) + const h = + Math.hypot( + this.topology.portX[neighborPortId]! - + this.topology.portX[frontier.targetPortId]!, + this.topology.portY[neighborPortId]! - + this.topology.portY[frontier.targetPortId]!, + ) * this.DISTANCE_TO_COST + frontier.queue.queue({ + portId: neighborPortId, + prevRegionId: candidate.nextRegionId, + nextRegionId, + prevCandidate: candidate, + f: g + h, + g, + h, + travelDistance, + }) + } + + return true + } + + private fallBackToOneSidedRouteSearch(): void { + const routeId = this.state.currentRouteId + if (routeId === undefined) return + this.outsideInRouteSearch = undefined + this.outsideInFallbackRouteCount += 1 + this.oneSidedFallbackRouteId = routeId + this.state.currentRouteId = undefined + this.state.currentRouteNetId = undefined + this.state.goalPortId = -1 + this.state.candidateQueue.clear() + this.resetCandidateBestCosts() + this.state.unroutedRoutes.unshift(routeId) + this.publishOutsideInStats() + } + + override _step(): void { + if (!this.OUTSIDE_IN_ROUTING) { + super._step() + return + } + + const routeIdToAdvance = + this.state.currentRouteId ?? this.state.unroutedRoutes[0] + if ( + routeIdToAdvance !== undefined && + !this.partialRipRoutePlans.has(routeIdToAdvance) + ) { + super._step() + return + } + + if (this.oneSidedFallbackRouteId !== undefined) { + super._step() + return + } + + if (this.state.currentRouteId === undefined) { + if (this.state.unroutedRoutes.length === 0) { + this.onAllRoutesRouted() + return + } + + const routeId = this.state.unroutedRoutes.shift()! + this.state.currentRouteId = routeId + this.state.currentRouteNetId = this.problem.routeNet[routeId]! + this.routeAttemptCountByRouteId[routeId] += 1 + if (!this.startOutsideInRouteSearch(routeId)) { + this.failed = true + this.error = `Route ${routeId} has an endpoint without an incident region` + return + } + } + + const search = this.outsideInRouteSearch + if (!search) return + + let expanded = this.expandOutsideInFrontier(search.expandForwardNext) + if (this.state.currentRouteId === undefined) return + if (!expanded) { + expanded = this.expandOutsideInFrontier(!search.expandForwardNext) + if (this.state.currentRouteId === undefined) return + } + search.expandForwardNext = !search.expandForwardNext + + if (search.bestJoinedCandidate) { + search.remainingPostMeetingExpansions = + (search.remainingPostMeetingExpansions ?? 1) - 1 + if ((search.remainingPostMeetingExpansions ?? 0) <= 0) { + this.commitBestOutsideInJoin() + return + } + } + + if ( + !expanded && + search.forward.queue.length === 0 && + search.reverse.queue.length === 0 + ) { + if (this.commitBestOutsideInJoin()) return + this.outsideInRouteSearch = undefined + if (search.distanceLimitHit) { + this.fallBackToOneSidedRouteSearch() + } else { + this.onOutOfCandidates() + } + } + + this.publishOutsideInStats() + } + + /** + * Serialization replays segments route-by-route. Keep the live cost cache in + * that same deterministic order before deciding whether a partial result is + * better. This also avoids the boundary-angle counter's legacy shared-port + * tie behavior producing a different score after round-tripping the output. + */ + private rebuildIntersectionCachesInCanonicalRouteOrder(): void { + this.state.regionIntersectionCaches = Array.from( + { length: this.topology.regionCount }, + () => createEmptyRegionIntersectionCache(), + ) + + for ( + let regionId = 0; + regionId < this.state.regionSegments.length; + regionId++ + ) { + const segments = this.state.regionSegments[regionId]! + segments.sort((left, right) => left[0] - right[0]) + for (const [routeId, fromPortId, toPortId] of segments) { + this.state.currentRouteNetId = this.problem.routeNet[routeId]! + this.appendSegmentToRegionCache(regionId, fromPortId, toPortId) + } + } + this.state.currentRouteNetId = undefined + } + + override onAllRoutesRouted(): void { + const { state, topology } = this + const maxRipAttempts = Math.min( + this.RIP_THRESHOLD_RAMP_ATTEMPTS, + this.PARTIAL_RIP_MAX_ATTEMPTS, + ) + this.rebuildIntersectionCachesInCanonicalRouteOrder() + const ripThresholdProgress = + maxRipAttempts <= 0 ? 1 : Math.min(1, state.ripCount / maxRipAttempts) + const currentRipThreshold = + this.RIP_THRESHOLD_START + + (this.RIP_THRESHOLD_END - this.RIP_THRESHOLD_START) * ripThresholdProgress + const regionCosts = new Float64Array(topology.regionCount) + const hotRegionIds: RegionId[] = [] + let maxRegionCost = 0 + let totalRegionCost = 0 + + for (let regionId = 0; regionId < topology.regionCount; regionId++) { + const regionCost = + state.regionIntersectionCaches[regionId]?.existingRegionCost ?? 0 + regionCosts[regionId] = regionCost + maxRegionCost = Math.max(maxRegionCost, regionCost) + totalRegionCost += regionCost + if (regionCost > currentRipThreshold) hotRegionIds.push(regionId) + } + + const summary: RegionCostSummary = { maxRegionCost, totalRegionCost } + this.captureBestSolvedState(summary) + this.stats = { + ...this.stats, + currentRipThreshold, + hotRegionCount: hotRegionIds.length, + maxRegionCost, + totalRegionCost, + bestMaxRegionCost: this.bestSolvedStateSummary?.maxRegionCost, + bestTotalRegionCost: this.bestSolvedStateSummary?.totalRegionCost, + ripCount: state.ripCount, + } + this.publishPartialRipStats() + + if (hotRegionIds.length === 0 || state.ripCount >= maxRipAttempts) { + this.restoreBestSolvedState() + this.solved = true + return + } + + for (let regionId = 0; regionId < topology.regionCount; regionId++) { + state.regionCongestionCost[regionId] += + regionCosts[regionId]! * this.RIP_CONGESTION_REGION_COST_FACTOR + } + + state.ripCount += 1 + const usedPartialRip = this.preparePartialRip(hotRegionIds, regionCosts) + if (!usedPartialRip) { + this.resetRoutingStateForRerip() + } + this.stats = { + ...this.stats, + ripCount: state.ripCount, + maxRegionCostBeforeRip: maxRegionCost, + reripRegionCount: hotRegionIds.length, + reripMode: usedPartialRip ? "partial" : "full", + } + this.logRipEvent("hot_regions", maxRegionCost, { + hotRegionCount: hotRegionIds.length, + currentRipThreshold, + reripMode: usedPartialRip ? "partial" : "full", + partialRouteCount: usedPartialRip ? this.partialRipRoutePlans.size : 0, + }) + } +} diff --git a/lib/selective-rerip-tiny-hyper-graph-solver.ts b/lib/selective-rerip-tiny-hyper-graph-solver.ts index 64a7f2c..a297c00 100644 --- a/lib/selective-rerip-tiny-hyper-graph-solver.ts +++ b/lib/selective-rerip-tiny-hyper-graph-solver.ts @@ -4,7 +4,7 @@ import { type TinyHyperGraphSolverOptions, type TinyHyperGraphTopology, } from "./core" -import { DistanceAwareTinyHyperGraphSolver } from "./distance-aware-tiny-hypergraph-solver" +import { OutsideInPartialRipTinyHyperGraphSolver } from "./outside-in-partial-rip-tiny-hypergraph-solver" import { findDistinctOwnerBlockerPath, type DistinctOwnerBlockerSearchResult, @@ -146,7 +146,7 @@ export function orderRoutesAfterSelectiveRerip(params: { * full rerip with a minimal, explicit rerip when the exhausted route has a * known set of committed blockers. */ -export class SelectiveReripTinyHyperGraphSolver extends DistanceAwareTinyHyperGraphSolver { +export class SelectiveReripTinyHyperGraphSolver extends OutsideInPartialRipTinyHyperGraphSolver { private readonly failedOwnerPairCounts = new Map< RouteId, Map @@ -258,6 +258,7 @@ export class SelectiveReripTinyHyperGraphSolver extends DistanceAwareTinyHyperGr directOwnerRouteIds, alternateOwnerRouteIds, }) + this.clearPartialRipPlans(rippedRouteIds) const alternateOnlyOwnerRouteIds = (alternateOwnerRouteIds ?? []).filter( (ownerRouteId) => !directPath.owners.has(ownerRouteId), ) @@ -322,8 +323,8 @@ export class SelectiveReripTinyHyperGraphSolver extends DistanceAwareTinyHyperGr ) } - const startPortId = this.problem.routeStartPort[routeId]! - const goalPortId = this.problem.routeEndPort[routeId]! + const startPortId = this.getRouteStartPortId(routeId) + const goalPortId = this.getRouteEndPortId(routeId) const startRegionId = this.getStartingNextRegionId(routeId, startPortId) if (startRegionId === undefined) { throw new Error( diff --git a/tests/outside-in-partial-rip-tiny-hypergraph-solver.test.ts b/tests/outside-in-partial-rip-tiny-hypergraph-solver.test.ts new file mode 100644 index 0000000..0658018 --- /dev/null +++ b/tests/outside-in-partial-rip-tiny-hypergraph-solver.test.ts @@ -0,0 +1,130 @@ +import { expect, test } from "bun:test" +import { + OutsideInPartialRipTinyHyperGraphSolver, + type TinyHyperGraphProblem, + type TinyHyperGraphTopology, +} from "lib/index" +import type { PortId, RegionId, RouteId } from "lib/types" + +class TestOutsideInPartialRipSolver extends OutsideInPartialRipTinyHyperGraphSolver { + prepare(hotRegionIds: RegionId[], regionCosts: Float64Array): boolean { + return this.preparePartialRip(hotRegionIds, regionCosts) + } + + getActiveEndpoints(routeId: RouteId): [PortId, PortId] { + return [this.getRouteStartPortId(routeId), this.getRouteEndPortId(routeId)] + } +} + +const createLinearSolver = (outsideInMaxDistance = 24) => { + const topology: TinyHyperGraphTopology = { + portCount: 5, + regionCount: 6, + regionIncidentPorts: [[0], [0, 1], [1, 2], [2, 3], [3, 4], [4]], + incidentPortRegion: [ + [1, 0], + [1, 2], + [2, 3], + [3, 4], + [4, 5], + ], + regionWidth: new Float64Array(6).fill(100), + regionHeight: new Float64Array(6).fill(100), + regionCenterX: new Float64Array([0, 2.5, 7.5, 12.5, 17.5, 20]), + regionCenterY: new Float64Array(6), + portAngleForRegion1: new Int32Array([18000, 18000, 18000, 18000, 18000]), + portAngleForRegion2: new Int32Array([0, 0, 0, 0, 0]), + portX: new Float64Array([0, 5, 10, 15, 20]), + portY: new Float64Array(5), + portZ: new Int32Array(5), + } + const problem: TinyHyperGraphProblem = { + routeCount: 1, + portSectionMask: new Int8Array(5).fill(1), + routeStartPort: new Int32Array([0]), + routeEndPort: new Int32Array([4]), + routeNet: new Int32Array([0]), + regionNetId: new Int32Array(6).fill(-1), + } + const solver = new TestOutsideInPartialRipSolver(topology, problem, { + PARTIAL_RIP_MAX_DISTANCE: 3, + PARTIAL_RIP_MAX_ATTEMPTS: 1, + OUTSIDE_IN_MAX_DISTANCE: outsideInMaxDistance, + STATIC_REACHABILITY_PRECHECK: false, + }) + + solver.state.portAssignment.fill(0) + solver.state.unroutedRoutes = [] + solver.state.regionSegments[1] = [[0, 0, 1]] + solver.state.regionSegments[2] = [[0, 1, 2]] + solver.state.regionSegments[3] = [[0, 2, 3]] + solver.state.regionSegments[4] = [[0, 3, 4]] + return solver +} + +test("partial rip preserves both outside route ends", () => { + const solver = createLinearSolver() + const regionCosts = new Float64Array(6) + regionCosts[3] = 1 + + expect(solver.prepare([3], regionCosts)).toBe(true) + expect(solver.getActiveEndpoints(0)).toEqual([2, 3]) + expect(solver.getStartingNextRegionId(0, 2)).toBe(3) + expect(solver.state.unroutedRoutes).toEqual([0]) + expect(solver.state.regionSegments.flat()).toEqual([ + [0, 0, 1], + [0, 1, 2], + [0, 3, 4], + ]) + expect(solver.stats.partiallyRippedSegmentCount).toBe(1) + expect(solver.stats.retainedPartialRipSegmentCount).toBe(3) +}) + +test("a near-target initial solution selects the larger quality window", () => { + const nearTargetSolver = createLinearSolver() + nearTargetSolver.PARTIAL_RIP_QUALITY_MAX_DISTANCE = 8 + const nearTargetCosts = new Float64Array(6) + nearTargetCosts[3] = 1 + + expect(nearTargetSolver.prepare([3], nearTargetCosts)).toBe(true) + expect(nearTargetSolver.stats.partialRipMaxDistance).toBe(8) + + const congestedSolver = createLinearSolver() + congestedSolver.PARTIAL_RIP_QUALITY_MAX_DISTANCE = 8 + const congestedCosts = new Float64Array(6) + congestedCosts[3] = 2 + + expect(congestedSolver.prepare([3], congestedCosts)).toBe(true) + expect(congestedSolver.stats.partialRipMaxDistance).toBe(3) +}) + +test("outside-in routing reconnects a partial span from both retained ends", () => { + const solver = createLinearSolver() + const regionCosts = new Float64Array(6) + regionCosts[3] = 1 + solver.prepare([3], regionCosts) + + solver.solve() + + expect(solver.solved).toBe(true) + expect(solver.failed).toBe(false) + expect(solver.stats.outsideInCompletedRouteCount).toBe(1) + expect(solver.stats.outsideInForwardExpansionCount).toBeGreaterThan(0) + expect(solver.stats.outsideInReverseExpansionCount).toBeGreaterThan(0) + expect(solver.state.regionSegments.flat()).toHaveLength(4) + expect(solver.getOutput().solvedRoutes?.[0]?.path).toHaveLength(5) +}) + +test("a span beyond the two-frontier distance budget falls back safely", () => { + const solver = createLinearSolver(2) + const regionCosts = new Float64Array(6) + regionCosts[3] = 1 + solver.prepare([3], regionCosts) + + solver.solve() + + expect(solver.solved).toBe(true) + expect(solver.failed).toBe(false) + expect(solver.stats.outsideInFallbackRouteCount).toBe(1) + expect(solver.state.regionSegments.flat()).toHaveLength(4) +}) diff --git a/tests/solver/on-all-routes-routed.test.ts b/tests/solver/on-all-routes-routed.test.ts index 16431f3..dc9db0f 100644 --- a/tests/solver/on-all-routes-routed.test.ts +++ b/tests/solver/on-all-routes-routed.test.ts @@ -259,6 +259,12 @@ test("constructor options override snake-case hyperparameters before setup", () MAX_ITERATIONS: 1234, ACCEPT_BEST_SOLUTION_ON_TIMEOUT: false, GREEDY_FINAL_ROUTE_ITERS: 6, + PARTIAL_RIP_ENABLED: true, + PARTIAL_RIP_MAX_DISTANCE: 9, + PARTIAL_RIP_QUALITY_MAX_DISTANCE: 16, + PARTIAL_RIP_MAX_ATTEMPTS: 3, + OUTSIDE_IN_ROUTING: true, + OUTSIDE_IN_MAX_DISTANCE: 18, }) expect(solver.DISTANCE_TO_COST).toBe(0.25) @@ -269,5 +275,11 @@ test("constructor options override snake-case hyperparameters before setup", () expect(solver.MAX_ITERATIONS).toBe(1234) expect(solver.ACCEPT_BEST_SOLUTION_ON_TIMEOUT).toBe(false) expect(solver.GREEDY_FINAL_ROUTE_ITERS).toBe(6) + expect(solver.PARTIAL_RIP_ENABLED).toBe(true) + expect(solver.PARTIAL_RIP_MAX_DISTANCE).toBe(9) + expect(solver.PARTIAL_RIP_QUALITY_MAX_DISTANCE).toBe(16) + expect(solver.PARTIAL_RIP_MAX_ATTEMPTS).toBe(3) + expect(solver.OUTSIDE_IN_ROUTING).toBe(true) + expect(solver.OUTSIDE_IN_MAX_DISTANCE).toBe(18) expect(solver.problemSetup.portHCostToEndOfRoute[0]).toBe(0.25) }) From cd081b5660148d4ab51ac2d292ba2a6c2832a681 Mon Sep 17 00:00:00 2001 From: seveibar Date: Mon, 10 Aug 2026 00:08:00 -0700 Subject: [PATCH 2/6] Tune partial-rip routing across graph scales --- experiments/outside-in-partial-rip.md | 91 +++++++++ lib/core.ts | 65 ++++++ ...e-in-partial-rip-tiny-hypergraph-solver.ts | 186 +++++++++++++++--- ...partial-rip-tiny-hypergraph-solver.test.ts | 60 +++++- 4 files changed, 368 insertions(+), 34 deletions(-) diff --git a/experiments/outside-in-partial-rip.md b/experiments/outside-in-partial-rip.md index ed6cac1..891bf8e 100644 --- a/experiments/outside-in-partial-rip.md +++ b/experiments/outside-in-partial-rip.md @@ -227,3 +227,94 @@ Verification: `bun run typecheck`, `bun run build`, `git diff --check`, and all 101 non-image-snapshot tests pass. As noted in Trial 8, the three remaining snapshot files cannot import their pre-existing optional Sharp native binary in this environment; they fail before any solver code runs. + +## Trial 10 - end-to-end topology canary and live-cache scoring + +The package benchmark alone hid an important downstream effect. On SRJ18 +sample005, the original implementation reduced solve-graph time from 8.08 s to +1.58 s, but increased `HighDensitySolver` work from 27,363 to 53,632 iterations +and end-to-end time from 26.58 s to 35.80 s. + +Diagnostics with zero, one, and two partial rounds all produced the same +53,632-iteration detailed route. Removing canonical route-order cache replay +and retaining the live candidate costs exposed a useful ten-round progression: + +- round 0: max cost 0.797, 1,129 segments; +- round 1: max cost 0.923, 1,029 segments; +- round 2: max cost 0.960, 992 segments; +- round 3: max cost 0.766, 988 segments. + +The temporary cost valley is necessary. Restoring round three reduced the +direct end-to-end canary to 15.78 s and solve-graph time to 1.17 s. Fixed caps +were rejected because they also changed the rip-threshold ramp; candidate +selection and stopping must remain separate. + +## Trial 11 - cross-dataset global reseed count + +SRJ19 samples 12,13,35,42,46,48,54,55,79,81,83,97,98,99,100 were used as a +regression-heavy set with four workers and a deliberately short 90 s cap. + +- main: 12/15 complete, 0/15 relaxed DRC, 67.8 s solved-case P50; +- partial only: 6/15 complete, 4/15 DRC, 25.5 s P50; +- one whole-graph warmup then partial: 8/15 complete, 5/15 DRC, 23.8 s P50; +- two whole-graph warmups then partial: 8/15 complete, 2/15 DRC, 25.1 s P50. + +One global warmup was accepted. It gives partial routing a different topology +basin without repeatedly discarding every completed trace. A second warmup +loses three DRC passes. + +## Trial 12 - density-aware candidate selection (rejected as a selector) + +Peak regional segment count and sum-of-squared regional segment count correlate +with detailed-routing difficulty. A strict density guard recovered SRJ19 +sample083 at the 90 s cap, but changed sample079 from a 5.1 s DRC pass into a +13.1 s DRC failure. Squared-density-first selection improved the SRJ19 stress +set to 6/15 DRC and a 22.0 s P50, but reduced SRJ20 DRC. + +On the SRJ20 stress set (samples 6,10,13,14,20,28,29,30,35,38,41,53,62,63,75): + +- main: 11/15 complete, 3/15 DRC, 12.5 s P50; +- region-cost-first partial routing: 11/15 complete, 5/15 DRC, 23.1 s P50; +- squared-density-first full horizon: 11/15 complete, 3/15 DRC, 23.2 s P50. + +Density metrics remain in solver and benchmark telemetry, but final candidate +selection is region-cost-first on medium graphs. The result is a 54.3% average +max-cost improvement from the first completed SRJ20 solution (6.736 to 3.079). + +## Trial 13 - scale-aware policy and preloaded guard + +A single selection policy was not universal. The accepted integration uses +three regimes: + +- fewer than 20 routes: use the established solver unchanged; +- 20-99 routes: one global warmup, then ten bounded partial rounds, restoring + the best region-cost state; +- at least 100 routes: allow segment count to break ties inside a 20% max-cost + and 10% total-cost envelope, with an optional 2% quality target. + +The large-graph mode reproduces the better SRJ18 downstream topology: all eight +expected completion cases solve, 4/8 pass relaxed DRC, P50 is 24.4 s, and +sample016 is recovered. Across those cases, max region cost improves 39.7% from +the first completed state. On seven cases shared with main, every case is +faster and the paired median is approximately 1.50x faster. + +Small dataset01 cases remain neutral. SRJ21 (8-16 routes) exactly reproduces +main at 10/10 completion and 9/10 DRC. Representative SRJ23 preloaded cases also +remain on the established behavior: serialized trace occupancy explicitly +disables partial rip and outside-in reconnection. + +## Final cross-dataset verification + +Command: `./benchmark.sh` + +- Success: 8/8 cases, 2,001/2,001 routes. +- Total completion time: 21.773 s (4.38x faster than main's 95.441 s). +- Average maximum region cost: 1.739 (25.5% better than main's 2.333). +- P50 duration: 1.611 s; P95 duration: 7.660 s. +- Average solver iterations: 329,201.4 (75.4% fewer than main). + +Verification: `bun run typecheck`, `bun run build`, the seven focused +outside-in/partial-rip tests, and `git diff --check` pass. The full suite runs +104 passing assertions; its three image-test modules still fail to import the +workspace's missing optional Sharp Darwin ARM64 binary before their assertions +execute. diff --git a/lib/core.ts b/lib/core.ts index 34b0566..c2b37e5 100644 --- a/lib/core.ts +++ b/lib/core.ts @@ -256,12 +256,27 @@ export interface TinyHyperGraphSolverOptions { GREEDY_FINAL_ROUTE_ITERS?: number /** Preserve route prefixes/suffixes and reopen only a bounded hot span. */ PARTIAL_RIP_ENABLED?: boolean + /** Minimum route count required to enable partial-rip optimization. */ + PARTIAL_RIP_MIN_ROUTE_COUNT?: number /** Maximum old-route distance reopened on either side of a hot segment. */ PARTIAL_RIP_MAX_DISTANCE?: number /** Larger partial-rip window used when the initial solution is near target. */ PARTIAL_RIP_QUALITY_MAX_DISTANCE?: number /** Maximum completed partial-rip rounds before restoring the best state. */ PARTIAL_RIP_MAX_ATTEMPTS?: number + /** Whole-graph reseeds allowed before subsequent hot-region partial rips. */ + PARTIAL_RIP_WARMUP_FULL_RIP_ATTEMPTS?: number + /** + * Minimum route count before route complexity may break region-cost ties + * inside the configured quality envelope. + */ + PARTIAL_RIP_COMPLEXITY_SELECTION_MIN_ROUTE_COUNT?: number + /** Stop after improving the initial max region cost by this fraction. */ + PARTIAL_RIP_TARGET_MAX_COST_IMPROVEMENT_RATIO?: number + /** Max region-cost growth allowed while preferring a simpler route state. */ + PARTIAL_RIP_MAX_REGION_COST_GROWTH_RATIO?: number + /** Maximum total region-cost growth allowed for an early-stop candidate. */ + PARTIAL_RIP_MAX_TOTAL_COST_GROWTH_RATIO?: number /** Search from both active route ends instead of only the start end. */ OUTSIDE_IN_ROUTING?: boolean /** Maximum geometric distance explored by either outside-in frontier. */ @@ -284,9 +299,15 @@ export interface TinyHyperGraphSolverOptionTarget { ACCEPT_BEST_SOLUTION_ON_TIMEOUT: boolean GREEDY_FINAL_ROUTE_ITERS: number PARTIAL_RIP_ENABLED?: boolean + PARTIAL_RIP_MIN_ROUTE_COUNT?: number PARTIAL_RIP_MAX_DISTANCE?: number PARTIAL_RIP_QUALITY_MAX_DISTANCE?: number PARTIAL_RIP_MAX_ATTEMPTS?: number + PARTIAL_RIP_WARMUP_FULL_RIP_ATTEMPTS?: number + PARTIAL_RIP_COMPLEXITY_SELECTION_MIN_ROUTE_COUNT?: number + PARTIAL_RIP_TARGET_MAX_COST_IMPROVEMENT_RATIO?: number + PARTIAL_RIP_MAX_REGION_COST_GROWTH_RATIO?: number + PARTIAL_RIP_MAX_TOTAL_COST_GROWTH_RATIO?: number OUTSIDE_IN_ROUTING?: boolean OUTSIDE_IN_MAX_DISTANCE?: number } @@ -347,6 +368,9 @@ export const applyTinyHyperGraphSolverOptions = ( if (options.PARTIAL_RIP_ENABLED !== undefined) { solver.PARTIAL_RIP_ENABLED = options.PARTIAL_RIP_ENABLED } + if (options.PARTIAL_RIP_MIN_ROUTE_COUNT !== undefined) { + solver.PARTIAL_RIP_MIN_ROUTE_COUNT = options.PARTIAL_RIP_MIN_ROUTE_COUNT + } if (options.PARTIAL_RIP_MAX_DISTANCE !== undefined) { solver.PARTIAL_RIP_MAX_DISTANCE = options.PARTIAL_RIP_MAX_DISTANCE } @@ -357,6 +381,26 @@ export const applyTinyHyperGraphSolverOptions = ( if (options.PARTIAL_RIP_MAX_ATTEMPTS !== undefined) { solver.PARTIAL_RIP_MAX_ATTEMPTS = options.PARTIAL_RIP_MAX_ATTEMPTS } + if (options.PARTIAL_RIP_WARMUP_FULL_RIP_ATTEMPTS !== undefined) { + solver.PARTIAL_RIP_WARMUP_FULL_RIP_ATTEMPTS = + options.PARTIAL_RIP_WARMUP_FULL_RIP_ATTEMPTS + } + if (options.PARTIAL_RIP_COMPLEXITY_SELECTION_MIN_ROUTE_COUNT !== undefined) { + solver.PARTIAL_RIP_COMPLEXITY_SELECTION_MIN_ROUTE_COUNT = + options.PARTIAL_RIP_COMPLEXITY_SELECTION_MIN_ROUTE_COUNT + } + if (options.PARTIAL_RIP_TARGET_MAX_COST_IMPROVEMENT_RATIO !== undefined) { + solver.PARTIAL_RIP_TARGET_MAX_COST_IMPROVEMENT_RATIO = + options.PARTIAL_RIP_TARGET_MAX_COST_IMPROVEMENT_RATIO + } + if (options.PARTIAL_RIP_MAX_REGION_COST_GROWTH_RATIO !== undefined) { + solver.PARTIAL_RIP_MAX_REGION_COST_GROWTH_RATIO = + options.PARTIAL_RIP_MAX_REGION_COST_GROWTH_RATIO + } + if (options.PARTIAL_RIP_MAX_TOTAL_COST_GROWTH_RATIO !== undefined) { + solver.PARTIAL_RIP_MAX_TOTAL_COST_GROWTH_RATIO = + options.PARTIAL_RIP_MAX_TOTAL_COST_GROWTH_RATIO + } if (options.OUTSIDE_IN_ROUTING !== undefined) { solver.OUTSIDE_IN_ROUTING = options.OUTSIDE_IN_ROUTING } @@ -384,9 +428,20 @@ export const getTinyHyperGraphSolverOptions = ( ACCEPT_BEST_SOLUTION_ON_TIMEOUT: solver.ACCEPT_BEST_SOLUTION_ON_TIMEOUT, GREEDY_FINAL_ROUTE_ITERS: solver.GREEDY_FINAL_ROUTE_ITERS, PARTIAL_RIP_ENABLED: solver.PARTIAL_RIP_ENABLED, + PARTIAL_RIP_MIN_ROUTE_COUNT: solver.PARTIAL_RIP_MIN_ROUTE_COUNT, PARTIAL_RIP_MAX_DISTANCE: solver.PARTIAL_RIP_MAX_DISTANCE, PARTIAL_RIP_QUALITY_MAX_DISTANCE: solver.PARTIAL_RIP_QUALITY_MAX_DISTANCE, PARTIAL_RIP_MAX_ATTEMPTS: solver.PARTIAL_RIP_MAX_ATTEMPTS, + PARTIAL_RIP_WARMUP_FULL_RIP_ATTEMPTS: + solver.PARTIAL_RIP_WARMUP_FULL_RIP_ATTEMPTS, + PARTIAL_RIP_COMPLEXITY_SELECTION_MIN_ROUTE_COUNT: + solver.PARTIAL_RIP_COMPLEXITY_SELECTION_MIN_ROUTE_COUNT, + PARTIAL_RIP_TARGET_MAX_COST_IMPROVEMENT_RATIO: + solver.PARTIAL_RIP_TARGET_MAX_COST_IMPROVEMENT_RATIO, + PARTIAL_RIP_MAX_REGION_COST_GROWTH_RATIO: + solver.PARTIAL_RIP_MAX_REGION_COST_GROWTH_RATIO, + PARTIAL_RIP_MAX_TOTAL_COST_GROWTH_RATIO: + solver.PARTIAL_RIP_MAX_TOTAL_COST_GROWTH_RATIO, OUTSIDE_IN_ROUTING: solver.OUTSIDE_IN_ROUTING, OUTSIDE_IN_MAX_DISTANCE: solver.OUTSIDE_IN_MAX_DISTANCE, }) @@ -435,9 +490,15 @@ export class TinyHyperGraphSolver extends BaseSolver { ACCEPT_BEST_SOLUTION_ON_TIMEOUT = true GREEDY_FINAL_ROUTE_ITERS = 4 PARTIAL_RIP_ENABLED = false + PARTIAL_RIP_MIN_ROUTE_COUNT = 0 PARTIAL_RIP_MAX_DISTANCE = 12 PARTIAL_RIP_QUALITY_MAX_DISTANCE?: number PARTIAL_RIP_MAX_ATTEMPTS = Number.POSITIVE_INFINITY + PARTIAL_RIP_WARMUP_FULL_RIP_ATTEMPTS = 0 + PARTIAL_RIP_COMPLEXITY_SELECTION_MIN_ROUTE_COUNT = Number.POSITIVE_INFINITY + PARTIAL_RIP_TARGET_MAX_COST_IMPROVEMENT_RATIO = 0 + PARTIAL_RIP_MAX_REGION_COST_GROWTH_RATIO = 0.2 + PARTIAL_RIP_MAX_TOTAL_COST_GROWTH_RATIO = 0.1 OUTSIDE_IN_ROUTING = false OUTSIDE_IN_MAX_DISTANCE = 24 @@ -1152,6 +1213,10 @@ export class TinyHyperGraphSolver extends BaseSolver { return } + this.replaceBestSolvedState(summary) + } + + protected replaceBestSolvedState(summary: RegionCostSummary) { this.bestSolvedStateSummary = summary this.bestSolvedStateSnapshot = cloneSolvedStateSnapshot({ portAssignment: this.state.portAssignment, diff --git a/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts b/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts index 5f63d1d..70e2238 100644 --- a/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts +++ b/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts @@ -58,6 +58,13 @@ type IndexedCommittedRouteSegment = CommittedRouteSegment & { segmentIndex: number } +type CompletedRoundSummary = RegionCostSummary & { + ripCount: number + segmentCount: number + maxRegionSegmentCount: number + squaredRegionSegmentCount: number +} + /** * Retains the two outside portions of a completed route and only reroutes a * bounded window around a congested region. The active window is represented @@ -83,6 +90,12 @@ export class OutsideInPartialRipTinyHyperGraphSolver extends DistanceAwareTinyHy private outsideInForwardExpansionCount = 0 private outsideInReverseExpansionCount = 0 private outsideInDistancePruneCount = 0 + private completedRoundSummaries: CompletedRoundSummary[] = [] + private firstCompletedRoundSummary?: CompletedRoundSummary + private partialRipQualityBaselineSummary?: CompletedRoundSummary + private bestSolvedRoundSummary?: CompletedRoundSummary + private partialRipTargetReached = false + private useComplexityAwareSelection = false constructor( topology: TinyHyperGraphTopology, @@ -102,6 +115,13 @@ export class OutsideInPartialRipTinyHyperGraphSolver extends DistanceAwareTinyHy ) { this.PARTIAL_RIP_MAX_ATTEMPTS = 10 } + if (problem.routeCount < Math.max(0, this.PARTIAL_RIP_MIN_ROUTE_COUNT)) { + this.PARTIAL_RIP_ENABLED = false + this.OUTSIDE_IN_ROUTING = false + } + this.useComplexityAwareSelection = + problem.routeCount >= + Math.max(0, this.PARTIAL_RIP_COMPLEXITY_SELECTION_MIN_ROUTE_COUNT) } protected override getRouteStartPortId(routeId: RouteId): PortId { @@ -951,40 +971,51 @@ export class OutsideInPartialRipTinyHyperGraphSolver extends DistanceAwareTinyHy this.publishOutsideInStats() } - /** - * Serialization replays segments route-by-route. Keep the live cost cache in - * that same deterministic order before deciding whether a partial result is - * better. This also avoids the boundary-angle counter's legacy shared-port - * tie behavior producing a different score after round-tripping the output. - */ - private rebuildIntersectionCachesInCanonicalRouteOrder(): void { - this.state.regionIntersectionCaches = Array.from( - { length: this.topology.regionCount }, - () => createEmptyRegionIntersectionCache(), - ) + private shouldReplaceBestSolvedState( + summary: CompletedRoundSummary, + ): boolean { + const bestSummary = this.bestSolvedRoundSummary + if (!bestSummary) return true + if (!this.useComplexityAwareSelection) { + return this.compareRegionCostSummaries(summary, bestSummary) < 0 + } - for ( - let regionId = 0; - regionId < this.state.regionSegments.length; - regionId++ - ) { - const segments = this.state.regionSegments[regionId]! - segments.sort((left, right) => left[0] - right[0]) - for (const [routeId, fromPortId, toPortId] of segments) { - this.state.currentRouteNetId = this.problem.routeNet[routeId]! - this.appendSegmentToRegionCache(regionId, fromPortId, toPortId) - } + const qualityBaseline = this.partialRipQualityBaselineSummary + if (!qualityBaseline) { + return this.compareRegionCostSummaries(summary, bestSummary) < 0 } - this.state.currentRouteNetId = undefined + + const maxRegionCostCeiling = + qualityBaseline.maxRegionCost * + (1 + Math.max(0, this.PARTIAL_RIP_MAX_REGION_COST_GROWTH_RATIO)) + const totalRegionCostCeiling = + qualityBaseline.totalRegionCost * + (1 + Math.max(0, this.PARTIAL_RIP_MAX_TOTAL_COST_GROWTH_RATIO)) + const isEligible = + summary.maxRegionCost <= maxRegionCostCeiling && + summary.totalRegionCost <= totalRegionCostCeiling + const isBestEligible = + bestSummary.maxRegionCost <= maxRegionCostCeiling && + bestSummary.totalRegionCost <= totalRegionCostCeiling + + if (isEligible !== isBestEligible) return isEligible + if (isEligible && summary.segmentCount !== bestSummary.segmentCount) { + return summary.segmentCount < bestSummary.segmentCount + } + return this.compareRegionCostSummaries(summary, bestSummary) < 0 } override onAllRoutesRouted(): void { + if (!this.PARTIAL_RIP_ENABLED) { + super.onAllRoutesRouted() + return + } + const { state, topology } = this const maxRipAttempts = Math.min( this.RIP_THRESHOLD_RAMP_ATTEMPTS, this.PARTIAL_RIP_MAX_ATTEMPTS, ) - this.rebuildIntersectionCachesInCanonicalRouteOrder() const ripThresholdProgress = maxRipAttempts <= 0 ? 1 : Math.min(1, state.ripCount / maxRipAttempts) const currentRipThreshold = @@ -994,6 +1025,9 @@ export class OutsideInPartialRipTinyHyperGraphSolver extends DistanceAwareTinyHy const hotRegionIds: RegionId[] = [] let maxRegionCost = 0 let totalRegionCost = 0 + let segmentCount = 0 + let maxRegionSegmentCount = 0 + let squaredRegionSegmentCount = 0 for (let regionId = 0; regionId < topology.regionCount; regionId++) { const regionCost = @@ -1001,11 +1035,62 @@ export class OutsideInPartialRipTinyHyperGraphSolver extends DistanceAwareTinyHy regionCosts[regionId] = regionCost maxRegionCost = Math.max(maxRegionCost, regionCost) totalRegionCost += regionCost + const regionSegmentCount = state.regionSegments[regionId]?.length ?? 0 + segmentCount += regionSegmentCount + maxRegionSegmentCount = Math.max( + maxRegionSegmentCount, + regionSegmentCount, + ) + squaredRegionSegmentCount += regionSegmentCount * regionSegmentCount if (regionCost > currentRipThreshold) hotRegionIds.push(regionId) } const summary: RegionCostSummary = { maxRegionCost, totalRegionCost } - this.captureBestSolvedState(summary) + const completedRoundSummary: CompletedRoundSummary = { + ...summary, + ripCount: state.ripCount, + segmentCount, + maxRegionSegmentCount, + squaredRegionSegmentCount, + } + this.completedRoundSummaries.push(completedRoundSummary) + this.firstCompletedRoundSummary ??= completedRoundSummary + if ( + this.partialRipQualityBaselineSummary === undefined && + state.ripCount >= Math.max(0, this.PARTIAL_RIP_WARMUP_FULL_RIP_ATTEMPTS) + ) { + this.partialRipQualityBaselineSummary = completedRoundSummary + } + const shouldReplaceBest = this.shouldReplaceBestSolvedState( + completedRoundSummary, + ) + if (shouldReplaceBest) { + this.replaceBestSolvedState(summary) + this.bestSolvedRoundSummary = completedRoundSummary + } + + const firstRound = this.firstCompletedRoundSummary + const qualityBaseline = this.partialRipQualityBaselineSummary ?? firstRound + const targetImprovementRatio = Math.max( + 0, + this.PARTIAL_RIP_TARGET_MAX_COST_IMPROVEMENT_RATIO, + ) + const maxTotalCostGrowthRatio = Math.max( + 0, + this.PARTIAL_RIP_MAX_TOTAL_COST_GROWTH_RATIO, + ) + const targetMaxRegionCost = + qualityBaseline.maxRegionCost * (1 - targetImprovementRatio) + const maxTargetTotalRegionCost = + qualityBaseline.totalRegionCost * (1 + maxTotalCostGrowthRatio) + const targetReached = + this.useComplexityAwareSelection && + state.ripCount > qualityBaseline.ripCount && + targetImprovementRatio > 0 && + maxRegionCost <= targetMaxRegionCost && + totalRegionCost <= maxTargetTotalRegionCost && + segmentCount <= qualityBaseline.segmentCount + if (targetReached) this.partialRipTargetReached = true this.stats = { ...this.stats, currentRipThreshold, @@ -1015,10 +1100,43 @@ export class OutsideInPartialRipTinyHyperGraphSolver extends DistanceAwareTinyHy bestMaxRegionCost: this.bestSolvedStateSummary?.maxRegionCost, bestTotalRegionCost: this.bestSolvedStateSummary?.totalRegionCost, ripCount: state.ripCount, + completedRoundSummaries: this.completedRoundSummaries.map( + (roundSummary) => ({ ...roundSummary }), + ), + firstMaxRegionCost: firstRound.maxRegionCost, + firstTotalRegionCost: firstRound.totalRegionCost, + firstSegmentCount: firstRound.segmentCount, + firstMaxRegionSegmentCount: firstRound.maxRegionSegmentCount, + firstSquaredRegionSegmentCount: firstRound.squaredRegionSegmentCount, + partialRipQualityBaselineRipCount: qualityBaseline.ripCount, + partialRipQualityBaselineMaxRegionCost: qualityBaseline.maxRegionCost, + partialRipQualityBaselineTotalRegionCost: qualityBaseline.totalRegionCost, + partialRipQualityBaselineSegmentCount: qualityBaseline.segmentCount, + partialRipQualityBaselineMaxRegionSegmentCount: + qualityBaseline.maxRegionSegmentCount, + partialRipQualityBaselineSquaredRegionSegmentCount: + qualityBaseline.squaredRegionSegmentCount, + bestSolvedSegmentCount: this.bestSolvedRoundSummary?.segmentCount, + bestSolvedMaxRegionSegmentCount: + this.bestSolvedRoundSummary?.maxRegionSegmentCount, + bestSolvedSquaredRegionSegmentCount: + this.bestSolvedRoundSummary?.squaredRegionSegmentCount, + partialRipTargetMaxRegionCost: targetMaxRegionCost, + partialRipMaxTargetTotalRegionCost: maxTargetTotalRegionCost, + partialRipComplexityAwareSelection: this.useComplexityAwareSelection, + partialRipComplexitySelectionMinRouteCount: + this.PARTIAL_RIP_COMPLEXITY_SELECTION_MIN_ROUTE_COUNT, + partialRipMaxRegionCostGrowthRatio: + this.PARTIAL_RIP_MAX_REGION_COST_GROWTH_RATIO, + partialRipTargetReached: this.partialRipTargetReached, } this.publishPartialRipStats() - if (hotRegionIds.length === 0 || state.ripCount >= maxRipAttempts) { + if ( + hotRegionIds.length === 0 || + targetReached || + state.ripCount >= maxRipAttempts + ) { this.restoreBestSolvedState() this.solved = true return @@ -1029,22 +1147,32 @@ export class OutsideInPartialRipTinyHyperGraphSolver extends DistanceAwareTinyHy regionCosts[regionId]! * this.RIP_CONGESTION_REGION_COST_FACTOR } + const usedWarmupFullRip = + state.ripCount < Math.max(0, this.PARTIAL_RIP_WARMUP_FULL_RIP_ATTEMPTS) state.ripCount += 1 - const usedPartialRip = this.preparePartialRip(hotRegionIds, regionCosts) + const usedPartialRip = + !usedWarmupFullRip && this.preparePartialRip(hotRegionIds, regionCosts) if (!usedPartialRip) { this.resetRoutingStateForRerip() } + const reripMode = usedWarmupFullRip + ? "warmup_full" + : usedPartialRip + ? "partial" + : "full_fallback" this.stats = { ...this.stats, ripCount: state.ripCount, maxRegionCostBeforeRip: maxRegionCost, reripRegionCount: hotRegionIds.length, - reripMode: usedPartialRip ? "partial" : "full", + reripMode, + partialRipWarmupFullRipAttempts: + this.PARTIAL_RIP_WARMUP_FULL_RIP_ATTEMPTS, } this.logRipEvent("hot_regions", maxRegionCost, { hotRegionCount: hotRegionIds.length, currentRipThreshold, - reripMode: usedPartialRip ? "partial" : "full", + reripMode, partialRouteCount: usedPartialRip ? this.partialRipRoutePlans.size : 0, }) } diff --git a/tests/outside-in-partial-rip-tiny-hypergraph-solver.test.ts b/tests/outside-in-partial-rip-tiny-hypergraph-solver.test.ts index 0658018..67e3793 100644 --- a/tests/outside-in-partial-rip-tiny-hypergraph-solver.test.ts +++ b/tests/outside-in-partial-rip-tiny-hypergraph-solver.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test" import { OutsideInPartialRipTinyHyperGraphSolver, type TinyHyperGraphProblem, + type TinyHyperGraphSolverOptions, type TinyHyperGraphTopology, } from "lib/index" import type { PortId, RegionId, RouteId } from "lib/types" @@ -16,7 +17,11 @@ class TestOutsideInPartialRipSolver extends OutsideInPartialRipTinyHyperGraphSol } } -const createLinearSolver = (outsideInMaxDistance = 24) => { +const createLinearSolver = ( + outsideInMaxDistance = 24, + options: TinyHyperGraphSolverOptions = {}, + routeCount = 1, +) => { const topology: TinyHyperGraphTopology = { portCount: 5, regionCount: 6, @@ -39,11 +44,11 @@ const createLinearSolver = (outsideInMaxDistance = 24) => { portZ: new Int32Array(5), } const problem: TinyHyperGraphProblem = { - routeCount: 1, + routeCount, portSectionMask: new Int8Array(5).fill(1), - routeStartPort: new Int32Array([0]), - routeEndPort: new Int32Array([4]), - routeNet: new Int32Array([0]), + routeStartPort: new Int32Array(routeCount).fill(0), + routeEndPort: new Int32Array(routeCount).fill(4), + routeNet: new Int32Array(routeCount), regionNetId: new Int32Array(6).fill(-1), } const solver = new TestOutsideInPartialRipSolver(topology, problem, { @@ -51,6 +56,7 @@ const createLinearSolver = (outsideInMaxDistance = 24) => { PARTIAL_RIP_MAX_ATTEMPTS: 1, OUTSIDE_IN_MAX_DISTANCE: outsideInMaxDistance, STATIC_REACHABILITY_PRECHECK: false, + ...options, }) solver.state.portAssignment.fill(0) @@ -128,3 +134,47 @@ test("a span beyond the two-frontier distance budget falls back safely", () => { expect(solver.stats.outsideInFallbackRouteCount).toBe(1) expect(solver.state.regionSegments.flat()).toHaveLength(4) }) + +test("small graphs bypass partial rip and outside-in routing", () => { + const solver = createLinearSolver(24, { + PARTIAL_RIP_MIN_ROUTE_COUNT: 2, + }) + + expect(solver.PARTIAL_RIP_ENABLED).toBe(false) + expect(solver.OUTSIDE_IN_ROUTING).toBe(false) +}) + +test("the configured warmup performs a whole-graph rerip first", () => { + const solver = createLinearSolver(24, { + PARTIAL_RIP_MAX_ATTEMPTS: 1, + PARTIAL_RIP_WARMUP_FULL_RIP_ATTEMPTS: 1, + }) + solver.state.regionIntersectionCaches[3]!.existingRegionCost = 1 + + solver.onAllRoutesRouted() + + expect(solver.state.ripCount).toBe(1) + expect(solver.stats.reripMode).toBe("warmup_full") + expect(solver.stats.partialRipCount ?? 0).toBe(0) +}) + +test("complexity-aware selection activates only at its route-count gate", () => { + const belowGateSolver = createLinearSolver(24, { + PARTIAL_RIP_MAX_ATTEMPTS: 0, + PARTIAL_RIP_COMPLEXITY_SELECTION_MIN_ROUTE_COUNT: 100, + }) + belowGateSolver.onAllRoutesRouted() + + const atGateSolver = createLinearSolver( + 24, + { + PARTIAL_RIP_MAX_ATTEMPTS: 0, + PARTIAL_RIP_COMPLEXITY_SELECTION_MIN_ROUTE_COUNT: 100, + }, + 100, + ) + atGateSolver.onAllRoutesRouted() + + expect(belowGateSolver.stats.partialRipComplexityAwareSelection).toBe(false) + expect(atGateSolver.stats.partialRipComplexityAwareSelection).toBe(true) +}) From 7565806bdc5b41fd4700b5177e641ed731675ee7 Mon Sep 17 00:00:00 2001 From: seveibar Date: Mon, 10 Aug 2026 00:21:26 -0700 Subject: [PATCH 3/6] Avoid partial-rip overhead when disabled --- experiments/outside-in-partial-rip.md | 18 +++++++++++++++++ ...e-in-partial-rip-tiny-hypergraph-solver.ts | 20 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/experiments/outside-in-partial-rip.md b/experiments/outside-in-partial-rip.md index 891bf8e..c5b291d 100644 --- a/experiments/outside-in-partial-rip.md +++ b/experiments/outside-in-partial-rip.md @@ -318,3 +318,21 @@ outside-in/partial-rip tests, and `git diff --check` pass. The full suite runs 104 passing assertions; its three image-test modules still fail to import the workspace's missing optional Sharp Darwin ARM64 binary before their assertions execute. + +## Trial 14 - zero-overhead compatibility path + +The first hosted SRJ21 run preserved completion, DRC, and routing output but +reported a higher wall-clock P50 than the stored main run. Profiling the gated +path showed that partial-rip bookkeeping was still performed on every completed +route even when both partial rip and outside-in routing were disabled. + +Fast exits now bypass partial-plan map lookups and stats publication in +`getRouteStartPortId`, `getRouteEndPortId`, `getStartingNextRegionId`, +`computeH`, `onPathFound`, and `resetRoutingStateForRerip` when the feature is +gated off. A controlled back-to-back SRJ21 run against the preceding commit +kept the exact 10/10 completion and 9/10 DRC result while reducing aggregate +runtime from 5.704 s to 5.374 s (1.06x) and improving the paired median by +1.07x. Every one of the ten samples was faster. + +The package benchmark remained 8/8 solved with the same 1.739 average maximum +region cost and completed in 20.632 s (4.63x faster than main's 95.441 s). diff --git a/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts b/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts index 70e2238..0f1bcf3 100644 --- a/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts +++ b/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts @@ -125,6 +125,9 @@ export class OutsideInPartialRipTinyHyperGraphSolver extends DistanceAwareTinyHy } protected override getRouteStartPortId(routeId: RouteId): PortId { + if (!this.PARTIAL_RIP_ENABLED) { + return super.getRouteStartPortId(routeId) + } return ( this.partialRipRoutePlans.get(routeId)?.activeStartPortId ?? super.getRouteStartPortId(routeId) @@ -132,6 +135,9 @@ export class OutsideInPartialRipTinyHyperGraphSolver extends DistanceAwareTinyHy } protected override getRouteEndPortId(routeId: RouteId): PortId { + if (!this.PARTIAL_RIP_ENABLED) { + return super.getRouteEndPortId(routeId) + } return ( this.partialRipRoutePlans.get(routeId)?.activeEndPortId ?? super.getRouteEndPortId(routeId) @@ -142,6 +148,9 @@ export class OutsideInPartialRipTinyHyperGraphSolver extends DistanceAwareTinyHy routeId: RouteId, startingPortId: PortId, ): RegionId | undefined { + if (!this.PARTIAL_RIP_ENABLED) { + return super.getStartingNextRegionId(routeId, startingPortId) + } const partialRipRoutePlan = this.partialRipRoutePlans.get(routeId) if ( partialRipRoutePlan && @@ -169,6 +178,9 @@ export class OutsideInPartialRipTinyHyperGraphSolver extends DistanceAwareTinyHy } override computeH(neighborPortId: PortId): number { + if (!this.PARTIAL_RIP_ENABLED) { + return super.computeH(neighborPortId) + } const routeId = this.state.currentRouteId if (routeId === undefined || !this.partialRipRoutePlans.has(routeId)) { return super.computeH(neighborPortId) @@ -184,6 +196,10 @@ export class OutsideInPartialRipTinyHyperGraphSolver extends DistanceAwareTinyHy } override onPathFound(finalCandidate: Candidate): void { + if (!this.PARTIAL_RIP_ENABLED && !this.OUTSIDE_IN_ROUTING) { + super.onPathFound(finalCandidate) + return + } const routeId = this.state.currentRouteId const completedOutsideInRoute = routeId !== undefined && this.outsideInRouteSearch?.routeId === routeId @@ -200,6 +216,10 @@ export class OutsideInPartialRipTinyHyperGraphSolver extends DistanceAwareTinyHy } override resetRoutingStateForRerip(): void { + if (!this.PARTIAL_RIP_ENABLED && !this.OUTSIDE_IN_ROUTING) { + super.resetRoutingStateForRerip() + return + } this.partialRipRoutePlans.clear() this.outsideInRouteSearch = undefined this.oneSidedFallbackRouteId = undefined From 476a28a3c17740f6e9f067a3dd05abb1bf14df37 Mon Sep 17 00:00:00 2001 From: seveibar Date: Mon, 10 Aug 2026 00:42:13 -0700 Subject: [PATCH 4/6] Bound partial ripping to effective graph scales --- README.md | 7 ++++- experiments/outside-in-partial-rip.md | 26 +++++++++++++++++++ lib/core.ts | 8 ++++++ ...e-in-partial-rip-tiny-hypergraph-solver.ts | 5 +++- ...partial-rip-tiny-hypergraph-solver.test.ts | 13 ++++++++++ 5 files changed, 57 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index beb84c0..8e1d44f 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ The behavior can be tuned through `TinyHyperGraphSolverOptions`: ```ts const solver = new SelectiveReripTinyHyperGraphSolver(topology, problem, { PARTIAL_RIP_ENABLED: true, + PARTIAL_RIP_MIN_ROUTE_COUNT: 60, + PARTIAL_RIP_MAX_ROUTE_COUNT: 350, PARTIAL_RIP_MAX_DISTANCE: 12, PARTIAL_RIP_QUALITY_MAX_DISTANCE: 24, PARTIAL_RIP_MAX_ATTEMPTS: 10, @@ -78,7 +80,10 @@ const solver = new SelectiveReripTinyHyperGraphSolver(topology, problem, { ``` Set `PARTIAL_RIP_ENABLED` or `OUTSIDE_IN_ROUTING` to `false` to use the legacy -whole-route or one-ended behavior respectively. The solver exposes aggregate +whole-route or one-ended behavior respectively. +`PARTIAL_RIP_MIN_ROUTE_COUNT` and `PARTIAL_RIP_MAX_ROUTE_COUNT` provide an +inclusive scale window; graphs outside it use the legacy behavior. The solver +exposes aggregate partial-rip, retained-segment, frontier-expansion, distance-prune, and fallback counts through `solver.stats`. When the first completed solution is already within 1.5 times the configured final rip threshold, the solver uses the diff --git a/experiments/outside-in-partial-rip.md b/experiments/outside-in-partial-rip.md index c5b291d..78f7949 100644 --- a/experiments/outside-in-partial-rip.md +++ b/experiments/outside-in-partial-rip.md @@ -336,3 +336,29 @@ runtime from 5.704 s to 5.374 s (1.06x) and improving the paired median by The package benchmark remained 8/8 solved with the same 1.739 average maximum region cost and completed in 20.632 s (4.63x faster than main's 95.441 s). + +## Trial 15 - full-holdout scale bounds + +The first hosted autorouter `/benchmark-all` run confirmed the large-graph win +but exposed that the 20-route activation boundary was too broad: + +- dataset01: 100% completion, 91.8% DRC versus 90.6% on main, and 6.5 s P50 + versus 7.0 s; +- SRJ18: 56.3% completion versus 50.0%, equal 25.0% DRC, and 83.4 s P50 + versus 144.4 s; +- SRJ19: 78.5% completion versus 82.5% and 35.0% DRC versus 37.0%; +- SRJ21 and preloaded SRJ23 preserve their completion and DRC rates exactly. + +SRJ19 telemetry showed that all 13 completion regressions had 41-59 routes. +Raising the minimum from 20 to 60 restores those graphs to the established +solver. It retains the 61-route sample068 completion/DRC gain, where partial +ripping improves max region cost from 11.148 to 2.401 and total region cost +from 48.803 to 24.171. + +The SRJ18 sample008 holdout (361 routes) also showed that partial candidates +could not satisfy the total-cost envelope. Bypassing partial routing above 350 +routes improved its selected max region cost from 3.400 to 2.244, reduced the +squared region-segment count from 10,628 to 7,437, and cut a controlled local +end-to-end run from 182.7 s to 93.1 s while restoring the main-like 299-via +topology. The accepted integration therefore enables partial routing only for +60-350 routes, with both bounds configurable. diff --git a/lib/core.ts b/lib/core.ts index c2b37e5..7888694 100644 --- a/lib/core.ts +++ b/lib/core.ts @@ -258,6 +258,8 @@ export interface TinyHyperGraphSolverOptions { PARTIAL_RIP_ENABLED?: boolean /** Minimum route count required to enable partial-rip optimization. */ PARTIAL_RIP_MIN_ROUTE_COUNT?: number + /** Maximum route count allowed to use partial-rip optimization. */ + PARTIAL_RIP_MAX_ROUTE_COUNT?: number /** Maximum old-route distance reopened on either side of a hot segment. */ PARTIAL_RIP_MAX_DISTANCE?: number /** Larger partial-rip window used when the initial solution is near target. */ @@ -300,6 +302,7 @@ export interface TinyHyperGraphSolverOptionTarget { GREEDY_FINAL_ROUTE_ITERS: number PARTIAL_RIP_ENABLED?: boolean PARTIAL_RIP_MIN_ROUTE_COUNT?: number + PARTIAL_RIP_MAX_ROUTE_COUNT?: number PARTIAL_RIP_MAX_DISTANCE?: number PARTIAL_RIP_QUALITY_MAX_DISTANCE?: number PARTIAL_RIP_MAX_ATTEMPTS?: number @@ -371,6 +374,9 @@ export const applyTinyHyperGraphSolverOptions = ( if (options.PARTIAL_RIP_MIN_ROUTE_COUNT !== undefined) { solver.PARTIAL_RIP_MIN_ROUTE_COUNT = options.PARTIAL_RIP_MIN_ROUTE_COUNT } + if (options.PARTIAL_RIP_MAX_ROUTE_COUNT !== undefined) { + solver.PARTIAL_RIP_MAX_ROUTE_COUNT = options.PARTIAL_RIP_MAX_ROUTE_COUNT + } if (options.PARTIAL_RIP_MAX_DISTANCE !== undefined) { solver.PARTIAL_RIP_MAX_DISTANCE = options.PARTIAL_RIP_MAX_DISTANCE } @@ -429,6 +435,7 @@ export const getTinyHyperGraphSolverOptions = ( GREEDY_FINAL_ROUTE_ITERS: solver.GREEDY_FINAL_ROUTE_ITERS, PARTIAL_RIP_ENABLED: solver.PARTIAL_RIP_ENABLED, PARTIAL_RIP_MIN_ROUTE_COUNT: solver.PARTIAL_RIP_MIN_ROUTE_COUNT, + PARTIAL_RIP_MAX_ROUTE_COUNT: solver.PARTIAL_RIP_MAX_ROUTE_COUNT, PARTIAL_RIP_MAX_DISTANCE: solver.PARTIAL_RIP_MAX_DISTANCE, PARTIAL_RIP_QUALITY_MAX_DISTANCE: solver.PARTIAL_RIP_QUALITY_MAX_DISTANCE, PARTIAL_RIP_MAX_ATTEMPTS: solver.PARTIAL_RIP_MAX_ATTEMPTS, @@ -491,6 +498,7 @@ export class TinyHyperGraphSolver extends BaseSolver { GREEDY_FINAL_ROUTE_ITERS = 4 PARTIAL_RIP_ENABLED = false PARTIAL_RIP_MIN_ROUTE_COUNT = 0 + PARTIAL_RIP_MAX_ROUTE_COUNT = Number.POSITIVE_INFINITY PARTIAL_RIP_MAX_DISTANCE = 12 PARTIAL_RIP_QUALITY_MAX_DISTANCE?: number PARTIAL_RIP_MAX_ATTEMPTS = Number.POSITIVE_INFINITY diff --git a/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts b/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts index 0f1bcf3..32dc9d5 100644 --- a/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts +++ b/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts @@ -115,7 +115,10 @@ export class OutsideInPartialRipTinyHyperGraphSolver extends DistanceAwareTinyHy ) { this.PARTIAL_RIP_MAX_ATTEMPTS = 10 } - if (problem.routeCount < Math.max(0, this.PARTIAL_RIP_MIN_ROUTE_COUNT)) { + if ( + problem.routeCount < Math.max(0, this.PARTIAL_RIP_MIN_ROUTE_COUNT) || + problem.routeCount > Math.max(0, this.PARTIAL_RIP_MAX_ROUTE_COUNT) + ) { this.PARTIAL_RIP_ENABLED = false this.OUTSIDE_IN_ROUTING = false } diff --git a/tests/outside-in-partial-rip-tiny-hypergraph-solver.test.ts b/tests/outside-in-partial-rip-tiny-hypergraph-solver.test.ts index 67e3793..f2f07e0 100644 --- a/tests/outside-in-partial-rip-tiny-hypergraph-solver.test.ts +++ b/tests/outside-in-partial-rip-tiny-hypergraph-solver.test.ts @@ -144,6 +144,19 @@ test("small graphs bypass partial rip and outside-in routing", () => { expect(solver.OUTSIDE_IN_ROUTING).toBe(false) }) +test("oversized graphs bypass partial rip and outside-in routing", () => { + const solver = createLinearSolver( + 24, + { + PARTIAL_RIP_MAX_ROUTE_COUNT: 2, + }, + 3, + ) + + expect(solver.PARTIAL_RIP_ENABLED).toBe(false) + expect(solver.OUTSIDE_IN_ROUTING).toBe(false) +}) + test("the configured warmup performs a whole-graph rerip first", () => { const solver = createLinearSolver(24, { PARTIAL_RIP_MAX_ATTEMPTS: 1, From 2ccf17493710c0d0d18da314af75bd98cf0e32ec Mon Sep 17 00:00:00 2001 From: seveibar Date: Mon, 10 Aug 2026 00:48:18 -0700 Subject: [PATCH 5/6] Document the full-holdout routing window --- README.md | 2 +- experiments/outside-in-partial-rip.md | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8e1d44f..b9e423e 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ The behavior can be tuned through `TinyHyperGraphSolverOptions`: ```ts const solver = new SelectiveReripTinyHyperGraphSolver(topology, problem, { PARTIAL_RIP_ENABLED: true, - PARTIAL_RIP_MIN_ROUTE_COUNT: 60, + PARTIAL_RIP_MIN_ROUTE_COUNT: 100, PARTIAL_RIP_MAX_ROUTE_COUNT: 350, PARTIAL_RIP_MAX_DISTANCE: 12, PARTIAL_RIP_QUALITY_MAX_DISTANCE: 24, diff --git a/experiments/outside-in-partial-rip.md b/experiments/outside-in-partial-rip.md index 78f7949..7094906 100644 --- a/experiments/outside-in-partial-rip.md +++ b/experiments/outside-in-partial-rip.md @@ -349,11 +349,14 @@ but exposed that the 20-route activation boundary was too broad: - SRJ19: 78.5% completion versus 82.5% and 35.0% DRC versus 37.0%; - SRJ21 and preloaded SRJ23 preserve their completion and DRC rates exactly. -SRJ19 telemetry showed that all 13 completion regressions had 41-59 routes. -Raising the minimum from 20 to 60 restores those graphs to the established -solver. It retains the 61-route sample068 completion/DRC gain, where partial -ripping improves max region cost from 11.148 to 2.401 and total region cost -from 48.803 to 24.171. +SRJ19 telemetry showed that all 13 completion regressions had 41-59 routes. An +intermediate minimum of 60 restored those graphs while retaining the 61-route +sample068 completion/DRC gain, where partial ripping improves max region cost +from 11.148 to 2.401 and total region cost from 48.803 to 24.171. The full +SRJ20 holdout then showed 67.0% completion versus 70.0% on main (with 31.0% +versus 30.5% DRC); its last remaining completion regression had 62 routes. +The final 100-route boundary cleanly preserves the medium datasets while every +SRJ18 graph remains eligible (the smallest has 114 routes). The SRJ18 sample008 holdout (361 routes) also showed that partial candidates could not satisfy the total-cost envelope. Bypassing partial routing above 350 @@ -361,4 +364,4 @@ routes improved its selected max region cost from 3.400 to 2.244, reduced the squared region-segment count from 10,628 to 7,437, and cut a controlled local end-to-end run from 182.7 s to 93.1 s while restoring the main-like 299-via topology. The accepted integration therefore enables partial routing only for -60-350 routes, with both bounds configurable. +100-350 routes, with both bounds configurable. From 85835dfeebedce62046956e7a5386aa5f4d4fb26 Mon Sep 17 00:00:00 2001 From: seveibar Date: Mon, 10 Aug 2026 01:28:41 -0700 Subject: [PATCH 6/6] Preserve gated routing topology and cost bounds --- experiments/outside-in-partial-rip.md | 38 +++++++++++++++++++ lib/DuplicateCongestedPortSolver.ts | 5 +++ ...e-in-partial-rip-tiny-hypergraph-solver.ts | 5 ++- ...partial-rip-tiny-hypergraph-solver.test.ts | 36 ++++++++++++++++++ .../duplicate-congested-port-solver.test.ts | 21 ++++++++++ 5 files changed, 103 insertions(+), 2 deletions(-) diff --git a/experiments/outside-in-partial-rip.md b/experiments/outside-in-partial-rip.md index 7094906..08484fb 100644 --- a/experiments/outside-in-partial-rip.md +++ b/experiments/outside-in-partial-rip.md @@ -365,3 +365,41 @@ squared region-segment count from 10,628 to 7,437, and cut a controlled local end-to-end run from 182.7 s to 93.1 s while restoring the main-like 299-via topology. The accepted integration therefore enables partial routing only for 100-350 routes, with both bounds configurable. + +## Trial 16 - dependency-boundary compatibility and first-state cost guard + +The first 100-350 holdout still showed a small SRJ19 completion difference +(82.0% versus 82.5%) even though every SRJ19 graph was below the activation +window. A route-count-84 regression fixture also changed its SVG by 33.9% with +both partial ripping and outside-in routing disabled. This was not partial-rip +bookkeeping: the newer tiny-hypergraph base loads serialized port penalties, +so the autorouter's duplicate-congested-port prepass began using penalties that +its pinned main version intentionally ignored. That changed the duplicated +topology before the gated solver ran. + +`DuplicateCongestedPortSolver` now has an explicit +`useSerializedPortPenalties` compatibility option. The autorouter disables the +penalties only for this topology prepass, then applies the same metadata, +duplicate-port, and cramped-port penalties as main to the final graph. On +bugreport80 this restores the exact main graph dimensions and penalty totals +(84 routes, 15,766 ports, 1,593 regions, total penalty 3,226,600); the complete +91.1 s local route again passes its existing SVG tolerance instead of differing +by 33.9%. + +The holdout also identified a region-cost envelope bug on bugreport58. The +post-warmup quality baseline could be worse than the first complete solution, +allowing complexity selection to return max region cost 0.670 from a 0.519 +first state (29.1% growth). Complexity eligibility is now anchored to the first +complete state. The selected result has max region cost 0.510 (1.7% better than +first) and total region cost 4.911 (8.3% above first, inside the configured 10% +limit), while its stitch-connectivity assertions continue to pass. A focused +regression test now prevents selection from crossing the configured first-state +max and total cost envelopes. + +Before these final compatibility fixes, the bounded hosted SRJ18 run already +improved completion from 8/16 to 10/16, held relaxed DRC at 4/16, and reduced +P50 from 144.4 s to 84.1 s. Every one of the nine partial-enabled completed +cases improved max region cost, by 34.3% on average; average total region cost +improved 18.8%. The final full holdout is rerun after pinning this compatibility +revision so gated datasets can be compared without the dependency-upgrade +topology change. diff --git a/lib/DuplicateCongestedPortSolver.ts b/lib/DuplicateCongestedPortSolver.ts index e9f0f08..2de9653 100644 --- a/lib/DuplicateCongestedPortSolver.ts +++ b/lib/DuplicateCongestedPortSolver.ts @@ -17,6 +17,8 @@ export const DUPLICATE_PORT_PROXIMITY = 0.05 export interface DuplicateCongestedPortSolverOptions { duplicatePortProximity?: number routeSolveOptions?: TinyHyperGraphSolverOptions + /** Ignore serialized port penalties while estimating shared-port use. */ + useSerializedPortPenalties?: boolean } export interface DuplicatedPortSummary { @@ -326,6 +328,9 @@ export class DuplicateCongestedPortSolver extends BaseSolver { const { topology, problem } = loadSerializedHyperGraph( this.serializedHyperGraph, ) + if (this.options.useSerializedPortPenalties === false) { + problem.portPenalty = undefined + } const portUseCounts = new Map() for (let routeId = 0; routeId < problem.routeCount; routeId++) { diff --git a/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts b/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts index 32dc9d5..6582ef3 100644 --- a/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts +++ b/lib/outside-in-partial-rip-tiny-hypergraph-solver.ts @@ -1007,12 +1007,13 @@ export class OutsideInPartialRipTinyHyperGraphSolver extends DistanceAwareTinyHy if (!qualityBaseline) { return this.compareRegionCostSummaries(summary, bestSummary) < 0 } + const selectionBaseline = this.firstCompletedRoundSummary ?? qualityBaseline const maxRegionCostCeiling = - qualityBaseline.maxRegionCost * + selectionBaseline.maxRegionCost * (1 + Math.max(0, this.PARTIAL_RIP_MAX_REGION_COST_GROWTH_RATIO)) const totalRegionCostCeiling = - qualityBaseline.totalRegionCost * + selectionBaseline.totalRegionCost * (1 + Math.max(0, this.PARTIAL_RIP_MAX_TOTAL_COST_GROWTH_RATIO)) const isEligible = summary.maxRegionCost <= maxRegionCostCeiling && diff --git a/tests/outside-in-partial-rip-tiny-hypergraph-solver.test.ts b/tests/outside-in-partial-rip-tiny-hypergraph-solver.test.ts index f2f07e0..dcb2a05 100644 --- a/tests/outside-in-partial-rip-tiny-hypergraph-solver.test.ts +++ b/tests/outside-in-partial-rip-tiny-hypergraph-solver.test.ts @@ -191,3 +191,39 @@ test("complexity-aware selection activates only at its route-count gate", () => expect(belowGateSolver.stats.partialRipComplexityAwareSelection).toBe(false) expect(atGateSolver.stats.partialRipComplexityAwareSelection).toBe(true) }) + +test("complexity selection cannot exceed the first solution cost envelope", () => { + const solver = createLinearSolver( + 24, + { + PARTIAL_RIP_MAX_ATTEMPTS: 1, + PARTIAL_RIP_COMPLEXITY_SELECTION_MIN_ROUTE_COUNT: 100, + PARTIAL_RIP_MAX_REGION_COST_GROWTH_RATIO: 0.2, + PARTIAL_RIP_MAX_TOTAL_COST_GROWTH_RATIO: 0.1, + }, + 100, + ) + solver.state.regionIntersectionCaches[3]!.existingRegionCost = 1 + + solver.onAllRoutesRouted() + + solver.state.portAssignment.fill(0) + solver.state.unroutedRoutes = [] + solver.state.currentRouteId = undefined + solver.state.currentRouteNetId = undefined + solver.state.regionSegments = Array.from({ length: 6 }, () => []) + solver.state.regionSegments[1] = [[0, 0, 1]] + solver.state.regionSegments[2] = [[0, 1, 2]] + solver.state.regionSegments[3] = [[0, 2, 3]] + solver.state.regionSegments[4] = [[0, 3, 4]] + for (const cache of solver.state.regionIntersectionCaches) { + cache.existingRegionCost = 0 + } + solver.state.regionIntersectionCaches[3]!.existingRegionCost = 1.21 + + solver.onAllRoutesRouted() + + expect(solver.solved).toBe(true) + expect(solver.stats.bestMaxRegionCost).toBe(1) + expect(solver.state.regionIntersectionCaches[3]!.existingRegionCost).toBe(1) +}) diff --git a/tests/solver/duplicate-congested-port-solver.test.ts b/tests/solver/duplicate-congested-port-solver.test.ts index ec2c1e5..da5645e 100644 --- a/tests/solver/duplicate-congested-port-solver.test.ts +++ b/tests/solver/duplicate-congested-port-solver.test.ts @@ -198,3 +198,24 @@ test("duplicate congested port solver duplicates independently reused ports in l .every((region) => region.pointIds.includes("shared-choke::dup1")), ).toBe(true) }) + +test("duplicate congested port solver can preserve legacy port-use estimation", () => { + const graph = createDuplicatePortFixture() + const sharedChoke = graph.ports.find( + (port) => port.portId === "shared-choke", + )! + sharedChoke.d = { + ...sharedChoke.d, + tinyHypergraphPortPenalty: 1_000, + } + + const penaltyAwareSolver = new DuplicateCongestedPortSolver(graph) + penaltyAwareSolver.solve() + const compatibilitySolver = new DuplicateCongestedPortSolver(graph, { + useSerializedPortPenalties: false, + }) + compatibilitySolver.solve() + + expect(penaltyAwareSolver.report.portUseCounts["shared-neighbor"]).toBe(2) + expect(compatibilitySolver.report.portUseCounts["shared-choke"]).toBe(2) +})