diff --git a/lib/section-solver/TinyHyperGraphSectionPipelineSolver.ts b/lib/section-solver/TinyHyperGraphSectionPipelineSolver.ts index fdc3ce5..fbfc6c7 100644 --- a/lib/section-solver/TinyHyperGraphSectionPipelineSolver.ts +++ b/lib/section-solver/TinyHyperGraphSectionPipelineSolver.ts @@ -1,6 +1,10 @@ import type { SerializedHyperGraph } from "@tscircuit/hypergraph" import { BasePipelineSolver } from "@tscircuit/solver-utils" import type { GraphicsObject } from "graphics-debug" +import { cpus, tmpdir } from "node:os" +import { mkdtempSync, readFileSync, rmSync } from "node:fs" +import { join } from "node:path" +import { Worker } from "node:worker_threads" import { loadSerializedHyperGraph } from "../compat/loadSerializedHyperGraph" import type { TinyHyperGraphProblem, @@ -42,6 +46,7 @@ type SectionMaskCandidate = { } type AutomaticSectionSearchResult = { + skipped: boolean portSectionMask: Int8Array baselineMaxRegionCost: number finalMaxRegionCost: number @@ -58,6 +63,89 @@ type AutomaticSectionSearchResult = { winningCandidateFamily?: TinyHyperGraphSectionCandidateFamily } +type ParallelSectionSearchWorkerInput = { + topology: TinyHyperGraphTopology + problem: TinyHyperGraphProblem + solution: TinyHyperGraphSolution + sectionSolverOptions: TinyHyperGraphSectionSolverOptions + baselineMaxRegionCost: number + candidates: SectionMaskCandidate[] + doneSignal?: Int32Array + resultPath?: string +} + +type ParallelSectionSearchWorkerResult = { + bestFinalMaxRegionCost: number + bestPortSectionMask: Int8Array + winningCandidateLabel?: string + winningCandidateFamily?: TinyHyperGraphSectionCandidateFamily + generatedCandidateCount: number + candidateCount: number + duplicateCandidateCount: number + candidateEligibilityMs: number + candidateInitMs: number + candidateSolveMs: number + candidateReplayScoreMs: number + improvementEntries: ImprovementEntry[] +} + +type ImprovementEntry = { + label: string + family: TinyHyperGraphSectionCandidateFamily + regionIds: RegionId[] + improvement: number + portSectionMask: Int8Array +} + +const shareInt32Array = (array: Int32Array) => { + const shared = new Int32Array(new SharedArrayBuffer(array.byteLength)) + shared.set(array) + return shared +} + +const shareFloat64Array = (array: Float64Array) => { + const shared = new Float64Array(new SharedArrayBuffer(array.byteLength)) + shared.set(array) + return shared +} + +const shareInt8Array = (array: Int8Array) => { + const shared = new Int8Array(new SharedArrayBuffer(array.byteLength)) + shared.set(array) + return shared +} + +const createSharedWorkerTopology = ( + topology: TinyHyperGraphTopology, +): TinyHyperGraphTopology => ({ + ...topology, + regionWidth: shareFloat64Array(topology.regionWidth), + regionHeight: shareFloat64Array(topology.regionHeight), + regionCenterX: shareFloat64Array(topology.regionCenterX), + regionCenterY: shareFloat64Array(topology.regionCenterY), + regionAvailableZMask: topology.regionAvailableZMask + ? shareInt32Array(topology.regionAvailableZMask) + : undefined, + portAngleForRegion1: shareInt32Array(topology.portAngleForRegion1), + portAngleForRegion2: topology.portAngleForRegion2 + ? shareInt32Array(topology.portAngleForRegion2) + : undefined, + portX: shareFloat64Array(topology.portX), + portY: shareFloat64Array(topology.portY), + portZ: shareInt32Array(topology.portZ), +}) + +const createSharedWorkerProblem = ( + problem: TinyHyperGraphProblem, +): TinyHyperGraphProblem => ({ + ...problem, + portSectionMask: shareInt8Array(problem.portSectionMask), + routeStartPort: shareInt32Array(problem.routeStartPort), + routeEndPort: shareInt32Array(problem.routeEndPort), + routeNet: shareInt32Array(problem.routeNet), + regionNetId: shareInt32Array(problem.regionNetId), +}) + const DEFAULT_SOLVE_GRAPH_OPTIONS: TinyHyperGraphSolverOptions = { RIP_THRESHOLD_RAMP_ATTEMPTS: 5, } @@ -79,6 +167,7 @@ const DEFAULT_CANDIDATE_FAMILIES: TinyHyperGraphSectionCandidateFamily[] = [ "twohop-touch", ] const DEFAULT_MAX_HOT_REGIONS = 2 +const DEFAULT_MIN_BASELINE_MAX_REGION_COST_TO_SEARCH = 0.4 const IMPROVEMENT_EPSILON = 1e-9 @@ -153,12 +242,59 @@ const createProblemWithPortSectionMask = ( routeCount: problem.routeCount, portSectionMask, routeMetadata: problem.routeMetadata, - routeStartPort: new Int32Array(problem.routeStartPort), - routeEndPort: new Int32Array(problem.routeEndPort), - routeNet: new Int32Array(problem.routeNet), - regionNetId: new Int32Array(problem.regionNetId), + routeStartPort: problem.routeStartPort, + routeEndPort: problem.routeEndPort, + routeNet: problem.routeNet, + regionNetId: problem.regionNetId, }) +const getMergedMask = (portCount: number, masks: Int8Array[]) => { + const merged = new Int8Array(portCount) + + for (const mask of masks) { + for (let portId = 0; portId < portCount; portId++) { + if (mask[portId] === 1) { + merged[portId] = 1 + } + } + } + + return merged +} + +const selectNonOverlappingImprovements = ( + portCount: number, + improvements: ImprovementEntry[], +) => { + const selectedRegionIds = new Set() + const selected: ImprovementEntry[] = [] + + for (const entry of improvements.sort( + (left, right) => right.improvement - left.improvement, + )) { + if (entry.improvement <= IMPROVEMENT_EPSILON) { + continue + } + + if (entry.regionIds.some((regionId) => selectedRegionIds.has(regionId))) { + continue + } + + selected.push(entry) + for (const regionId of entry.regionIds) { + selectedRegionIds.add(regionId) + } + } + + return { + selected, + mergedMask: getMergedMask( + portCount, + selected.map((entry) => entry.portSectionMask), + ), + } +} + const getSectionMaskCandidates = ( solvedSolver: TinyHyperGraphSolver, topology: TinyHyperGraphTopology, @@ -225,6 +361,72 @@ const getSectionMaskCandidates = ( return candidates } +type CandidateChunkWorkerJob = { + worker: Worker + doneSignal: Int32Array + tempDir: string + resultPath: string + error?: Error +} + +const startCandidateChunkWorker = ( + input: ParallelSectionSearchWorkerInput, +): CandidateChunkWorkerJob => { + const tempDir = mkdtempSync(join(tmpdir(), "section-worker-")) + const resultPath = join(tempDir, "result.json") + const doneSignal = new Int32Array(new SharedArrayBuffer(4)) + const worker = new Worker( + new URL("./parallelSectionSearchWorker.ts", import.meta.url), + ) + const job: CandidateChunkWorkerJob = { + worker, + doneSignal, + tempDir, + resultPath, + } + + worker.on("error", (workerError) => { + job.error = workerError as Error + Atomics.store(doneSignal, 0, 1) + Atomics.notify(doneSignal, 0) + }) + worker.postMessage({ ...input, doneSignal, resultPath }) + return job +} + +const finishCandidateChunkWorker = ( + job: CandidateChunkWorkerJob, +): ParallelSectionSearchWorkerResult => { + const { doneSignal, worker, tempDir, resultPath } = job + Atomics.wait(doneSignal, 0, 0) + void worker.terminate() + + if (job.error) { + rmSync(tempDir, { recursive: true, force: true }) + throw job.error + } + + const parsedResult = JSON.parse( + readFileSync(resultPath, "utf8"), + ) as ParallelSectionSearchWorkerResult & { + bestPortSectionMask: number[] + improvementEntries: Array & { portSectionMask: number[] }> + } + rmSync(tempDir, { recursive: true, force: true }) + + return { + ...parsedResult, + bestPortSectionMask: Int8Array.from(parsedResult.bestPortSectionMask), + improvementEntries: parsedResult.improvementEntries.map((entry) => ({ + ...entry, + portSectionMask: Int8Array.from(entry.portSectionMask), + })), + } +} + +const getWorkerCount = (candidateCount: number) => + Math.max(1, Math.min(candidateCount, cpus().length)) + const findBestAutomaticSectionMask = ( solvedSolver: TinyHyperGraphSolver, topology: TinyHyperGraphTopology, @@ -235,16 +437,29 @@ const findBestAutomaticSectionMask = ( ): AutomaticSectionSearchResult => { const searchStartTime = performance.now() const baselineEvaluationStartTime = performance.now() - const baselineSectionSolver = new TinyHyperGraphSectionSolver( - topology, - problem, - solution, - sectionSolverOptions, - ) - const baselineMaxRegionCost = getMaxRegionCost( - baselineSectionSolver.baselineSolver, - ) + const baselineMaxRegionCost = getMaxRegionCost(solvedSolver) const baselineEvaluationMs = performance.now() - baselineEvaluationStartTime + const minBaselineMaxRegionCostToSearch = + searchConfig?.minBaselineMaxRegionCostToSearch ?? + DEFAULT_MIN_BASELINE_MAX_REGION_COST_TO_SEARCH + + if (baselineMaxRegionCost < minBaselineMaxRegionCostToSearch) { + return { + skipped: true, + portSectionMask: new Int8Array(topology.portCount), + baselineMaxRegionCost, + finalMaxRegionCost: baselineMaxRegionCost, + generatedCandidateCount: 0, + candidateCount: 0, + duplicateCandidateCount: 0, + totalMs: performance.now() - searchStartTime, + baselineEvaluationMs, + candidateEligibilityMs: 0, + candidateInitMs: 0, + candidateSolveMs: 0, + candidateReplayScoreMs: 0, + } + } let bestFinalMaxRegionCost = baselineMaxRegionCost let bestPortSectionMask = new Int8Array(topology.portCount) @@ -263,6 +478,8 @@ const findBestAutomaticSectionMask = ( sectionSolverOptions.MAX_HOT_REGIONS ?? DEFAULT_MAX_HOT_REGIONS + const uniqueCandidates: SectionMaskCandidate[] = [] + for (const candidate of getSectionMaskCandidates( solvedSolver, topology, @@ -286,68 +503,90 @@ const findBestAutomaticSectionMask = ( } seenPortSectionMasks.add(portSectionMaskKey) + uniqueCandidates.push(candidate) + } + if (uniqueCandidates.length > 0) { try { - const eligibilityStartTime = performance.now() - const activeRouteIds = getActiveSectionRouteIds( - topology, - candidateProblem, - solution, - ) - candidateEligibilityMs += performance.now() - eligibilityStartTime + const workerCount = getWorkerCount(uniqueCandidates.length) + const chunkSize = Math.ceil(uniqueCandidates.length / workerCount) + const workerInputs: ParallelSectionSearchWorkerInput[] = [] + const sharedTopology = createSharedWorkerTopology(topology) + const sharedProblem = createSharedWorkerProblem(problem) + + for (let workerIndex = 0; workerIndex < workerCount; workerIndex++) { + const start = workerIndex * chunkSize + const end = start + chunkSize + const chunkCandidates = uniqueCandidates.slice(start, end) + + if (chunkCandidates.length === 0) { + continue + } - if (activeRouteIds.length === 0) { - continue + workerInputs.push({ + topology: sharedTopology, + problem: sharedProblem, + solution, + sectionSolverOptions, + baselineMaxRegionCost, + candidates: chunkCandidates, + }) } - candidateCount += 1 - - const candidateInitStartTime = performance.now() - const sectionSolver = new TinyHyperGraphSectionSolver( - topology, - candidateProblem, - solution, - sectionSolverOptions, + const workerJobs = workerInputs.map((workerInput) => + startCandidateChunkWorker(workerInput), ) - candidateInitMs += performance.now() - candidateInitStartTime - - const candidateSolveStartTime = performance.now() - sectionSolver.solve() - candidateSolveMs += performance.now() - candidateSolveStartTime - - if (sectionSolver.failed || !sectionSolver.solved) { - continue + const workerResults = workerJobs.map((workerJob) => + finishCandidateChunkWorker(workerJob), + ) + const improvements: ImprovementEntry[] = [] + + for (const workerResult of workerResults) { + candidateCount += workerResult.candidateCount + duplicateCandidateCount += workerResult.duplicateCandidateCount + candidateEligibilityMs += workerResult.candidateEligibilityMs + candidateInitMs += workerResult.candidateInitMs + candidateSolveMs += workerResult.candidateSolveMs + candidateReplayScoreMs += workerResult.candidateReplayScoreMs + improvements.push(...workerResult.improvementEntries) } - const finalMaxRegionCost = Number( - sectionSolver.stats.finalMaxRegionCost ?? - getMaxRegionCost(sectionSolver.getSolvedSolver()), + const selected = selectNonOverlappingImprovements( + topology.portCount, + improvements, ) - if (finalMaxRegionCost < bestFinalMaxRegionCost - IMPROVEMENT_EPSILON) { - const candidateReplayScoreStartTime = performance.now() - const replayedFinalMaxRegionCost = getSerializedOutputMaxRegionCost( - sectionSolver.getOutput(), + if (selected.selected.length > 0) { + bestPortSectionMask = selected.mergedMask + const mergedProblem = createProblemWithPortSectionMask( + problem, + selected.mergedMask, ) - candidateReplayScoreMs += - performance.now() - candidateReplayScoreStartTime - - if ( - replayedFinalMaxRegionCost < - bestFinalMaxRegionCost - IMPROVEMENT_EPSILON - ) { - bestFinalMaxRegionCost = replayedFinalMaxRegionCost - bestPortSectionMask = new Int8Array(candidateProblem.portSectionMask) - winningCandidateLabel = candidate.label - winningCandidateFamily = candidate.family + const mergedSectionSolver = new TinyHyperGraphSectionSolver( + topology, + mergedProblem, + solution, + sectionSolverOptions, + ) + + mergedSectionSolver.solve() + + if (mergedSectionSolver.solved && !mergedSectionSolver.failed) { + bestFinalMaxRegionCost = getSerializedOutputMaxRegionCost( + mergedSectionSolver.getOutput(), + ) + const winner = selected.selected[0] + winningCandidateLabel = winner?.label + winningCandidateFamily = winner?.family } } } catch { - // Skip invalid section masks that split a route into multiple spans. + // Fall back to baseline when worker-based parallel search fails. } } return { + skipped: false, portSectionMask: bestPortSectionMask, baselineMaxRegionCost, finalMaxRegionCost: bestFinalMaxRegionCost, @@ -377,6 +616,11 @@ export interface TinyHyperGraphSectionMaskContext { export interface TinyHyperGraphSectionPipelineSearchConfig { maxHotRegions?: number candidateFamilies?: TinyHyperGraphSectionCandidateFamily[] + /** + * Skip automatic section-search when the solved baseline max region cost is + * already below this threshold. This avoids large overhead on easy circuits. + */ + minBaselineMaxRegionCostToSearch?: number } export interface TinyHyperGraphSectionPipelineInput { @@ -392,6 +636,7 @@ export class TinyHyperGraphSectionPipelineSolver extends BasePipelineSolver("optimizeSection") ?? - this.getStageOutput("solveGraph") ?? - null + const solveGraphOutput = + this.getStageOutput("solveGraph") ?? null + const optimizeSectionOutput = + this.getStageOutput("optimizeSection") ?? null + + if (!optimizeSectionOutput) { + return solveGraphOutput + } + + if (!solveGraphOutput) { + return optimizeSectionOutput + } + + if (this.sectionSearchSkipped) { + return solveGraphOutput + } + + const solveGraphMaxRegionCost = getSerializedOutputMaxRegionCost( + solveGraphOutput, + ) + const optimizeSectionMaxRegionCost = getSerializedOutputMaxRegionCost( + optimizeSectionOutput, ) + + return optimizeSectionMaxRegionCost <= solveGraphMaxRegionCost + ? optimizeSectionOutput + : solveGraphOutput } } diff --git a/lib/section-solver/parallelSectionSearchWorker.ts b/lib/section-solver/parallelSectionSearchWorker.ts new file mode 100644 index 0000000..55929b0 --- /dev/null +++ b/lib/section-solver/parallelSectionSearchWorker.ts @@ -0,0 +1,240 @@ +import { parentPort } from "node:worker_threads" +import type { SerializedHyperGraph } from "@tscircuit/hypergraph" +import { writeFileSync } from "node:fs" +import { loadSerializedHyperGraph } from "../compat/loadSerializedHyperGraph" +import type { + TinyHyperGraphProblem, + TinyHyperGraphSolution, + TinyHyperGraphTopology, +} from "../core" +import type { RegionId } from "../types" +import type { RegionIntersectionCache } from "../types" +import { + getActiveSectionRouteIds, + TinyHyperGraphSectionSolver, + type TinyHyperGraphSectionSolverOptions, +} from "./index" +import type { + TinyHyperGraphSectionCandidateFamily, +} from "./TinyHyperGraphSectionPipelineSolver" + +type SectionMaskCandidate = { + label: string + family: TinyHyperGraphSectionCandidateFamily + regionIds: RegionId[] + portSelectionRule: "touches-selected-region" | "all-incident-regions-selected" +} + +type ParallelSectionSearchWorkerInput = { + topology: TinyHyperGraphTopology + problem: TinyHyperGraphProblem + solution: TinyHyperGraphSolution + sectionSolverOptions: TinyHyperGraphSectionSolverOptions + baselineMaxRegionCost: number + candidates: SectionMaskCandidate[] + doneSignal?: Int32Array + resultPath?: string +} + +type ImprovementEntry = { + label: string + family: TinyHyperGraphSectionCandidateFamily + regionIds: RegionId[] + improvement: number + portSectionMask: Int8Array +} + +type ParallelSectionSearchWorkerResult = { + bestFinalMaxRegionCost: number + bestPortSectionMask: Int8Array + winningCandidateLabel?: string + winningCandidateFamily?: TinyHyperGraphSectionCandidateFamily + generatedCandidateCount: number + candidateCount: number + duplicateCandidateCount: number + candidateEligibilityMs: number + candidateInitMs: number + candidateSolveMs: number + candidateReplayScoreMs: number + improvementEntries: ImprovementEntry[] +} + +const IMPROVEMENT_EPSILON = 1e-9 + +const createPortSectionMaskForRegionIds = ( + topology: TinyHyperGraphTopology, + regionIds: RegionId[], + portSelectionRule: + | "touches-selected-region" + | "all-incident-regions-selected", +) => { + const selectedRegionIds = new Set(regionIds) + + return Int8Array.from({ length: topology.portCount }, (_, portId) => { + const incidentRegionIds = topology.incidentPortRegion[portId] ?? [] + + if (portSelectionRule === "touches-selected-region") { + return incidentRegionIds.some((regionId) => selectedRegionIds.has(regionId)) + ? 1 + : 0 + } + + return incidentRegionIds.length > 0 && + incidentRegionIds.every((regionId) => selectedRegionIds.has(regionId)) + ? 1 + : 0 + }) +} + +const createProblemWithPortSectionMask = ( + problem: TinyHyperGraphProblem, + portSectionMask: Int8Array, +): TinyHyperGraphProblem => ({ + routeCount: problem.routeCount, + portSectionMask, + routeMetadata: problem.routeMetadata, + routeStartPort: problem.routeStartPort, + routeEndPort: problem.routeEndPort, + routeNet: problem.routeNet, + regionNetId: problem.regionNetId, +}) + +const getSerializedOutputMaxRegionCost = ( + serializedOutput: SerializedHyperGraph, +) => { + const replay = loadSerializedHyperGraph(serializedOutput) + const replayedSolver = new TinyHyperGraphSectionSolver( + replay.topology, + replay.problem, + replay.solution, + ) + + return replayedSolver.baselineSolver.state.regionIntersectionCaches.reduce( + ( + maxRegionCost: number, + regionIntersectionCache: RegionIntersectionCache, + ) => + Math.max(maxRegionCost, regionIntersectionCache.existingRegionCost), + 0, + ) +} + +parentPort?.on("message", (input: ParallelSectionSearchWorkerInput) => { + let bestFinalMaxRegionCost = input.baselineMaxRegionCost + let bestPortSectionMask = new Int8Array(input.topology.portCount) + let winningCandidateLabel: string | undefined + let winningCandidateFamily: TinyHyperGraphSectionCandidateFamily | undefined + let candidateCount = 0 + let candidateEligibilityMs = 0 + let candidateInitMs = 0 + let candidateSolveMs = 0 + let candidateReplayScoreMs = 0 + const improvementEntries: ImprovementEntry[] = [] + + for (const candidate of input.candidates) { + const candidateProblem = createProblemWithPortSectionMask( + input.problem, + createPortSectionMaskForRegionIds( + input.topology, + candidate.regionIds, + candidate.portSelectionRule, + ), + ) + + try { + const eligibilityStartTime = performance.now() + const activeRouteIds = getActiveSectionRouteIds( + input.topology, + candidateProblem, + input.solution, + ) + candidateEligibilityMs += performance.now() - eligibilityStartTime + + if (activeRouteIds.length === 0) { + continue + } + + candidateCount += 1 + + const candidateInitStartTime = performance.now() + const sectionSolver = new TinyHyperGraphSectionSolver( + input.topology, + candidateProblem, + input.solution, + input.sectionSolverOptions, + ) + candidateInitMs += performance.now() - candidateInitStartTime + + const candidateSolveStartTime = performance.now() + sectionSolver.solve() + candidateSolveMs += performance.now() - candidateSolveStartTime + + if (sectionSolver.failed || !sectionSolver.solved) { + continue + } + + const candidateReplayScoreStartTime = performance.now() + const replayedFinalMaxRegionCost = getSerializedOutputMaxRegionCost( + sectionSolver.getOutput(), + ) + candidateReplayScoreMs += performance.now() - candidateReplayScoreStartTime + + const improvement = input.baselineMaxRegionCost - replayedFinalMaxRegionCost + + if (improvement > IMPROVEMENT_EPSILON) { + improvementEntries.push({ + label: candidate.label, + family: candidate.family, + regionIds: candidate.regionIds, + improvement, + portSectionMask: new Int8Array(candidateProblem.portSectionMask), + }) + } + + if (replayedFinalMaxRegionCost < bestFinalMaxRegionCost - IMPROVEMENT_EPSILON) { + bestFinalMaxRegionCost = replayedFinalMaxRegionCost + bestPortSectionMask = new Int8Array(candidateProblem.portSectionMask) + winningCandidateLabel = candidate.label + winningCandidateFamily = candidate.family + } + } catch { + // Skip invalid section masks that split a route into multiple spans. + } + } + + const result: ParallelSectionSearchWorkerResult = { + bestFinalMaxRegionCost, + bestPortSectionMask, + winningCandidateLabel, + winningCandidateFamily, + generatedCandidateCount: input.candidates.length, + candidateCount, + duplicateCandidateCount: 0, + candidateEligibilityMs, + candidateInitMs, + candidateSolveMs, + candidateReplayScoreMs, + improvementEntries, + } + + if (input.resultPath) { + writeFileSync( + input.resultPath, + JSON.stringify({ + ...result, + bestPortSectionMask: Array.from(result.bestPortSectionMask), + improvementEntries: result.improvementEntries.map((entry) => ({ + ...entry, + portSectionMask: Array.from(entry.portSectionMask), + })), + }), + ) + } + + if (input.doneSignal) { + Atomics.store(input.doneSignal, 0, 1) + Atomics.notify(input.doneSignal, 0) + } else { + parentPort?.postMessage(result) + } +}) diff --git a/tests/solver/section-solver.test.ts b/tests/solver/section-solver.test.ts index a06d131..bc3ccd4 100644 --- a/tests/solver/section-solver.test.ts +++ b/tests/solver/section-solver.test.ts @@ -232,3 +232,32 @@ test("section pipeline accepts MAX_HOT_REGIONS through sectionSolverOptions", () expect(pipelineSolver.stats.sectionSearchGeneratedCandidateCount).toBe(5) expect(pipelineSolver.stats.sectionSearchCandidateCount).toBeGreaterThan(0) }) + +test("section pipeline skips automatic search on low-baseline-cost circuits", () => { + const pipelineSolver = new TinyHyperGraphSectionPipelineSolver({ + serializedHyperGraph: datasetHg07.sample007, + }) + + pipelineSolver.solve() + + expect(pipelineSolver.solved).toBe(true) + expect(pipelineSolver.failed).toBe(false) + expect(pipelineSolver.stats.sectionSearchSkipped).toBe(true) + expect(pipelineSolver.stats.sectionSearchCandidateCount).toBe(0) + expect( + [...(pipelineSolver.selectedSectionMask ?? [])].every((value) => value === 0), + ).toBe(true) + + const solveGraphOutput = + pipelineSolver.getStageOutput>( + "solveGraph", + ) + const pipelineOutput = pipelineSolver.getOutput() + + expect(solveGraphOutput).toBeDefined() + expect(pipelineOutput).toBeDefined() + expect(getSerializedOutputMaxRegionCost(pipelineOutput!)).toBeCloseTo( + getSerializedOutputMaxRegionCost(solveGraphOutput!), + 10, + ) +})