diff --git a/.gitignore b/.gitignore index be4dafd..c624709 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules # output out dist +cosmos-export *.tgz # code coverage diff --git a/lib/core.ts b/lib/core.ts index 650cae5..fc7011e 100644 --- a/lib/core.ts +++ b/lib/core.ts @@ -210,6 +210,21 @@ export const getTinyHyperGraphSolverOptions = ( const compareCandidatesByF = (left: Candidate, right: Candidate) => left.f - right.f +const compareRegionCostSummaries = ( + left: RegionCostSummary, + right: RegionCostSummary, +) => { + if (left.totalRegionCost !== right.totalRegionCost) { + return left.totalRegionCost - right.totalRegionCost + } + + if (left.maxRegionCost !== right.maxRegionCost) { + return left.maxRegionCost - right.maxRegionCost + } + + return 0 +} + interface SegmentGeometryScratch { lesserAngle: number greaterAngle: number @@ -488,6 +503,36 @@ export class TinyHyperGraphSolver extends BaseSolver { ) } + getSharedRegionIdsForPorts(port1Id: PortId, port2Id: PortId): RegionId[] { + const port1IncidentRegions = this.topology.incidentPortRegion[port1Id] ?? [] + const port2IncidentRegions = this.topology.incidentPortRegion[port2Id] ?? [] + + return port1IncidentRegions.filter((regionId) => + port2IncidentRegions.includes(regionId), + ) + } + + getAlternateRegionIdForSegment( + regionId: RegionId, + port1Id: PortId, + port2Id: PortId, + ): RegionId | undefined { + const sharedRegionIds = this.getSharedRegionIdsForPorts(port1Id, port2Id) + + if (sharedRegionIds.length < 2 || !sharedRegionIds.includes(regionId)) { + return undefined + } + + return sharedRegionIds.find((sharedRegionId) => sharedRegionId !== regionId) + } + + canRebalanceRegionAssignment( + currentRegionId: RegionId, + nextRegionId: RegionId, + ): boolean { + return true + } + isPortReservedForDifferentNet(portId: PortId): boolean { const reservedNetIds = this.problemSetup.portEndpointNetIds[portId] if (!reservedNetIds) { @@ -511,7 +556,8 @@ export class TinyHyperGraphSolver extends BaseSolver { } isKnownSingleLayerRegion(regionId: RegionId): boolean { - const regionAvailableZMask = this.topology.regionAvailableZMask?.[regionId] ?? 0 + const regionAvailableZMask = + this.topology.regionAvailableZMask?.[regionId] ?? 0 return isKnownSingleLayerMask(regionAvailableZMask) } @@ -525,15 +571,17 @@ export class TinyHyperGraphSolver extends BaseSolver { const port1IncidentRegions = topology.incidentPortRegion[port1Id] const port2IncidentRegions = topology.incidentPortRegion[port2Id] const angle1 = - port1IncidentRegions[0] === regionId || port1IncidentRegions[1] !== regionId + port1IncidentRegions[0] === regionId || + port1IncidentRegions[1] !== regionId ? topology.portAngleForRegion1[port1Id] - : topology.portAngleForRegion2?.[port1Id] ?? - topology.portAngleForRegion1[port1Id] + : (topology.portAngleForRegion2?.[port1Id] ?? + topology.portAngleForRegion1[port1Id]) const angle2 = - port2IncidentRegions[0] === regionId || port2IncidentRegions[1] !== regionId + port2IncidentRegions[0] === regionId || + port2IncidentRegions[1] !== regionId ? topology.portAngleForRegion1[port2Id] - : topology.portAngleForRegion2?.[port2Id] ?? - topology.portAngleForRegion1[port2Id] + : (topology.portAngleForRegion2?.[port2Id] ?? + topology.portAngleForRegion1[port2Id]) const z1 = topology.portZ[port1Id] const z2 = topology.portZ[port2Id] scratch.lesserAngle = angle1 < angle2 ? angle1 : angle2 @@ -548,9 +596,26 @@ export class TinyHyperGraphSolver extends BaseSolver { regionId: RegionId, port1Id: PortId, port2Id: PortId, - ) { - const { topology, state } = this - const regionCache = state.regionIntersectionCaches[regionId] + ): RegionIntersectionCache { + const nextRegionIntersectionCache = this.createNextRegionIntersectionCache( + regionId, + this.state.regionIntersectionCaches[regionId]!, + this.state.currentRouteNetId!, + port1Id, + port2Id, + ) + this.state.regionIntersectionCaches[regionId] = nextRegionIntersectionCache + return nextRegionIntersectionCache + } + + createNextRegionIntersectionCache( + regionId: RegionId, + regionCache: RegionIntersectionCache, + routeNetId: NetId, + port1Id: PortId, + port2Id: PortId, + ): RegionIntersectionCache { + const { topology } = this const segmentGeometry = this.populateSegmentGeometryScratch( regionId, port1Id, @@ -562,7 +627,7 @@ export class TinyHyperGraphSolver extends BaseSolver { newEntryExitLayerChanges, ] = countNewIntersectionsWithValues( regionCache, - state.currentRouteNetId!, + routeNetId, segmentGeometry.lesserAngle, segmentGeometry.greaterAngle, segmentGeometry.layerMask, @@ -572,7 +637,7 @@ export class TinyHyperGraphSolver extends BaseSolver { const netIds = new Int32Array(nextLength) netIds.set(regionCache.netIds) - netIds[nextLength - 1] = state.currentRouteNetId! + netIds[nextLength - 1] = routeNetId const lesserAngles = new Int32Array(nextLength) lesserAngles.set(regionCache.lesserAngles) @@ -595,7 +660,7 @@ export class TinyHyperGraphSolver extends BaseSolver { regionCache.existingEntryExitLayerChanges + newEntryExitLayerChanges const existingSegmentCount = lesserAngles.length - state.regionIntersectionCaches[regionId] = { + return { netIds, lesserAngles, greaterAngles, @@ -616,6 +681,114 @@ export class TinyHyperGraphSolver extends BaseSolver { } } + createRegionIntersectionCacheForSegments( + regionId: RegionId, + regionSegments: Array<[RouteId, PortId, PortId]>, + ): RegionIntersectionCache { + let regionIntersectionCache = createEmptyRegionIntersectionCache() + + for (const [routeId, port1Id, port2Id] of regionSegments) { + regionIntersectionCache = this.createNextRegionIntersectionCache( + regionId, + regionIntersectionCache, + this.problem.routeNet[routeId]!, + port1Id, + port2Id, + ) + } + + return regionIntersectionCache + } + + rebalanceRegionAssignments() { + let didChange = true + + while (didChange) { + didChange = false + + for (let regionId = 0; regionId < this.topology.regionCount; regionId++) { + const regionSegments = this.state.regionSegments[regionId]! + let segmentIndex = 0 + + while (segmentIndex < regionSegments.length) { + const [routeId, fromPortId, toPortId] = regionSegments[segmentIndex]! + const alternateRegionId = this.getAlternateRegionIdForSegment( + regionId, + fromPortId, + toPortId, + ) + + if ( + alternateRegionId === undefined || + !this.canRebalanceRegionAssignment(regionId, alternateRegionId) + ) { + segmentIndex += 1 + continue + } + + const nextSourceRegionSegments = regionSegments.filter( + (_, currentSegmentIndex) => currentSegmentIndex !== segmentIndex, + ) + const movedSegment: [RouteId, PortId, PortId] = [ + routeId, + fromPortId, + toPortId, + ] + const nextTargetRegionSegments = [ + ...this.state.regionSegments[alternateRegionId]!, + movedSegment, + ] + const currentSummary: RegionCostSummary = { + maxRegionCost: Math.max( + this.state.regionIntersectionCaches[regionId]!.existingRegionCost, + this.state.regionIntersectionCaches[alternateRegionId]! + .existingRegionCost, + ), + totalRegionCost: + this.state.regionIntersectionCaches[regionId]! + .existingRegionCost + + this.state.regionIntersectionCaches[alternateRegionId]! + .existingRegionCost, + } + const nextSourceRegionCache = + this.createRegionIntersectionCacheForSegments( + regionId, + nextSourceRegionSegments, + ) + const nextTargetRegionCache = + this.createRegionIntersectionCacheForSegments( + alternateRegionId, + nextTargetRegionSegments, + ) + const nextSummary: RegionCostSummary = { + maxRegionCost: Math.max( + nextSourceRegionCache.existingRegionCost, + nextTargetRegionCache.existingRegionCost, + ), + totalRegionCost: + nextSourceRegionCache.existingRegionCost + + nextTargetRegionCache.existingRegionCost, + } + + if ( + compareRegionCostSummaries(nextSummary, currentSummary) >= + -Number.EPSILON + ) { + segmentIndex += 1 + continue + } + + regionSegments.splice(segmentIndex, 1) + this.state.regionSegments[alternateRegionId]!.push(movedSegment) + this.state.regionIntersectionCaches[regionId] = nextSourceRegionCache + this.state.regionIntersectionCaches[alternateRegionId] = + nextTargetRegionCache + didChange = true + } + } + } + } + getSolvedPathSegments(finalCandidate: Candidate): Array<{ regionId: RegionId fromPortId: PortId @@ -769,6 +942,8 @@ export class TinyHyperGraphSolver extends BaseSolver { this.appendSegmentToRegionCache(regionId, fromPortId, toPortId) } + this.rebalanceRegionAssignments() + state.candidateQueue.clear() state.currentRouteNetId = undefined state.currentRouteId = undefined diff --git a/lib/section-solver/index.ts b/lib/section-solver/index.ts index cc8d4c6..6246c91 100644 --- a/lib/section-solver/index.ts +++ b/lib/section-solver/index.ts @@ -52,9 +52,7 @@ export interface TinyHyperGraphSectionSolverOptions } const applyTinyHyperGraphSectionSolverOptions = ( - solver: - | TinyHyperGraphSectionSearchSolver - | TinyHyperGraphSectionSolver, + solver: TinyHyperGraphSectionSearchSolver | TinyHyperGraphSectionSolver, options?: TinyHyperGraphSectionSolverOptions, ) => { applyTinyHyperGraphSolverOptions(solver, options) @@ -131,7 +129,8 @@ const restoreSolvedStateSnapshot = ( const clonedSnapshot = cloneSolvedStateSnapshot(snapshot) solver.state.portAssignment = clonedSnapshot.portAssignment solver.state.regionSegments = clonedSnapshot.regionSegments - solver.state.regionIntersectionCaches = clonedSnapshot.regionIntersectionCaches + solver.state.regionIntersectionCaches = + clonedSnapshot.regionIntersectionCaches } const summarizeRegionIntersectionCaches = ( @@ -245,7 +244,8 @@ const getOrderedRoutePath = ( orderedRegionIds: RegionId[] } => { const routeSegments = solution.solvedRoutePathSegments[routeId] ?? [] - const routeSegmentRegionIds = solution.solvedRoutePathRegionIds?.[routeId] ?? [] + const routeSegmentRegionIds = + solution.solvedRoutePathRegionIds?.[routeId] ?? [] const startPortId = problem.routeStartPort[routeId] const endPortId = problem.routeEndPort[routeId] @@ -588,6 +588,7 @@ class TinyHyperGraphSectionSearchSolver extends TinyHyperGraphSolver { baselineBeatRipCount?: number previousBestMaxRegionCost = Number.POSITIVE_INFINITY ripsSinceBestMaxRegionCostImprovement = 0 + mutableRegionIdSet: Set MAX_RIPS = Number.POSITIVE_INFINITY MAX_RIPS_WITHOUT_MAX_REGION_COST_IMPROVEMENT = Number.POSITIVE_INFINITY @@ -605,6 +606,7 @@ class TinyHyperGraphSectionSearchSolver extends TinyHyperGraphSolver { ) { super(topology, problem, options) applyTinyHyperGraphSectionSolverOptions(this, options) + this.mutableRegionIdSet = new Set(mutableRegionIds) this.state.unroutedRoutes = [...activeRouteIds] this.applyFixedSegments() this.fixedSnapshot = cloneSolvedStateSnapshot({ @@ -616,7 +618,11 @@ class TinyHyperGraphSectionSearchSolver extends TinyHyperGraphSolver { applyFixedSegments() { for (const routePlan of this.routePlans) { - for (const { regionId, fromPortId, toPortId } of routePlan.fixedSegments) { + for (const { + regionId, + fromPortId, + toPortId, + } of routePlan.fixedSegments) { this.state.currentRouteNetId = this.problem.routeNet[routePlan.routeId] this.state.regionSegments[regionId]!.push([ routePlan.routeId, @@ -698,6 +704,16 @@ class TinyHyperGraphSectionSearchSolver extends TinyHyperGraphSolver { this.state.goalPortId = -1 } + override canRebalanceRegionAssignment( + currentRegionId: RegionId, + nextRegionId: RegionId, + ): boolean { + return ( + this.mutableRegionIdSet.has(currentRegionId) && + this.mutableRegionIdSet.has(nextRegionId) + ) + } + override onAllRoutesRouted() { const { state } = this const maxRips = Math.min(this.MAX_RIPS, this.RIP_THRESHOLD_RAMP_ATTEMPTS) @@ -944,7 +960,8 @@ export class TinyHyperGraphSectionSolver extends BaseSolver { this.stats = { ...this.stats, sectionBaselineMaxRegionCost: this.sectionBaselineSummary.maxRegionCost, - sectionBaselineTotalRegionCost: this.sectionBaselineSummary.totalRegionCost, + sectionBaselineTotalRegionCost: + this.sectionBaselineSummary.totalRegionCost, effectiveRipThresholdStart: this.RIP_THRESHOLD_START, effectiveRipThresholdEnd: this.RIP_THRESHOLD_END, effectiveMaxRips: this.MAX_RIPS, diff --git a/package.json b/package.json index b1b1734..68d7c9a 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "module": "index.ts", "type": "module", "scripts": { + "build": "cosmos-export", "start": "cosmos", "typecheck": "tsc -p tsconfig.json --pretty false", "benchmark1": "bun run --cpu-prof-md scripts/profiling/hg07-first10.ts", @@ -15,10 +16,12 @@ "@tscircuit/hypergraph": "^0.0.71", "@tscircuit/solver-utils": "^0.0.17", "@types/bun": "latest", - "cosmos": "^0.1.2", "dataset-hg07": "https://github.com/tscircuit/dataset-hg07#6adb2ed998a16675b11cf59c25789ae95f0e1a6f", "graphics-debug": "^0.0.90", + "react": "^19.2.0", + "react-cosmos": "^7.2.0", "react-cosmos-plugin-vite": "^7.2.0", + "react-dom": "^19.2.0", "stack-svgs": "^0.0.1", "vite": "^8.0.1" }, diff --git a/scripts/benchmarking/benchmark.ts b/scripts/benchmarking/benchmark.ts index 623c1de..931e675 100644 --- a/scripts/benchmarking/benchmark.ts +++ b/scripts/benchmarking/benchmark.ts @@ -34,7 +34,10 @@ type BenchmarkSampleResult = { durationMs: number baselineMaxRegionCost: number | null finalMaxRegionCost: number | null + baselineAvgRegionCost: number | null + finalAvgRegionCost: number | null delta: number | null + avgDelta: number | null optimized: boolean zeroFinalCost: boolean candidateCount: number @@ -75,6 +78,7 @@ Summary metrics: - zero-final-max-region-cost rate - avg / P50 / P95 duration - avg baseline/final max region cost and avg delta + - avg baseline/final region cost and avg delta ` const usageError = (message: string): never => { @@ -145,7 +149,8 @@ const parseArgs = () => { return { limit, sampleName } } -const formatSeconds = (durationMs: number) => `${(durationMs / 1000).toFixed(3)}s` +const formatSeconds = (durationMs: number) => + `${(durationMs / 1000).toFixed(3)}s` const formatMetric = (value: number | null, digits = 3) => value === null ? "n/a" : value.toFixed(digits) @@ -177,7 +182,17 @@ const getMaxRegionCost = (solver: TinyHyperGraphSolver) => 0, ) -const getSerializedOutputMaxRegionCost = ( +const getAverageRegionCost = (solver: TinyHyperGraphSolver) => { + const totalRegionCost = solver.state.regionIntersectionCaches.reduce( + (sum, regionIntersectionCache) => + sum + regionIntersectionCache.existingRegionCost, + 0, + ) + + return totalRegionCost / Math.max(1, solver.topology.regionCount) +} + +const getSerializedOutputRegionCosts = ( serializedHyperGraph: SerializedHyperGraph, ) => { const { topology, problem, solution } = @@ -188,7 +203,10 @@ const getSerializedOutputMaxRegionCost = ( solution, ) - return getMaxRegionCost(replaySolver.baselineSolver) + return { + maxRegionCost: getMaxRegionCost(replaySolver.baselineSolver), + avgRegionCost: getAverageRegionCost(replaySolver.baselineSolver), + } } const getNextRunDirectory = async (resultsDir: string) => { @@ -309,11 +327,17 @@ const main = async () => { throw new Error("pipeline did not produce both stage outputs") } - const baselineMaxRegionCost = - getSerializedOutputMaxRegionCost(solveGraphOutput) - const finalMaxRegionCost = - getSerializedOutputMaxRegionCost(optimizeSectionOutput) + const baselineRegionCosts = + getSerializedOutputRegionCosts(solveGraphOutput) + const finalRegionCosts = getSerializedOutputRegionCosts( + optimizeSectionOutput, + ) + const baselineMaxRegionCost = baselineRegionCosts.maxRegionCost + const finalMaxRegionCost = finalRegionCosts.maxRegionCost + const baselineAvgRegionCost = baselineRegionCosts.avgRegionCost + const finalAvgRegionCost = finalRegionCosts.avgRegionCost const delta = baselineMaxRegionCost - finalMaxRegionCost + const avgDelta = baselineAvgRegionCost - finalAvgRegionCost const durationMs = performance.now() - sampleStart const optimized = delta > IMPROVEMENT_EPSILON const candidateCount = Number( @@ -333,13 +357,17 @@ const main = async () => { durationMs, baselineMaxRegionCost, finalMaxRegionCost, + baselineAvgRegionCost, + finalAvgRegionCost, delta, + avgDelta, optimized, zeroFinalCost: finalMaxRegionCost <= IMPROVEMENT_EPSILON, candidateCount, generatedCandidateCount, duplicateCandidateCount, - selectedCandidateLabel: pipelineSolver.selectedSectionCandidateLabel ?? null, + selectedCandidateLabel: + pipelineSolver.selectedSectionCandidateLabel ?? null, selectedCandidateFamily: pipelineSolver.selectedSectionCandidateFamily ?? null, error: null, @@ -359,12 +387,11 @@ const main = async () => { `duration=${formatSeconds(durationMs)}`, ].join(" "), ) - console.log( - `# no artifacts written` - ) + console.log(`# no artifacts written`) } catch (error) { const durationMs = performance.now() - sampleStart - const errorMessage = error instanceof Error ? error.stack ?? error.message : String(error) + const errorMessage = + error instanceof Error ? (error.stack ?? error.message) : String(error) const sampleDir = path.join(runDir, sampleMeta.sampleName) const logsPath = path.join(sampleDir, "logs.txt") const snapshotPath = path.join(sampleDir, "snapshot.png") @@ -382,7 +409,7 @@ const main = async () => { } catch (snapshotError) { snapshotErrorMessage = snapshotError instanceof Error - ? snapshotError.stack ?? snapshotError.message + ? (snapshotError.stack ?? snapshotError.message) : String(snapshotError) } @@ -409,7 +436,10 @@ const main = async () => { durationMs, baselineMaxRegionCost: null, finalMaxRegionCost: null, + baselineAvgRegionCost: null, + finalAvgRegionCost: null, delta: null, + avgDelta: null, optimized: false, zeroFinalCost: false, candidateCount: 0, @@ -456,7 +486,18 @@ const main = async () => { const finalCosts = successfulResults .map((result) => result.finalMaxRegionCost) .filter((value): value is number => value !== null) - const candidateCounts = successfulResults.map((result) => result.candidateCount) + const baselineAvgRegionCosts = successfulResults + .map((result) => result.baselineAvgRegionCost) + .filter((value): value is number => value !== null) + const finalAvgRegionCosts = successfulResults + .map((result) => result.finalAvgRegionCost) + .filter((value): value is number => value !== null) + const candidateCounts = successfulResults.map( + (result) => result.candidateCount, + ) + const avgDeltas = successfulResults + .map((result) => result.avgDelta) + .filter((value): value is number => value !== null) const successCount = successfulResults.length const improvedCount = successfulResults.filter( (result) => result.optimized, @@ -470,9 +511,18 @@ const main = async () => { console.log( `zero-final-max-region-cost rate: ${formatPercent(zeroFinalCostCount, successCount)}`, ) - console.log(`avg baseline max region cost: ${average(baselineCosts).toFixed(3)}`) + console.log( + `avg baseline max region cost: ${average(baselineCosts).toFixed(3)}`, + ) console.log(`avg final max region cost: ${average(finalCosts).toFixed(3)}`) console.log(`avg max region delta: ${average(deltas).toFixed(3)}`) + console.log( + `avg baseline region cost: ${average(baselineAvgRegionCosts).toFixed(6)}`, + ) + console.log( + `avg final region cost: ${average(finalAvgRegionCosts).toFixed(6)}`, + ) + console.log(`avg region cost delta: ${average(avgDeltas).toFixed(6)}`) console.log(`avg candidate count: ${average(candidateCounts).toFixed(3)}`) console.log(`avg duration: ${formatSeconds(average(durations))}`) console.log(`P50 duration: ${formatSeconds(percentile(durations, 50))}`) diff --git a/tests/solver/region-rebalancing.test.ts b/tests/solver/region-rebalancing.test.ts new file mode 100644 index 0000000..9caf8a8 --- /dev/null +++ b/tests/solver/region-rebalancing.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test" +import type { TinyHyperGraphProblem, TinyHyperGraphTopology } from "lib/index" +import { TinyHyperGraphSolver } from "lib/index" + +const createTopology = (): TinyHyperGraphTopology => ({ + portCount: 4, + regionCount: 4, + regionIncidentPorts: [[0, 1], [0, 1, 2, 3], [2], [3]], + incidentPortRegion: [ + [0, 1], + [0, 1], + [1, 2], + [1, 3], + ], + regionWidth: new Float64Array([4, 4, 1, 1]), + regionHeight: new Float64Array([4, 4, 1, 1]), + regionCenterX: new Float64Array(4).fill(0), + regionCenterY: new Float64Array(4).fill(0), + portAngleForRegion1: new Int32Array([0, 18000, 9000, 27000]), + portAngleForRegion2: new Int32Array([0, 18000, 0, 0]), + portX: new Float64Array([1, -1, 0, 0]), + portY: new Float64Array([0, 0, 1, -1]), + portZ: new Int32Array(4).fill(0), +}) + +const createProblem = (): TinyHyperGraphProblem => ({ + routeCount: 2, + portSectionMask: new Int8Array(4).fill(1), + routeStartPort: new Int32Array([0, 2]), + routeEndPort: new Int32Array([1, 3]), + routeNet: new Int32Array([0, 1]), + regionNetId: new Int32Array(4).fill(-1), +}) + +test("rebalanceRegionAssignments moves ambiguous segments to the lower-cost neighboring region", () => { + const solver = new TinyHyperGraphSolver(createTopology(), createProblem()) + + solver.state.regionSegments[1]!.push([0, 0, 1], [1, 2, 3]) + + solver.state.currentRouteNetId = 0 + solver.appendSegmentToRegionCache(1, 0, 1) + solver.state.currentRouteNetId = 1 + solver.appendSegmentToRegionCache(1, 2, 3) + solver.state.currentRouteNetId = undefined + + const lowerCostBefore = + solver.state.regionIntersectionCaches[1]!.existingRegionCost + + expect(lowerCostBefore).toBeGreaterThan(0) + expect(solver.state.regionSegments[0]).toEqual([]) + expect(solver.state.regionSegments[1]).toEqual([ + [0, 0, 1], + [1, 2, 3], + ]) + + ;(solver as any).rebalanceRegionAssignments() + + expect(solver.state.regionSegments[0]).toEqual([[0, 0, 1]]) + expect(solver.state.regionSegments[1]).toEqual([[1, 2, 3]]) + expect( + solver.state.regionIntersectionCaches[1]!.existingRegionCost, + ).toBeLessThan(lowerCostBefore) + expect(solver.state.regionIntersectionCaches[0]!.existingRegionCost).toBe(0) + expect(solver.state.regionIntersectionCaches[1]!.existingRegionCost).toBe(0) +}) diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..94f020e --- /dev/null +++ b/vercel.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "buildCommand": "npm run build", + "outputDirectory": "cosmos-export" +}