From f285b5379a51ca0267811deb6e0960eb7d846a45 Mon Sep 17 00:00:00 2001 From: imagineer99 Date: Sun, 15 Feb 2026 17:31:31 +0000 Subject: [PATCH] feat: VRAM-based model filtering in frontend --- studio/frontend/bun.lock | 51 ++++++++ studio/frontend/package.json | 5 +- .../studio/sections/model-section.tsx | 87 +++++++++++-- studio/frontend/src/hooks/index.ts | 1 + studio/frontend/src/hooks/use-gpu-info.ts | 68 ++++++++++ studio/frontend/src/lib/vram.test.ts | 123 ++++++++++++++++++ studio/frontend/src/lib/vram.ts | 95 ++++++++++++++ 7 files changed, 415 insertions(+), 15 deletions(-) create mode 100644 studio/frontend/src/hooks/use-gpu-info.ts create mode 100644 studio/frontend/src/lib/vram.test.ts create mode 100644 studio/frontend/src/lib/vram.ts diff --git a/studio/frontend/bun.lock b/studio/frontend/bun.lock index f438f4e3ea..8cd8bc3ba1 100644 --- a/studio/frontend/bun.lock +++ b/studio/frontend/bun.lock @@ -9,6 +9,8 @@ "@assistant-ui/react-markdown": "^0.12.1", "@assistant-ui/react-streamdown": "^0.1.0", "@base-ui/react": "^1.1.0", + "@dagrejs/dagre": "^2.0.4", + "@dagrejs/graphlib": "^3.0.4", "@fontsource-variable/figtree": "^5.2.10", "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/space-grotesk": "^5.2.10", @@ -75,6 +77,7 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4", + "vitest": "^4.0.18", }, }, }, @@ -189,6 +192,10 @@ "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], + "@dagrejs/dagre": ["@dagrejs/dagre@2.0.4", "", { "dependencies": { "@dagrejs/graphlib": "3.0.4" } }, "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA=="], + + "@dagrejs/graphlib": ["@dagrejs/graphlib@3.0.4", "", {}, "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg=="], + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.52.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w=="], @@ -679,6 +686,8 @@ "@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -743,6 +752,8 @@ "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -797,6 +808,20 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], + "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], + + "@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.0.18", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw=="], + + "@vitest/runner": ["@vitest/runner@4.0.18", "", { "dependencies": { "@vitest/utils": "4.0.18", "pathe": "^2.0.3" } }, "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA=="], + + "@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="], + + "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], "@xyflow/react": ["@xyflow/react@12.10.0", "", { "dependencies": { "@xyflow/system": "0.0.74", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw=="], @@ -825,6 +850,8 @@ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "assistant-cloud": ["assistant-cloud@0.1.15", "", { "dependencies": { "assistant-stream": "^0.3.0" } }, "sha512-LK+HrE6p1/jefzH3IosGUfpZvsM8wLPCsd955TLPEfJ9rMknl9KeE2QxRMHl3Ob17m33NqmJi/t/oWWKdgP6Nw=="], "assistant-stream": ["assistant-stream@0.3.0", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-gp5wXZiH7fiPdCByusRZ6ZQrmifAjiuDTenR0HSeviiRLXmirePxePvTgHPT1ZahohCLroge7fjtC9vAc+Hqsg=="], @@ -865,6 +892,8 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -1089,6 +1118,8 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], @@ -1121,6 +1152,8 @@ "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], @@ -1133,6 +1166,8 @@ "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], @@ -1591,6 +1626,8 @@ "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -1821,6 +1858,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], @@ -1835,8 +1874,12 @@ "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], "streamdown": ["streamdown@2.1.0", "", { "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "marked": "^17.0.1", "rehype-harden": "^1.1.7", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.1.0", "tailwind-merge": "^3.4.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-u9gWd0AmjKg1d+74P44XaPlGrMeC21oDOSIhjGNEYMAttDMzCzlJO6lpTyJ9JkSinQQF65YcK4eOd3q9iTvULw=="], @@ -1883,10 +1926,14 @@ "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + "tldts": ["tldts@7.0.19", "", { "dependencies": { "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA=="], "tldts-core": ["tldts-core@7.0.19", "", {}, "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A=="], @@ -1993,6 +2040,8 @@ "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], @@ -2011,6 +2060,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], diff --git a/studio/frontend/package.json b/studio/frontend/package.json index ac61c22969..3b2bcf0b53 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -72,18 +72,19 @@ "zustand": "^5.0.10" }, "devDependencies": { + "@biomejs/biome": "^1.9.4", "@eslint/js": "^9.39.1", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", - "@biomejs/biome": "^1.9.4", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.0.18" } } diff --git a/studio/frontend/src/features/studio/sections/model-section.tsx b/studio/frontend/src/features/studio/sections/model-section.tsx index fcaa319e1d..3fe304a01f 100644 --- a/studio/frontend/src/features/studio/sections/model-section.tsx +++ b/studio/frontend/src/features/studio/sections/model-section.tsx @@ -28,10 +28,17 @@ import { import { MODEL_TYPE_TO_HF_TASK } from "@/config/training"; import { useDebouncedValue, + useGpuInfo, useHfModelSearch, useInfiniteScroll, } from "@/hooks"; import { formatCompact } from "@/lib/utils"; +import { + type TrainingMethod as VramTrainingMethod, + type VramFitStatus, + checkVramFit, + estimateLoadingVram, +} from "@/lib/vram"; import { useTrainingConfigStore } from "@/features/training"; import type { TrainingMethod } from "@/types/training"; import { @@ -57,6 +64,8 @@ const DARK_CONTENT = "bg-foreground text-background shadow-xl border-background/10 [--accent:rgba(255,255,255,0.1)] [--accent-foreground:white] [&_[data-slot=select-item]]:text-white/70 [&_[data-slot=select-scroll-up-button]]:bg-foreground [&_[data-slot=select-scroll-down-button]]:bg-foreground"; export function ModelSection() { + const gpu = useGpuInfo(); + const { modelType, selectedModel, @@ -122,6 +131,37 @@ export function ModelSection() { return ids; }, [hfResults, selectedModel]); + // Pre-compute VRAM fit status for every model in the current result set. + // Keyed by model id so the render callback is a simple O(1) lookup. + // + // Pre-compute VRAM fit status for every model in the current result set. + // Keyed by model id so the render callback is a simple O(1) lookup. + // Re-computes when the training method changes (QLoRA=4-bit vs LoRA/Full=fp16). + const vramMap = useMemo(() => { + const method = trainingMethod as VramTrainingMethod; + const map = new Map< + string, + { est: number; status: VramFitStatus | null; detail: string | null } + >(); + for (const r of hfResults) { + const detail = r.totalParams + ? formatCompact(r.totalParams) + : r.downloads != null + ? `\u2193${formatCompact(r.downloads)}` + : null; + if (r.totalParams) { + const est = estimateLoadingVram(r.totalParams, method); + const status = gpu.available + ? checkVramFit(est, gpu.memoryTotalGb) + : null; + map.set(r.id, { est, status, detail }); + } else { + map.set(r.id, { est: 0, status: null, detail }); + } + } + return map; + }, [hfResults, gpu, trainingMethod]); + const comboboxAnchorRef = useRef(null); const { scrollRef, sentinelRef } = useInfiniteScroll( fetchMore, @@ -225,21 +265,21 @@ export function ModelSection() { > {(id: string) => { - const r = hfResults.find((m) => m.id === id); - const detail = r?.totalParams - ? formatCompact(r.totalParams) - : r?.downloads != null - ? `↓${formatCompact(r.downloads)}` - : null; + const entry = vramMap.get(id); + const detail = entry?.detail ?? null; + const fitStatus = entry?.status ?? null; + const vramEst = entry?.est ?? null; + const exceeds = fitStatus === "exceeds"; + return ( - + {id} @@ -248,13 +288,34 @@ export function ModelSection() { className="max-w-xs break-all" > {id} + {vramEst != null && vramEst > 0 && gpu.available && ( + + {exceeds + ? `Needs ~${vramEst}GB VRAM (GPU: ${gpu.memoryTotalGb}GB)` + : fitStatus === "tight" + ? `~${vramEst}GB VRAM (tight fit on ${gpu.memoryTotalGb}GB)` + : `~${vramEst}GB VRAM`} + + )} - {detail && ( - - {detail} - - )} + + {fitStatus === "exceeds" && ( + + OOM + + )} + {fitStatus === "tight" && ( + + TIGHT + + )} + {detail && ( + + {detail} + + )} + ); }} diff --git a/studio/frontend/src/hooks/index.ts b/studio/frontend/src/hooks/index.ts index 4e338169e4..24f6faca79 100644 --- a/studio/frontend/src/hooks/index.ts +++ b/studio/frontend/src/hooks/index.ts @@ -1,4 +1,5 @@ export { useDebouncedValue } from "./use-debounced-value"; +export { useGpuInfo } from "./use-gpu-info"; export { useHfModelSearch } from "./use-hf-model-search"; export { useHfDatasetSearch } from "./use-hf-dataset-search"; export { useInfiniteScroll } from "./use-infinite-scroll"; diff --git a/studio/frontend/src/hooks/use-gpu-info.ts b/studio/frontend/src/hooks/use-gpu-info.ts new file mode 100644 index 0000000000..98ca056050 --- /dev/null +++ b/studio/frontend/src/hooks/use-gpu-info.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from "react"; + +export interface GpuInfo { + available: boolean; + name: string; + memoryTotalGb: number; +} + +const DEFAULT_GPU: GpuInfo = { + available: false, + name: "Unknown", + memoryTotalGb: 0, +}; + +// Module-level cache so multiple components share one fetch. +let cachedGpu: GpuInfo | null = null; +let fetchPromise: Promise | null = null; + +async function fetchGpuOnce(): Promise { + if (cachedGpu) return cachedGpu; + if (fetchPromise) return fetchPromise; + + fetchPromise = (async () => { + try { + const res = await fetch("/api/system"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const gpuData = data?.gpu; + if (!gpuData?.available || !gpuData.devices?.length) return DEFAULT_GPU; + const dev = gpuData.devices[0]; + const info: GpuInfo = { + available: true, + name: dev.name ?? "Unknown", + memoryTotalGb: dev.memory_total_gb ?? 0, + }; + cachedGpu = info; + return info; + } catch { + // Reset promise so subsequent calls retry (e.g. backend wasn't ready) + fetchPromise = null; + return DEFAULT_GPU; + } + })(); + + return fetchPromise; +} + +/** + * Fetch GPU info from the backend /api/system endpoint. + * + * The result is cached at module level -- only one network request is made + * regardless of how many components call this hook. + */ +export function useGpuInfo(): GpuInfo { + const [gpu, setGpu] = useState(cachedGpu ?? DEFAULT_GPU); + + useEffect(() => { + if (cachedGpu) return; + + let cancelled = false; + fetchGpuOnce().then((info) => { + if (!cancelled) setGpu(info); + }); + return () => { cancelled = true; }; + }, []); + + return gpu; +} diff --git a/studio/frontend/src/lib/vram.test.ts b/studio/frontend/src/lib/vram.test.ts new file mode 100644 index 0000000000..01ab74e98e --- /dev/null +++ b/studio/frontend/src/lib/vram.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { + BNB_4BIT_LOADING_BYTES, + FP16_LOADING_BYTES, + LOADING_OVERHEAD_GB, + checkVramFit, + estimateLoadingVram, +} from "./vram"; + +// --------------------------------------------------------------------------- +// estimateLoadingVram (QLoRA / 4-bit -- default) +// --------------------------------------------------------------------------- + +describe("estimateLoadingVram (qlora)", () => { + it("returns overhead only for 0 params", () => { + expect(estimateLoadingVram(0)).toBe(LOADING_OVERHEAD_GB); + }); + + it("Qwen2.5-0.5B (0.49B params)", () => { + const est = estimateLoadingVram(0.49e9); + // 0.49 * 0.9 + 1.4 = 1.841 -> rounds to 1.8 + expect(est).toBeCloseTo(1.8, 1); + }); + + it("Llama-3.2-1B (1.24B params)", () => { + const est = estimateLoadingVram(1.24e9); + // 1.24 * 0.9 + 1.4 = 2.516 -> rounds to 2.5 + expect(est).toBeCloseTo(2.5, 1); + }); + + it("Llama-3.2-3B (3.21B params)", () => { + const est = estimateLoadingVram(3.21e9); + // 3.21 * 0.9 + 1.4 = 4.289 -> rounds to 4.3 + expect(est).toBeCloseTo(4.3, 1); + }); + + it("Llama-3.1-8B (8.03B params)", () => { + const est = estimateLoadingVram(8.03e9); + // 8.03 * 0.9 + 1.4 = 8.627 -> rounds to 8.6 + expect(est).toBeCloseTo(8.6, 1); + }); +}); + +// --------------------------------------------------------------------------- +// estimateLoadingVram (LoRA / fp16) +// --------------------------------------------------------------------------- + +describe("estimateLoadingVram (lora / fp16)", () => { + it("returns overhead only for 0 params", () => { + expect(estimateLoadingVram(0, "lora")).toBe(LOADING_OVERHEAD_GB); + }); + + it("1B model at fp16", () => { + const est = estimateLoadingVram(1e9, "lora"); + // 1.0 * 2.0 + 1.4 = 3.4 + expect(est).toBeCloseTo(3.4, 1); + }); + + it("8B model at fp16", () => { + const est = estimateLoadingVram(8e9, "lora"); + // 8.0 * 2.0 + 1.4 = 17.4 + expect(est).toBeCloseTo(17.4, 1); + }); + + it("full fine-tune uses same fp16 rate", () => { + const full = estimateLoadingVram(3e9, "full"); + const lora = estimateLoadingVram(3e9, "lora"); + expect(full).toBe(lora); + }); +}); + +// --------------------------------------------------------------------------- +// Sanity: fp16 always requires more VRAM than 4-bit +// --------------------------------------------------------------------------- + +describe("fp16 vs 4-bit ordering", () => { + it("fp16 estimate is always larger for non-zero params", () => { + for (const params of [0.5e9, 1e9, 3e9, 7e9, 13e9]) { + const q4 = estimateLoadingVram(params, "qlora"); + const fp16 = estimateLoadingVram(params, "lora"); + expect(fp16).toBeGreaterThan(q4); + } + }); +}); + +// --------------------------------------------------------------------------- +// checkVramFit +// --------------------------------------------------------------------------- + +describe("checkVramFit", () => { + it("fits when ratio <= 0.75", () => { + expect(checkVramFit(6, 16)).toBe("fits"); + expect(checkVramFit(12, 16)).toBe("fits"); + }); + + it("tight when ratio 0.75..1.0", () => { + expect(checkVramFit(13, 16)).toBe("tight"); + expect(checkVramFit(16, 16)).toBe("tight"); + }); + + it("exceeds when ratio > 1.0", () => { + expect(checkVramFit(17, 16)).toBe("exceeds"); + }); + + it("handles 0 available gracefully", () => { + expect(checkVramFit(5, 0)).toBe("exceeds"); + expect(checkVramFit(0, 0)).toBe("fits"); + }); +}); + +// --------------------------------------------------------------------------- +// Constants sanity +// --------------------------------------------------------------------------- + +describe("constants", () => { + it("4-bit rate is less than fp16 rate", () => { + expect(BNB_4BIT_LOADING_BYTES).toBeLessThan(FP16_LOADING_BYTES); + }); + + it("overhead is positive", () => { + expect(LOADING_OVERHEAD_GB).toBeGreaterThan(0); + }); +}); diff --git a/studio/frontend/src/lib/vram.ts b/studio/frontend/src/lib/vram.ts new file mode 100644 index 0000000000..7aea44e850 --- /dev/null +++ b/studio/frontend/src/lib/vram.ts @@ -0,0 +1,95 @@ +/** + * VRAM estimation for model loading (4-bit quantization via bitsandbytes). + * + * Estimates the total driver-level VRAM (what nvidia-smi reports) needed to + * load a model in 4-bit with Unsloth / bitsandbytes. This determines + * whether a model will fit on the user's GPU before any training begins. + * + * Formula: totalParams * 0.90 + 1.4 GB + * + * Calibrated against isolated Unsloth model loads on RTX 5070 Ti (2026.2): + * Qwen2.5-0.5B (0.49B) : est 1.8 vs actual 1.86 GB (-3%) + * Llama-3.2-1B (1.24B) : est 2.5 vs actual 2.54 GB (-1%) + * Llama-3.2-3B (3.21B) : est 4.3 vs actual 4.40 GB (-2%) + * Llama-3.1-8B (8.03B) : est 8.6 vs actual 8.14 GB (+6%) + * + * Accuracy: within 3% for 0.5B-3B models, within 6% for 8B. + */ + +// --------------------------------------------------------------------------- +// Constants (exported for testing) +// --------------------------------------------------------------------------- + +/** + * Effective bytes per parameter for 4-bit model weights at driver level. + * + * Raw bnb 4-bit is ~0.5 bytes/param, but embedding and lm_head layers remain + * in fp16 and bnb adds per-block quantization metadata, bringing the + * effective rate to ~0.84-0.93 across tested architectures. 0.9 is the + * calibrated middle ground. + */ +export const BNB_4BIT_LOADING_BYTES = 0.9; + +/** + * Fixed overhead (GB) for the CUDA driver context and PyTorch runtime. + * + * This is independent of model size -- it is the baseline GPU memory consumed + * before any model weights are loaded. Measured at 1.34-1.46 GB across + * tested models; we use 1.4 as the default. + */ +export const LOADING_OVERHEAD_GB = 1.4; + +// --------------------------------------------------------------------------- +// Estimation +// --------------------------------------------------------------------------- + +export type VramFitStatus = "fits" | "tight" | "exceeds"; + +/** + * Bytes per parameter when loading a model at fp16/bf16 (LoRA, full FT). + * + * This is the theoretical value (2 bytes = 16 bits). Not yet calibrated + * against actual measurements -- the real driver-level usage may be slightly + * higher due to buffers and metadata, similar to how 4-bit is 0.9 vs 0.5. + */ +export const FP16_LOADING_BYTES = 2.0; + +export type TrainingMethod = "qlora" | "lora" | "full"; + +/** + * Estimate VRAM (GB) needed to load a model with Unsloth. + * + * The bytes-per-param rate depends on the training method: + * - QLoRA : 4-bit quantized via bnb -> 0.90 bytes/param (calibrated) + * - LoRA : fp16 -> 2.0 bytes/param (theoretical) + * - Full : fp16 -> 2.0 bytes/param (theoretical) + * + * Formula: totalParams * bytesPerParam + 1.4 GB overhead + */ +export function estimateLoadingVram( + totalParams: number, + method: TrainingMethod = "qlora", +): number { + const bytesPerParam = + method === "qlora" ? BNB_4BIT_LOADING_BYTES : FP16_LOADING_BYTES; + const gb = (totalParams / 1e9) * bytesPerParam + LOADING_OVERHEAD_GB; + return Math.round(gb * 10) / 10; +} + +/** + * Check whether a model fits in the available GPU VRAM. + * + * fits - uses <= 75% of available + * tight - uses 75-100% of available + * exceeds - uses > 100% of available + */ +export function checkVramFit( + requiredGb: number, + availableGb: number, +): VramFitStatus { + if (availableGb <= 0) return requiredGb <= 0 ? "fits" : "exceeds"; + const ratio = requiredGb / availableGb; + if (ratio <= 0.75) return "fits"; + if (ratio <= 1.0) return "tight"; + return "exceeds"; +}