@tscircuit/fanout-solver 0.0.36 → 0.0.37

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.
@@ -5,10 +5,9 @@ import type { FanoutRoutePlan, SimpleRouteJsonWithFanoutPlanes } from "./types"
5
5
  function createViaObstacle(
6
6
  plan: FanoutRoutePlan,
7
7
  layerNames: string[],
8
- endpoint = false,
8
+ via: NonNullable<FanoutRoutePlan["via"]>,
9
+ viaIndex: number | "endpoint",
9
10
  ): Obstacle | null {
10
- const via = endpoint ? plan.planeEndpointVia : plan.via
11
- if (!via) return null
12
11
  const zLayers = via.spanLayers.map((layer) => {
13
12
  const layerIndex = layerNames.indexOf(layer)
14
13
  if (layerIndex < 0) {
@@ -20,9 +19,12 @@ function createViaObstacle(
20
19
  })
21
20
  const outputIds = createFanoutOutputIds(plan)
22
21
  return {
23
- obstacleId: endpoint
24
- ? outputIds.planeEndpointViaObstacleId
25
- : outputIds.viaObstacleId,
22
+ obstacleId:
23
+ viaIndex === "endpoint"
24
+ ? outputIds.planeEndpointViaObstacleId
25
+ : viaIndex === 0
26
+ ? outputIds.viaObstacleId
27
+ : `${outputIds.viaObstacleId}:${viaIndex}`,
26
28
  type: "rect",
27
29
  center: via.center,
28
30
  width: via.diameter,
@@ -67,10 +69,30 @@ export function buildOutputSimpleRouteJson(params: {
67
69
  if (plan.termination.type === "plane") {
68
70
  planeTerminatedConnectionNames.add(plan.connectionName)
69
71
  }
70
- const viaObstacle = createViaObstacle(plan, layerNames)
71
- if (viaObstacle) viaObstacles.push(viaObstacle)
72
- const endpointViaObstacle = createViaObstacle(plan, layerNames, true)
73
- if (endpointViaObstacle) viaObstacles.push(endpointViaObstacle)
72
+ if (plan.via) {
73
+ const viaObstacle = createViaObstacle(plan, layerNames, plan.via, 0)
74
+ if (viaObstacle) viaObstacles.push(viaObstacle)
75
+ }
76
+ for (const [additionalViaIndex, via] of (
77
+ plan.additionalVias ?? []
78
+ ).entries()) {
79
+ const viaObstacle = createViaObstacle(
80
+ plan,
81
+ layerNames,
82
+ via,
83
+ additionalViaIndex + 1,
84
+ )
85
+ if (viaObstacle) viaObstacles.push(viaObstacle)
86
+ }
87
+ if (plan.planeEndpointVia) {
88
+ const endpointViaObstacle = createViaObstacle(
89
+ plan,
90
+ layerNames,
91
+ plan.planeEndpointVia,
92
+ "endpoint",
93
+ )
94
+ if (endpointViaObstacle) viaObstacles.push(endpointViaObstacle)
95
+ }
74
96
  }
75
97
  const planTraces = plans.flatMap((plan) => [
76
98
  plan.trace,
@@ -219,6 +219,7 @@ function getPlanViaCount(plans: readonly FanoutRoutePlan[]): number {
219
219
  (count, plan) =>
220
220
  count +
221
221
  Number(Boolean(plan.via)) +
222
+ (plan.additionalVias?.length ?? 0) +
222
223
  Number(Boolean(plan.planeEndpointVia)),
223
224
  0,
224
225
  )
@@ -257,6 +258,27 @@ function busUsesDestinationGuidedTracks(bus: PreparedBus): boolean {
257
258
  })
258
259
  }
259
260
 
261
+ function getCommonExplicitExitTargetLayer(
262
+ bus: PreparedBus,
263
+ ): string | undefined {
264
+ if (
265
+ bus.connections.length === 0 ||
266
+ bus.connections.some(
267
+ (connection) =>
268
+ !connection.hasExplicitLayeredExitTarget ||
269
+ !connection.exitTargetPoint?.layer,
270
+ )
271
+ ) {
272
+ return undefined
273
+ }
274
+ const targetLayers = new Set(
275
+ bus.connections.map((connection) => connection.exitTargetPoint!.layer!),
276
+ )
277
+ if (targetLayers.size !== 1) return undefined
278
+ const [targetLayer] = targetLayers
279
+ return targetLayer
280
+ }
281
+
260
282
  function busIsOnOutwardComponentEdge(bus: PreparedBus): boolean {
261
283
  const isHorizontal = bus.direction === "left" || bus.direction === "right"
262
284
  const directionalCoordinates = isHorizontal
@@ -327,7 +349,14 @@ function createInitialLayerAssignment(params: {
327
349
  const viaLayers = routableEscapeLayers.filter(
328
350
  (layer) => layer !== sourceLayer,
329
351
  )
352
+ const commonExitTargetLayer = getCommonExplicitExitTargetLayer(bus)
330
353
  if (
354
+ commonExitTargetLayer &&
355
+ routableEscapeLayers.includes(commonExitTargetLayer)
356
+ ) {
357
+ assignment[bus.busId] = commonExitTargetLayer
358
+ } else if (
359
+ !busUsesCoordinatedWinding(bus) &&
331
360
  routableEscapeLayers.includes(sourceLayer) &&
332
361
  (busUsesDestinationGuidedTracks(bus) || busIsOnOutwardComponentEdge(bus))
333
362
  ) {
@@ -368,6 +397,17 @@ function prioritizeLayerAssignment(params: {
368
397
  ].slice(0, maxAssignments)
369
398
  }
370
399
 
400
+ function busUsesCoordinatedWinding(bus: PreparedBus): boolean {
401
+ return Boolean(
402
+ bus.exitEdge &&
403
+ bus.termination.type === "boundary" &&
404
+ bus.connections.length > 0 &&
405
+ bus.connections.every(
406
+ (connection) => connection.hasExplicitLayeredExitTarget === true,
407
+ ),
408
+ )
409
+ }
410
+
371
411
  function getCandidateEscapeLayersForBus(params: {
372
412
  bus: PreparedBus
373
413
  srj: SimpleRouteJson
@@ -380,6 +420,10 @@ function getCandidateEscapeLayersForBus(params: {
380
420
  busAllowedLayers === undefined
381
421
  ? config.escapeLayers
382
422
  : config.escapeLayers.filter((layer) => busAllowedLayers.includes(layer))
423
+ // A coordinated winding route is deliberately planned with the other buses'
424
+ // committed escape vias present. Testing it in isolation is both expensive
425
+ // and can reject a layer whose shared via field guides a valid bus ordering.
426
+ if (busUsesCoordinatedWinding(bus)) return allowedEscapeLayers
383
427
  const individuallyRoutableLayers = allowedEscapeLayers.filter(
384
428
  (targetLayer) =>
385
429
  routeBus({
@@ -453,6 +497,19 @@ export class FanoutSolver extends BaseSolver {
453
497
  this.preparedBuses = prepareFanoutBuses(this.routingSrj, options)
454
498
  validateCornerBandCapacities(this.preparedBuses, this.config)
455
499
  for (const bus of this.preparedBuses) {
500
+ for (const connection of bus.connections) {
501
+ if (!connection.hasExplicitLayeredExitTarget) continue
502
+ const targetLayer = connection.exitTargetPoint?.layer
503
+ if (
504
+ typeof targetLayer !== "string" ||
505
+ targetLayer.length === 0 ||
506
+ !this.config.layerNames.includes(targetLayer)
507
+ ) {
508
+ throw new Error(
509
+ `FanoutSolver: connection exit target for "${connection.connection.name}" uses unavailable layer "${String(targetLayer)}"`,
510
+ )
511
+ }
512
+ }
456
513
  for (const allowedLayer of bus.allowedLayers ?? []) {
457
514
  if (!this.config.layerNames.includes(allowedLayer)) {
458
515
  throw new Error(
@@ -471,6 +528,9 @@ export class FanoutSolver extends BaseSolver {
471
528
  `FanoutSolver: bus "${bus.busId}" has no allowed layer in escapeLayers`,
472
529
  )
473
530
  }
531
+ bus.routableEscapeLayers = this.config.escapeLayers.filter(
532
+ (layer) => bus.allowedLayers?.includes(layer) ?? true,
533
+ )
474
534
  if (bus.termination.type !== "plane") continue
475
535
  const planeLayer = bus.termination.layer
476
536
  if (!this.config.layerNames.includes(planeLayer)) {
@@ -637,10 +697,22 @@ export class FanoutSolver extends BaseSolver {
637
697
  failedBusIds.push(...this.preparedBuses.map((bus) => bus.busId))
638
698
  }
639
699
  }
640
- const busesInRoutingOrder = [...this.preparedBuses].sort(
641
- (a, b) =>
700
+ const busesInRoutingOrder = [...this.preparedBuses].sort((a, b) => {
701
+ const aUsesCoordinatedWinding = busUsesCoordinatedWinding(a)
702
+ const bUsesCoordinatedWinding = busUsesCoordinatedWinding(b)
703
+ const aLayerIndex = this.config.layerNames.indexOf(
704
+ busLayerAssignments[a.busId] ?? "",
705
+ )
706
+ const bLayerIndex = this.config.layerNames.indexOf(
707
+ busLayerAssignments[b.busId] ?? "",
708
+ )
709
+ return (
642
710
  Number(b.termination.type === "plane") -
643
711
  Number(a.termination.type === "plane") ||
712
+ Number(bUsesCoordinatedWinding) - Number(aUsesCoordinatedWinding) ||
713
+ (aUsesCoordinatedWinding && bUsesCoordinatedWinding
714
+ ? bLayerIndex - aLayerIndex
715
+ : 0) ||
644
716
  (routingStrategy === "group-by-layer"
645
717
  ? (busLayerAssignments[a.busId] ?? "").localeCompare(
646
718
  busLayerAssignments[b.busId] ?? "",
@@ -652,8 +724,9 @@ export class FanoutSolver extends BaseSolver {
652
724
  : b.connections.length - a.connections.length ||
653
725
  (routingStrategy === "deep-first"
654
726
  ? getBusDistanceToBoundary(b) - getBusDistanceToBoundary(a)
655
- : getBusDistanceToBoundary(a) - getBusDistanceToBoundary(b))),
656
- )
727
+ : getBusDistanceToBoundary(a) - getBusDistanceToBoundary(b)))
728
+ )
729
+ })
657
730
 
658
731
  let routingPrefixKey = `${routingStrategy}|`
659
732
  for (const bus of useSingleLayerPushAndShove ? [] : busesInRoutingOrder) {
@@ -780,7 +853,8 @@ export class FanoutSolver extends BaseSolver {
780
853
  )
781
854
  if (
782
855
  bestAttempt.summary.routedConnectionCount ===
783
- this.inputSrj.connections.length
856
+ this.inputSrj.connections.length &&
857
+ this.getCoordinatedAdditionalViaCount(bestAttempt.plans) === 0
784
858
  ) {
785
859
  return bestAttempt
786
860
  }
@@ -791,12 +865,13 @@ export class FanoutSolver extends BaseSolver {
791
865
  busLayerAssignments,
792
866
  routingStrategy,
793
867
  )
794
- if (attempt.summary.score < bestAttempt.summary.score) {
868
+ if (this.isAttemptBetter(attempt, bestAttempt)) {
795
869
  bestAttempt = attempt
796
870
  }
797
871
  if (
798
872
  bestAttempt.summary.routedConnectionCount ===
799
- this.inputSrj.connections.length
873
+ this.inputSrj.connections.length &&
874
+ this.getCoordinatedAdditionalViaCount(bestAttempt.plans) === 0
800
875
  ) {
801
876
  return bestAttempt
802
877
  }
@@ -829,7 +904,24 @@ export class FanoutSolver extends BaseSolver {
829
904
  return null
830
905
  }
831
906
 
907
+ const getMaximumViaSpan = (bus: PreparedBus): number => {
908
+ const sourceLayerIndex = this.config.layerNames.indexOf(
909
+ bus.connections[0]?.sourceLayer ?? "",
910
+ )
911
+ const candidateLayers =
912
+ bus.termination.type === "plane"
913
+ ? [bus.termination.layer]
914
+ : (this.escapeLayersByBusId[bus.busId] ?? this.config.escapeLayers)
915
+ return Math.max(
916
+ 0,
917
+ ...candidateLayers.map((layer) =>
918
+ Math.abs(this.config.layerNames.indexOf(layer) - sourceLayerIndex),
919
+ ),
920
+ )
921
+ }
832
922
  const busesInSearchOrder = [...this.preparedBuses].sort((a, b) => {
923
+ const aUsesCoordinatedWinding = busUsesCoordinatedWinding(a)
924
+ const bUsesCoordinatedWinding = busUsesCoordinatedWinding(b)
833
925
  const aLayerCount =
834
926
  a.termination.type === "plane"
835
927
  ? 1
@@ -843,6 +935,10 @@ export class FanoutSolver extends BaseSolver {
843
935
  return (
844
936
  Number(b.termination.type === "plane") -
845
937
  Number(a.termination.type === "plane") ||
938
+ Number(bUsesCoordinatedWinding) - Number(aUsesCoordinatedWinding) ||
939
+ (aUsesCoordinatedWinding && bUsesCoordinatedWinding
940
+ ? getMaximumViaSpan(b) - getMaximumViaSpan(a)
941
+ : 0) ||
846
942
  (groupByDirection ? a.direction.localeCompare(b.direction) : 0) ||
847
943
  aLayerCount - bLayerCount ||
848
944
  b.componentObstacles.length - a.componentObstacles.length ||
@@ -880,7 +976,9 @@ export class FanoutSolver extends BaseSolver {
880
976
  return count
881
977
  }
882
978
  const sourceLayer = bus.connections[0]?.sourceLayer
883
- return state.assignment[bus.busId] === sourceLayer
979
+ const preferredLayer =
980
+ getCommonExplicitExitTargetLayer(bus) ?? sourceLayer
981
+ return state.assignment[bus.busId] === preferredLayer
884
982
  ? count
885
983
  : count + bus.connections.length
886
984
  },
@@ -920,10 +1018,13 @@ export class FanoutSolver extends BaseSolver {
920
1018
  )
921
1019
  }
922
1020
  const sourceLayer = bus.connections[0]?.sourceLayer
1021
+ const commonExitTargetLayer = getCommonExplicitExitTargetLayer(bus)
923
1022
  const preferSourceLayer = busUsesDestinationGuidedTracks(bus)
924
1023
  const orderedLayers = candidateLayers.toSorted(
925
1024
  (first, second) =>
926
1025
  (layerLoads.get(first) ?? 0) - (layerLoads.get(second) ?? 0) ||
1026
+ Number(second === commonExitTargetLayer) -
1027
+ Number(first === commonExitTargetLayer) ||
927
1028
  (preferSourceLayer
928
1029
  ? Number(second === sourceLayer) - Number(first === sourceLayer)
929
1030
  : Number(first === sourceLayer) -
@@ -963,6 +1064,10 @@ export class FanoutSolver extends BaseSolver {
963
1064
 
964
1065
  if (nextStates.length === 0) return null
965
1066
  nextStates.sort((first, second) => {
1067
+ const additionalViaDifference =
1068
+ this.getCoordinatedAdditionalViaCount(first.plans) -
1069
+ this.getCoordinatedAdditionalViaCount(second.plans)
1070
+ if (additionalViaDifference !== 0) return additionalViaDifference
966
1071
  const scoreDifference = getStateScore(first) - getStateScore(second)
967
1072
  if (Math.abs(scoreDifference) > 1e-9) return scoreDifference
968
1073
  return JSON.stringify(first.assignment).localeCompare(
@@ -981,19 +1086,23 @@ export class FanoutSolver extends BaseSolver {
981
1086
  }
982
1087
  }
983
1088
 
984
- const bestState = states[0]
985
- if (!bestState) return null
986
- const outputSrj = buildOutputSimpleRouteJson({
987
- inputSrj: this.inputSrj,
988
- plans: bestState.plans,
989
- layerNames: this.config.layerNames,
990
- })
991
- if (
992
- bestState.plans.length === this.inputSrj.connections.length &&
993
- !this.validateCompletePlans(bestState.plans, outputSrj).valid
994
- ) {
995
- return null
1089
+ let bestState: GroupedBeamState | undefined
1090
+ let outputSrj: SimpleRouteJson | undefined
1091
+ for (const state of states) {
1092
+ if (state.plans.length !== this.inputSrj.connections.length) continue
1093
+ const candidateOutput = buildOutputSimpleRouteJson({
1094
+ inputSrj: this.inputSrj,
1095
+ plans: state.plans,
1096
+ layerNames: this.config.layerNames,
1097
+ })
1098
+ if (!this.validateCompletePlans(state.plans, candidateOutput).valid) {
1099
+ continue
1100
+ }
1101
+ bestState = state
1102
+ outputSrj = candidateOutput
1103
+ break
996
1104
  }
1105
+ if (!bestState || !outputSrj) return null
997
1106
  const score =
998
1107
  bestState.plans.length === this.inputSrj.connections.length
999
1108
  ? bestState.plans.reduce((total, plan) => total + plan.length, 0) +
@@ -1116,6 +1225,66 @@ export class FanoutSolver extends BaseSolver {
1116
1225
  )
1117
1226
  }
1118
1227
 
1228
+ private getCoordinatedAdditionalViaCount(
1229
+ plans: readonly FanoutRoutePlan[],
1230
+ ): number {
1231
+ const coordinatedBusIds = new Set(
1232
+ this.preparedBuses
1233
+ .filter(busUsesCoordinatedWinding)
1234
+ .map((bus) => bus.busId),
1235
+ )
1236
+ return plans.reduce(
1237
+ (count, plan) =>
1238
+ count +
1239
+ (coordinatedBusIds.has(plan.busId)
1240
+ ? (plan.additionalVias?.length ?? 0)
1241
+ : 0),
1242
+ 0,
1243
+ )
1244
+ }
1245
+
1246
+ private isAttemptBetter(
1247
+ candidate: AssignmentAttempt,
1248
+ current: AssignmentAttempt,
1249
+ ): boolean {
1250
+ if (
1251
+ candidate.summary.routedConnectionCount !==
1252
+ current.summary.routedConnectionCount
1253
+ ) {
1254
+ return (
1255
+ candidate.summary.routedConnectionCount >
1256
+ current.summary.routedConnectionCount
1257
+ )
1258
+ }
1259
+ if (candidate.summary.routedBusCount !== current.summary.routedBusCount) {
1260
+ return candidate.summary.routedBusCount > current.summary.routedBusCount
1261
+ }
1262
+ const candidateAdditionalVias = this.getCoordinatedAdditionalViaCount(
1263
+ candidate.plans,
1264
+ )
1265
+ const currentAdditionalVias = this.getCoordinatedAdditionalViaCount(
1266
+ current.plans,
1267
+ )
1268
+ if (candidateAdditionalVias !== currentAdditionalVias) {
1269
+ return candidateAdditionalVias < currentAdditionalVias
1270
+ }
1271
+ return candidate.summary.score < current.summary.score
1272
+ }
1273
+
1274
+ private hasGloballyViaMinimalBestAttempt(): boolean {
1275
+ if (!this.hasCompleteBestAttempt() || !this.bestAttempt) return false
1276
+ if (
1277
+ this.preparedBuses.length === 0 ||
1278
+ !this.preparedBuses.every(busUsesCoordinatedWinding)
1279
+ ) {
1280
+ return false
1281
+ }
1282
+ return this.bestAttempt.plans.every(
1283
+ (plan) =>
1284
+ plan.via !== undefined && (plan.additionalVias?.length ?? 0) === 0,
1285
+ )
1286
+ }
1287
+
1119
1288
  private shouldEvaluateGroupedBeam(): boolean {
1120
1289
  if (this.groupedBeamEvaluated || this.nextAssignmentIndex === 0) {
1121
1290
  return false
@@ -1130,6 +1299,14 @@ export class FanoutSolver extends BaseSolver {
1130
1299
  }
1131
1300
 
1132
1301
  override _step(): void {
1302
+ if (
1303
+ this.nextAssignmentIndex > 0 &&
1304
+ this.hasGloballyViaMinimalBestAttempt()
1305
+ ) {
1306
+ this.completeBestAttemptEndpoints()
1307
+ this.solved = true
1308
+ return
1309
+ }
1133
1310
  // Try the deterministic assignment and only its targeted repair queue
1134
1311
  // before paying for the grouped beam. If the beam cannot solve, continue
1135
1312
  // with the broader generated-assignment search below.
@@ -1143,7 +1320,7 @@ export class FanoutSolver extends BaseSolver {
1143
1320
  this.attempts.push(beamAttempt.summary)
1144
1321
  if (
1145
1322
  !this.bestAttempt ||
1146
- beamAttempt.summary.score < this.bestAttempt.summary.score
1323
+ this.isAttemptBetter(beamAttempt, this.bestAttempt)
1147
1324
  ) {
1148
1325
  this.bestAttempt = beamAttempt
1149
1326
  }
@@ -1159,11 +1336,19 @@ export class FanoutSolver extends BaseSolver {
1159
1336
  failedBuses: "none",
1160
1337
  bestScore: bestSummary.score,
1161
1338
  }
1162
- this.completeBestAttemptEndpoints()
1163
- this.solved = true
1164
- return
1339
+ if (
1340
+ this.getCoordinatedAdditionalViaCount(this.bestAttempt.plans) === 0
1341
+ ) {
1342
+ this.completeBestAttemptEndpoints()
1343
+ this.solved = true
1344
+ return
1345
+ }
1165
1346
  }
1166
- if (this.hasCompleteBestAttempt()) {
1347
+ if (
1348
+ this.hasCompleteBestAttempt() &&
1349
+ this.bestAttempt &&
1350
+ this.getCoordinatedAdditionalViaCount(this.bestAttempt.plans) === 0
1351
+ ) {
1167
1352
  this.completeBestAttemptEndpoints()
1168
1353
  this.solved = true
1169
1354
  return
@@ -1237,10 +1422,7 @@ export class FanoutSolver extends BaseSolver {
1237
1422
  )
1238
1423
  }
1239
1424
  this.attempts.push(attempt.summary)
1240
- if (
1241
- !this.bestAttempt ||
1242
- attempt.summary.score < this.bestAttempt.summary.score
1243
- ) {
1425
+ if (!this.bestAttempt || this.isAttemptBetter(attempt, this.bestAttempt)) {
1244
1426
  this.bestAttempt = attempt
1245
1427
  }
1246
1428
  this.stats = {
@@ -1253,7 +1435,9 @@ export class FanoutSolver extends BaseSolver {
1253
1435
  }
1254
1436
  if (
1255
1437
  this.groupedBeamEvaluated &&
1256
- attempt.summary.routedConnectionCount === this.inputSrj.connections.length
1438
+ attempt.summary.routedConnectionCount ===
1439
+ this.inputSrj.connections.length &&
1440
+ this.getCoordinatedAdditionalViaCount(this.bestAttempt.plans) === 0
1257
1441
  ) {
1258
1442
  this.completeBestAttemptEndpoints()
1259
1443
  this.solved = true
@@ -819,7 +819,7 @@ function prepareConnection(params: {
819
819
  sourceGrid: ComponentGrid
820
820
  componentGrids: ComponentGrid[]
821
821
  termination: FanoutBusTermination
822
- exitTargetPoint?: { x: number; y: number }
822
+ exitTargetPoint?: { x: number; y: number; layer?: string }
823
823
  }): PreparedConnection {
824
824
  const {
825
825
  connection,
@@ -863,7 +863,11 @@ function prepareConnection(params: {
863
863
  sourceLayer,
864
864
  sourceObstacle: sourceMatch.obstacle,
865
865
  targetPoint,
866
- exitTargetPoint: exitTargetPoint ?? targetPoint,
866
+ exitTargetPoint: exitTargetPoint ?? {
867
+ x: targetPoint.x,
868
+ y: targetPoint.y,
869
+ },
870
+ hasExplicitLayeredExitTarget: exitTargetPoint?.layer !== undefined,
867
871
  }
868
872
  }
869
873
  throw new Error(