@tscircuit/fanout-solver 0.0.61 → 0.0.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -282,8 +282,8 @@ extents.
282
282
 
283
283
  ## Dataset 31 benchmark
284
284
 
285
- Run `./benchmark.sh` (or `bun run benchmark`) to benchmark **only the 12 AM62L
286
- directional cases** from
285
+ Run `./benchmark.sh` (or `bun run benchmark`) to benchmark **all 24 dataset 31
286
+ directional cases: 12 AM62L and 12 RK3308** from
287
287
  [`tscircuit/dataset-fanout31-am62l`](https://github.com/tscircuit/dataset-fanout31-am62l).
288
288
  The upstream revision is pinned in `scripts/generate-repro/package.json` and
289
289
  recorded in every report. Other datasets remain available for regression tests
@@ -293,14 +293,24 @@ and the debugger, but have no benchmark commands or workflows.
293
293
  ./benchmark.sh
294
294
  ./benchmark.sh --list
295
295
  ./benchmark.sh --sample 11-left-center
296
+ ./benchmark.sh --sample 13-rk3308-top-left-offset
296
297
  ./benchmark.sh --concurrency 8 --sample-timeout-seconds 300
297
298
  ```
298
299
 
299
300
  Before timing the solver, the benchmark renders the selected upstream TSX/core
300
301
  circuits and captures their exact fanout-solver constructor inputs into
301
- `benchmark-results/inputs/<sample-id>.json`. Each case retains all 135 AM62L
302
- connections, 573 pad obstacles, nine DDR buses, 102 plane drops, and the original
303
- clearance, differential-pair, and length-skew constraints. The timed workers run
302
+ `benchmark-results/inputs/<sample-id>.json`. Each case retains its complete
303
+ SoC fanout workload and the original clearance, differential-pair, and length-skew
304
+ constraints:
305
+
306
+ | SoC | Cases | Signal connections | Plane drops | Total connections | Pad obstacles |
307
+ | --- | ---: | ---: | ---: | ---: | ---: |
308
+ | AM62L | 12 | 33 | 102 | 135 | 573 |
309
+ | RK3308 | 12 | 49 | 113 | 162 | 451 |
310
+
311
+ Both families have nine DDR signal buses. The RK3308 samples use a 355-ball SoC
312
+ and 96-ball DDR3L RAM, with the RAM placed on each side at three offsets.
313
+ The timed workers run
304
314
  **this checkout's solver**, not the upstream package's released solver.
305
315
  To capture the inputs without solving, use `bun run generate:dataset31`.
306
316
  The optional `--dataset dataset31` flag is accepted for explicit CI invocation;
@@ -335,8 +345,10 @@ are committed so route changes can be reviewed in Git. A run replaces the select
335
345
  cases' snapshots and removes their stale SVGs if they no longer solve; filtered
336
346
  runs preserve unselected snapshots. JSON reports and captured inputs remain
337
347
  ignored. CI includes the SVGs in its benchmark artifacts.
338
- Compare reports with the same budgets to track progress. Solved means validated
339
- AM62L fanout, not RAM fanout or downstream inter-chip routing. Partial, error,
348
+ Compare reports with the same dataset revision and budgets to track progress.
349
+ A case is solved only when every SoC connection has validated fanout: all 135
350
+ connections for AM62L or all 162 for RK3308. This covers the SoC fanout phase; RAM
351
+ fanout and downstream inter-chip routing are separate phases. Partial, error,
340
352
  and timeout rows are benchmark results (exit 0); invalid CLI arguments or report
341
353
  I/O failures are command failures (nonzero exit).
342
354
 
@@ -344,7 +356,7 @@ I/O failures are command failures (nonzero exit).
344
356
 
345
357
  Once `.github/workflows/benchmark.yml` is on the default branch, a repository
346
358
  writer can comment **`/benchmark`** on an open PR. The workflow captures that
347
- PR's exact head SHA, runs all 12 dataset 31 samples on a **32-vCPU Blacksmith ARM**
359
+ PR's exact head SHA, runs all 24 dataset 31 samples on a **32-vCPU Blacksmith ARM**
348
360
  runner, then updates a status comment with solve totals, per-sample results,
349
361
  and a link to the complete JSON/Markdown reports and captured inputs. The Actions
350
362
  UI also supports a manual run, optionally supplying an open PR number. No custom
@@ -510,8 +522,8 @@ bun run render:dataset
510
522
  bun run start
511
523
  ```
512
524
 
513
- The benchmark runs only the 12 dataset 31 AM62L cases and reports solve counts,
514
- validation, and timing. `bun run start` opens all regression
525
+ The benchmark runs all 24 dataset 31 AM62L and RK3308 cases and reports solve
526
+ counts, validation, and timing. `bun run start` opens all regression
515
527
  datasets in the standard tscircuit solver debugger. `bun run
516
528
  render:dataset` writes `graphics-debug` PNGs under one subdirectory per dataset,
517
529
  with a red shared boundary, gray component courtyards, and green fanout-exit
package/lib/geometry.ts CHANGED
@@ -190,6 +190,25 @@ export function segmentsAreClear(
190
190
  ): boolean {
191
191
  if (first.layer !== second.layer) return true
192
192
  const requiredDistance = (first.width + second.width) / 2 + clearance
193
+ // Axis separation is a lower bound on the distance between the segments.
194
+ // Leave a conservative tolerance band to the exact check below.
195
+ const broadPhaseDistance = requiredDistance + EPSILON
196
+ if (
197
+ Math.min(first.start.x, first.end.x) -
198
+ Math.max(second.start.x, second.end.x) >
199
+ broadPhaseDistance ||
200
+ Math.min(second.start.x, second.end.x) -
201
+ Math.max(first.start.x, first.end.x) >
202
+ broadPhaseDistance ||
203
+ Math.min(first.start.y, first.end.y) -
204
+ Math.max(second.start.y, second.end.y) >
205
+ broadPhaseDistance ||
206
+ Math.min(second.start.y, second.end.y) -
207
+ Math.max(first.start.y, first.end.y) >
208
+ broadPhaseDistance
209
+ ) {
210
+ return true
211
+ }
193
212
  return (
194
213
  distanceSegmentToSegment(
195
214
  first.start,
package/lib/route-bus.ts CHANGED
@@ -1951,6 +1951,8 @@ function planIsClearOfPlans(params: {
1951
1951
  clearance,
1952
1952
  blockingBusCounts,
1953
1953
  } = params
1954
+ const planSegments = getPlanSegments(plan)
1955
+ const planVias = getPlanVias(plan)
1954
1956
  for (const otherPlan of otherPlans) {
1955
1957
  if (
1956
1958
  allowSameNetMerges &&
@@ -1975,9 +1977,7 @@ function planIsClearOfPlans(params: {
1975
1977
  (blockingBusCounts.get(otherPlan.busId) ?? 0) + 1,
1976
1978
  )
1977
1979
  }
1978
- const planSegments = getPlanSegments(plan)
1979
1980
  const otherSegments = getPlanSegments(otherPlan)
1980
- const planVias = getPlanVias(plan)
1981
1981
  const otherVias = getPlanVias(otherPlan)
1982
1982
  for (const segment of planSegments) {
1983
1983
  for (const otherSegment of otherSegments) {
@@ -824,6 +824,13 @@ export function* routeViaMinimalWindingAlternativesSteps(
824
824
  const sharesNet = (first: string, second: string): boolean =>
825
825
  first === second ||
826
826
  (allowSameNetMerges && connectionsShareElectricalNet(srj, first, second))
827
+ const boundedBlockingSegments = blockingSegments.map((blocker) => ({
828
+ ...blocker,
829
+ minX: Math.min(blocker.segment.start.x, blocker.segment.end.x),
830
+ maxX: Math.max(blocker.segment.start.x, blocker.segment.end.x),
831
+ minY: Math.min(blocker.segment.start.y, blocker.segment.end.y),
832
+ maxY: Math.max(blocker.segment.start.y, blocker.segment.end.y),
833
+ }))
827
834
  const allBlockingVias = [...blockingVias, ...terminalVias]
828
835
  const maximumViaToTraceDistance = allBlockingVias.reduce(
829
836
  (maximum, { via }) =>
@@ -850,6 +857,10 @@ export function* routeViaMinimalWindingAlternativesSteps(
850
857
  }): boolean => {
851
858
  const { segment, terminal, acceptedAttemptSegments } = params
852
859
  const connectionName = terminal.connection.connection.name
860
+ const segmentMinX = Math.min(segment.start.x, segment.end.x)
861
+ const segmentMaxX = Math.max(segment.start.x, segment.end.x)
862
+ const segmentMinY = Math.min(segment.start.y, segment.end.y)
863
+ const segmentMaxY = Math.max(segment.start.y, segment.end.y)
853
864
  const requiredObstacleClearance = segment.width / 2 + clearance
854
865
  for (const obstacle of targetLayerObstacleIndex.querySegment(
855
866
  segment,
@@ -869,8 +880,19 @@ export function* routeViaMinimalWindingAlternativesSteps(
869
880
  return false
870
881
  }
871
882
  }
872
- for (const blocker of blockingSegments) {
883
+ for (const blocker of boundedBlockingSegments) {
873
884
  if (sharesNet(connectionName, blocker.connectionName)) continue
885
+ const margin = (segment.width + blocker.segment.width) / 2 + clearance
886
+ // Keep the full clearance margin in the broad phase; the exact check
887
+ // retains the existing tolerance for nearby copper.
888
+ if (
889
+ segmentMaxX + margin < blocker.minX ||
890
+ segmentMinX - margin > blocker.maxX ||
891
+ segmentMaxY + margin < blocker.minY ||
892
+ segmentMinY - margin > blocker.maxY
893
+ ) {
894
+ continue
895
+ }
874
896
  if (
875
897
  distanceSegmentToSegment(
876
898
  segment.start,
@@ -878,7 +900,7 @@ export function* routeViaMinimalWindingAlternativesSteps(
878
900
  blocker.segment.start,
879
901
  blocker.segment.end,
880
902
  ) <
881
- (segment.width + blocker.segment.width) / 2 + clearance - EPSILON
903
+ margin - EPSILON
882
904
  ) {
883
905
  return false
884
906
  }
@@ -887,13 +909,13 @@ export function* routeViaMinimalWindingAlternativesSteps(
887
909
  if (sharesNet(connectionName, blocker.connectionName)) continue
888
910
  const margin = (segment.width + blocker.segment.width) / 2 + clearance
889
911
  if (
890
- Math.max(segment.start.x, segment.end.x) + margin <
912
+ segmentMaxX + margin <
891
913
  Math.min(blocker.segment.start.x, blocker.segment.end.x) ||
892
- Math.min(segment.start.x, segment.end.x) - margin >
914
+ segmentMinX - margin >
893
915
  Math.max(blocker.segment.start.x, blocker.segment.end.x) ||
894
- Math.max(segment.start.y, segment.end.y) + margin <
916
+ segmentMaxY + margin <
895
917
  Math.min(blocker.segment.start.y, blocker.segment.end.y) ||
896
- Math.min(segment.start.y, segment.end.y) - margin >
918
+ segmentMinY - margin >
897
919
  Math.max(blocker.segment.start.y, blocker.segment.end.y)
898
920
  )
899
921
  continue
@@ -917,10 +939,6 @@ export function* routeViaMinimalWindingAlternativesSteps(
917
939
  )
918
940
  return false
919
941
  }
920
- const segmentMinX = Math.min(segment.start.x, segment.end.x)
921
- const segmentMaxX = Math.max(segment.start.x, segment.end.x)
922
- const segmentMinY = Math.min(segment.start.y, segment.end.y)
923
- const segmentMaxY = Math.max(segment.start.y, segment.end.y)
924
942
  for (
925
943
  let viaIndex = getFirstViaAtOrAfterX(
926
944
  segmentMinX - maximumViaToTraceDistance,
@@ -1151,18 +1169,33 @@ export function* routeViaMinimalWindingAlternativesSteps(
1151
1169
  )
1152
1170
  const previous = new Int32Array(stateCount).fill(-1)
1153
1171
  const heap = new MinHeap()
1154
- const heuristic = (point: Point2D): number => {
1172
+ // A node is revisited with different incoming directions. Its distance
1173
+ // estimate and lane penalty stay fixed throughout this terminal search.
1174
+ const remainingDistances = new Float64Array(nodeCount)
1175
+ const lanePenalties = new Float64Array(nodeCount)
1176
+ const targetTrack = getPerpendicularAxis(
1177
+ terminal.exitPoint,
1178
+ boundaryDirection,
1179
+ )
1180
+ for (let nodeIndex = 0; nodeIndex < nodeCount; nodeIndex++) {
1181
+ const point = nodes[nodeIndex]!.point
1155
1182
  const deltaX = Math.abs(point.x - terminal.exitPoint.x)
1156
1183
  const deltaY = Math.abs(point.y - terminal.exitPoint.y)
1157
- return (
1184
+ remainingDistances[nodeIndex] =
1158
1185
  Math.max(deltaX, deltaY) + (Math.SQRT2 - 1) * Math.min(deltaX, deltaY)
1159
- )
1186
+ const nextTrack = getPerpendicularAxis(point, boundaryDirection)
1187
+ lanePenalties[nodeIndex] =
1188
+ laneBias === 0
1189
+ ? 0
1190
+ : laneBias > 0
1191
+ ? Math.max(0, targetTrack - nextTrack) * 0.2
1192
+ : Math.max(0, nextTrack - targetTrack) * 0.2
1160
1193
  }
1161
1194
  for (const start of starts) {
1162
1195
  const state = start.nodeIndex * 9 + 8
1163
1196
  if (start.length >= distances[state]!) continue
1164
1197
  distances[state] = start.length
1165
- const remaining = heuristic(nodes[start.nodeIndex]!.point)
1198
+ const remaining = remainingDistances[start.nodeIndex]!
1166
1199
  heap.push({
1167
1200
  node: start.nodeIndex,
1168
1201
  direction: 8,
@@ -1179,6 +1212,16 @@ export function* routeViaMinimalWindingAlternativesSteps(
1179
1212
  [0, -1],
1180
1213
  [1, -1],
1181
1214
  ] as const
1215
+ // Preserve the original ascending neighbor order, but do not reconsider
1216
+ // the five disallowed turns every time a directed state is expanded.
1217
+ const nextDirectionsByIncoming = Array.from({ length: 9 }, (_, incoming) =>
1218
+ directions.flatMap((_, directionIndex) => {
1219
+ const delta = Math.abs(incoming - directionIndex)
1220
+ return incoming === 8 || Math.min(delta, 8 - delta) <= 1
1221
+ ? [directionIndex]
1222
+ : []
1223
+ }),
1224
+ )
1182
1225
  const startsByNode = new Map<number, ConnectorCandidate[]>()
1183
1226
  for (const start of starts) {
1184
1227
  const values = startsByNode.get(start.nodeIndex) ?? []
@@ -1198,7 +1241,7 @@ export function* routeViaMinimalWindingAlternativesSteps(
1198
1241
  const currentDistance = distances[state]!
1199
1242
  if (
1200
1243
  current.score >
1201
- currentDistance + heuristic(nodes[current.node]!.point) + EPSILON
1244
+ currentDistance + remainingDistances[current.node]! + EPSILON
1202
1245
  )
1203
1246
  continue
1204
1247
  expandedStateCount++
@@ -1257,17 +1300,9 @@ export function* routeViaMinimalWindingAlternativesSteps(
1257
1300
  }
1258
1301
  }
1259
1302
  const node = nodes[current.node]!
1260
- for (
1261
- let directionIndex = 0;
1262
- directionIndex < directions.length;
1263
- directionIndex++
1264
- ) {
1265
- if (current.direction !== 8) {
1266
- const rawDirectionDelta = Math.abs(current.direction - directionIndex)
1267
- if (Math.min(rawDirectionDelta, 8 - rawDirectionDelta) > 1) {
1268
- continue
1269
- }
1270
- }
1303
+ for (const directionIndex of nextDirectionsByIncoming[
1304
+ current.direction
1305
+ ]!) {
1271
1306
  const [deltaColumn, deltaRow] = directions[directionIndex]!
1272
1307
  const column = node.column + deltaColumn
1273
1308
  const row = node.row + deltaRow
@@ -1278,17 +1313,7 @@ export function* routeViaMinimalWindingAlternativesSteps(
1278
1313
  const nextPoint = nodes[nextNode]!.point
1279
1314
  const addsTurn =
1280
1315
  current.direction !== 8 && current.direction !== directionIndex
1281
- const nextTrack = getPerpendicularAxis(nextPoint, boundaryDirection)
1282
- const targetTrack = getPerpendicularAxis(
1283
- terminal.exitPoint,
1284
- boundaryDirection,
1285
- )
1286
- const lanePenalty =
1287
- laneBias === 0
1288
- ? 0
1289
- : laneBias > 0
1290
- ? Math.max(0, targetTrack - nextTrack) * 0.2
1291
- : Math.max(0, nextTrack - targetTrack) * 0.2
1316
+ const lanePenalty = lanePenalties[nextNode]!
1292
1317
  const nextDistance =
1293
1318
  currentDistance +
1294
1319
  (deltaColumn !== 0 && deltaRow !== 0
@@ -1319,7 +1344,7 @@ export function* routeViaMinimalWindingAlternativesSteps(
1319
1344
  if (edgeClearance[edgeIndex] === 2) continue
1320
1345
  distances[nextState] = nextDistance
1321
1346
  previous[nextState] = state
1322
- const remaining = heuristic(nextPoint)
1347
+ const remaining = remainingDistances[nextNode]!
1323
1348
  heap.push({
1324
1349
  node: nextNode,
1325
1350
  direction: directionIndex,
@@ -1488,6 +1513,14 @@ export function* routeViaMinimalWindingAlternativesSteps(
1488
1513
 
1489
1514
  const alternatives: FanoutRoutePlan[][] = []
1490
1515
  const seenAlternativeKeys = new Set<string>()
1516
+ // Different complete route orders can share the same unsuccessful prefix.
1517
+ // Static obstacles/vias, grid and search limits belong to this invocation;
1518
+ // the terminal, lane bias and already accepted copper identify the rest.
1519
+ // A reused failure still emits its normal completion progress below.
1520
+ const failedSearches = new Map<
1521
+ string,
1522
+ { expandedStateCount: number; searchBatch: number }
1523
+ >()
1491
1524
  let routeOrderAttemptCount = 0
1492
1525
  for (const routeOrder of adaptiveRouteOrders()) {
1493
1526
  for (const laneBias of laneBiases) {
@@ -1507,13 +1540,36 @@ export function* routeViaMinimalWindingAlternativesSteps(
1507
1540
  terminalIndex++
1508
1541
  ) {
1509
1542
  const terminal = routeOrder[terminalIndex]!
1543
+ const failedSearchKey = JSON.stringify([
1544
+ terminals.indexOf(terminal),
1545
+ laneBias,
1546
+ acceptedAttemptSegments.map(({ connectionName, segment }) => [
1547
+ connectionName,
1548
+ segment.start.x,
1549
+ segment.start.y,
1550
+ segment.end.x,
1551
+ segment.end.y,
1552
+ segment.width,
1553
+ segment.layer,
1554
+ ]),
1555
+ ])
1556
+ const cachedFailure = failedSearches.get(failedSearchKey)
1510
1557
  const connectionSteps = routeOneSteps({
1511
1558
  terminal,
1512
1559
  acceptedAttemptSegments,
1513
1560
  laneBias,
1514
1561
  })
1515
- let connectionResult = connectionSteps.next()
1516
- let searchBatch = 0
1562
+ let connectionResult: ReturnType<typeof connectionSteps.next> =
1563
+ cachedFailure === undefined
1564
+ ? connectionSteps.next()
1565
+ : {
1566
+ done: true,
1567
+ value: {
1568
+ points: null,
1569
+ expandedStateCount: cachedFailure.expandedStateCount,
1570
+ },
1571
+ }
1572
+ let searchBatch = cachedFailure?.searchBatch ?? 0
1517
1573
  let expandedStateCount = 0
1518
1574
  while (!connectionResult.done) {
1519
1575
  expandedStateCount = connectionResult.value.expandedStateCount
@@ -1560,6 +1616,10 @@ export function* routeViaMinimalWindingAlternativesSteps(
1560
1616
  : {}),
1561
1617
  }
1562
1618
  if (!points) {
1619
+ failedSearches.set(failedSearchKey, {
1620
+ expandedStateCount: finalExpandedStateCount,
1621
+ searchBatch,
1622
+ })
1563
1623
  if (
1564
1624
  adaptiveRouteOrder &&
1565
1625
  maximumRouteOrderAttempts !== undefined &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tscircuit/fanout-solver",
3
- "version": "0.0.61",
3
+ "version": "0.0.63",
4
4
  "description": "BGA fanout solver with coordinated bus-layer escapes for SimpleRouteJson",
5
5
  "module": "lib/index.ts",
6
6
  "type": "module",