The AtmosphereConf talks your skyline missed
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #9 from musicjunkieg/feat/scoring-algorithm

feat: Layer 1 scoring algorithm + Vitest setup (#19)

authored by

chaos gremlin and committed by
GitHub
556ae429 fdfea2bf

+1725 -3
+835 -1
package-lock.json
··· 26 26 "eslint-config-next": "16.2.2", 27 27 "tailwindcss": "^4", 28 28 "tsx": "^4.21.0", 29 - "typescript": "^5" 29 + "typescript": "^5", 30 + "vite-tsconfig-paths": "^6.1.1", 31 + "vitest": "^4.1.4" 30 32 } 31 33 }, 32 34 "node_modules/@alloc/quick-lru": { ··· 2049 2051 "node": ">=12.4.0" 2050 2052 } 2051 2053 }, 2054 + "node_modules/@oxc-project/types": { 2055 + "version": "0.124.0", 2056 + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", 2057 + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", 2058 + "dev": true, 2059 + "license": "MIT", 2060 + "funding": { 2061 + "url": "https://github.com/sponsors/Boshen" 2062 + } 2063 + }, 2064 + "node_modules/@rolldown/binding-android-arm64": { 2065 + "version": "1.0.0-rc.15", 2066 + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", 2067 + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", 2068 + "cpu": [ 2069 + "arm64" 2070 + ], 2071 + "dev": true, 2072 + "license": "MIT", 2073 + "optional": true, 2074 + "os": [ 2075 + "android" 2076 + ], 2077 + "engines": { 2078 + "node": "^20.19.0 || >=22.12.0" 2079 + } 2080 + }, 2081 + "node_modules/@rolldown/binding-darwin-arm64": { 2082 + "version": "1.0.0-rc.15", 2083 + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", 2084 + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", 2085 + "cpu": [ 2086 + "arm64" 2087 + ], 2088 + "dev": true, 2089 + "license": "MIT", 2090 + "optional": true, 2091 + "os": [ 2092 + "darwin" 2093 + ], 2094 + "engines": { 2095 + "node": "^20.19.0 || >=22.12.0" 2096 + } 2097 + }, 2098 + "node_modules/@rolldown/binding-darwin-x64": { 2099 + "version": "1.0.0-rc.15", 2100 + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", 2101 + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", 2102 + "cpu": [ 2103 + "x64" 2104 + ], 2105 + "dev": true, 2106 + "license": "MIT", 2107 + "optional": true, 2108 + "os": [ 2109 + "darwin" 2110 + ], 2111 + "engines": { 2112 + "node": "^20.19.0 || >=22.12.0" 2113 + } 2114 + }, 2115 + "node_modules/@rolldown/binding-freebsd-x64": { 2116 + "version": "1.0.0-rc.15", 2117 + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", 2118 + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", 2119 + "cpu": [ 2120 + "x64" 2121 + ], 2122 + "dev": true, 2123 + "license": "MIT", 2124 + "optional": true, 2125 + "os": [ 2126 + "freebsd" 2127 + ], 2128 + "engines": { 2129 + "node": "^20.19.0 || >=22.12.0" 2130 + } 2131 + }, 2132 + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { 2133 + "version": "1.0.0-rc.15", 2134 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", 2135 + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", 2136 + "cpu": [ 2137 + "arm" 2138 + ], 2139 + "dev": true, 2140 + "license": "MIT", 2141 + "optional": true, 2142 + "os": [ 2143 + "linux" 2144 + ], 2145 + "engines": { 2146 + "node": "^20.19.0 || >=22.12.0" 2147 + } 2148 + }, 2149 + "node_modules/@rolldown/binding-linux-arm64-gnu": { 2150 + "version": "1.0.0-rc.15", 2151 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", 2152 + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", 2153 + "cpu": [ 2154 + "arm64" 2155 + ], 2156 + "dev": true, 2157 + "license": "MIT", 2158 + "optional": true, 2159 + "os": [ 2160 + "linux" 2161 + ], 2162 + "engines": { 2163 + "node": "^20.19.0 || >=22.12.0" 2164 + } 2165 + }, 2166 + "node_modules/@rolldown/binding-linux-arm64-musl": { 2167 + "version": "1.0.0-rc.15", 2168 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", 2169 + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", 2170 + "cpu": [ 2171 + "arm64" 2172 + ], 2173 + "dev": true, 2174 + "license": "MIT", 2175 + "optional": true, 2176 + "os": [ 2177 + "linux" 2178 + ], 2179 + "engines": { 2180 + "node": "^20.19.0 || >=22.12.0" 2181 + } 2182 + }, 2183 + "node_modules/@rolldown/binding-linux-ppc64-gnu": { 2184 + "version": "1.0.0-rc.15", 2185 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", 2186 + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", 2187 + "cpu": [ 2188 + "ppc64" 2189 + ], 2190 + "dev": true, 2191 + "license": "MIT", 2192 + "optional": true, 2193 + "os": [ 2194 + "linux" 2195 + ], 2196 + "engines": { 2197 + "node": "^20.19.0 || >=22.12.0" 2198 + } 2199 + }, 2200 + "node_modules/@rolldown/binding-linux-s390x-gnu": { 2201 + "version": "1.0.0-rc.15", 2202 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", 2203 + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", 2204 + "cpu": [ 2205 + "s390x" 2206 + ], 2207 + "dev": true, 2208 + "license": "MIT", 2209 + "optional": true, 2210 + "os": [ 2211 + "linux" 2212 + ], 2213 + "engines": { 2214 + "node": "^20.19.0 || >=22.12.0" 2215 + } 2216 + }, 2217 + "node_modules/@rolldown/binding-linux-x64-gnu": { 2218 + "version": "1.0.0-rc.15", 2219 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", 2220 + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", 2221 + "cpu": [ 2222 + "x64" 2223 + ], 2224 + "dev": true, 2225 + "license": "MIT", 2226 + "optional": true, 2227 + "os": [ 2228 + "linux" 2229 + ], 2230 + "engines": { 2231 + "node": "^20.19.0 || >=22.12.0" 2232 + } 2233 + }, 2234 + "node_modules/@rolldown/binding-linux-x64-musl": { 2235 + "version": "1.0.0-rc.15", 2236 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", 2237 + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", 2238 + "cpu": [ 2239 + "x64" 2240 + ], 2241 + "dev": true, 2242 + "license": "MIT", 2243 + "optional": true, 2244 + "os": [ 2245 + "linux" 2246 + ], 2247 + "engines": { 2248 + "node": "^20.19.0 || >=22.12.0" 2249 + } 2250 + }, 2251 + "node_modules/@rolldown/binding-openharmony-arm64": { 2252 + "version": "1.0.0-rc.15", 2253 + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", 2254 + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", 2255 + "cpu": [ 2256 + "arm64" 2257 + ], 2258 + "dev": true, 2259 + "license": "MIT", 2260 + "optional": true, 2261 + "os": [ 2262 + "openharmony" 2263 + ], 2264 + "engines": { 2265 + "node": "^20.19.0 || >=22.12.0" 2266 + } 2267 + }, 2268 + "node_modules/@rolldown/binding-wasm32-wasi": { 2269 + "version": "1.0.0-rc.15", 2270 + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", 2271 + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", 2272 + "cpu": [ 2273 + "wasm32" 2274 + ], 2275 + "dev": true, 2276 + "license": "MIT", 2277 + "optional": true, 2278 + "dependencies": { 2279 + "@emnapi/core": "1.9.2", 2280 + "@emnapi/runtime": "1.9.2", 2281 + "@napi-rs/wasm-runtime": "^1.1.3" 2282 + }, 2283 + "engines": { 2284 + "node": ">=14.0.0" 2285 + } 2286 + }, 2287 + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { 2288 + "version": "1.1.3", 2289 + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", 2290 + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", 2291 + "dev": true, 2292 + "license": "MIT", 2293 + "optional": true, 2294 + "dependencies": { 2295 + "@tybys/wasm-util": "^0.10.1" 2296 + }, 2297 + "funding": { 2298 + "type": "github", 2299 + "url": "https://github.com/sponsors/Brooooooklyn" 2300 + }, 2301 + "peerDependencies": { 2302 + "@emnapi/core": "^1.7.1", 2303 + "@emnapi/runtime": "^1.7.1" 2304 + } 2305 + }, 2306 + "node_modules/@rolldown/binding-win32-arm64-msvc": { 2307 + "version": "1.0.0-rc.15", 2308 + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", 2309 + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", 2310 + "cpu": [ 2311 + "arm64" 2312 + ], 2313 + "dev": true, 2314 + "license": "MIT", 2315 + "optional": true, 2316 + "os": [ 2317 + "win32" 2318 + ], 2319 + "engines": { 2320 + "node": "^20.19.0 || >=22.12.0" 2321 + } 2322 + }, 2323 + "node_modules/@rolldown/binding-win32-x64-msvc": { 2324 + "version": "1.0.0-rc.15", 2325 + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", 2326 + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", 2327 + "cpu": [ 2328 + "x64" 2329 + ], 2330 + "dev": true, 2331 + "license": "MIT", 2332 + "optional": true, 2333 + "os": [ 2334 + "win32" 2335 + ], 2336 + "engines": { 2337 + "node": "^20.19.0 || >=22.12.0" 2338 + } 2339 + }, 2340 + "node_modules/@rolldown/pluginutils": { 2341 + "version": "1.0.0-rc.15", 2342 + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", 2343 + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", 2344 + "dev": true, 2345 + "license": "MIT" 2346 + }, 2052 2347 "node_modules/@rtsao/scc": { 2053 2348 "version": "1.1.0", 2054 2349 "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", 2055 2350 "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", 2351 + "dev": true, 2352 + "license": "MIT" 2353 + }, 2354 + "node_modules/@standard-schema/spec": { 2355 + "version": "1.1.0", 2356 + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", 2357 + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", 2056 2358 "dev": true, 2057 2359 "license": "MIT" 2058 2360 }, ··· 2346 2648 "dependencies": { 2347 2649 "tslib": "^2.4.0" 2348 2650 } 2651 + }, 2652 + "node_modules/@types/chai": { 2653 + "version": "5.2.3", 2654 + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", 2655 + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", 2656 + "dev": true, 2657 + "license": "MIT", 2658 + "dependencies": { 2659 + "@types/deep-eql": "*", 2660 + "assertion-error": "^2.0.1" 2661 + } 2662 + }, 2663 + "node_modules/@types/deep-eql": { 2664 + "version": "4.0.2", 2665 + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", 2666 + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", 2667 + "dev": true, 2668 + "license": "MIT" 2349 2669 }, 2350 2670 "node_modules/@types/estree": { 2351 2671 "version": "1.0.8", ··· 2962 3282 "win32" 2963 3283 ] 2964 3284 }, 3285 + "node_modules/@vitest/expect": { 3286 + "version": "4.1.4", 3287 + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", 3288 + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", 3289 + "dev": true, 3290 + "license": "MIT", 3291 + "dependencies": { 3292 + "@standard-schema/spec": "^1.1.0", 3293 + "@types/chai": "^5.2.2", 3294 + "@vitest/spy": "4.1.4", 3295 + "@vitest/utils": "4.1.4", 3296 + "chai": "^6.2.2", 3297 + "tinyrainbow": "^3.1.0" 3298 + }, 3299 + "funding": { 3300 + "url": "https://opencollective.com/vitest" 3301 + } 3302 + }, 3303 + "node_modules/@vitest/mocker": { 3304 + "version": "4.1.4", 3305 + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", 3306 + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", 3307 + "dev": true, 3308 + "license": "MIT", 3309 + "dependencies": { 3310 + "@vitest/spy": "4.1.4", 3311 + "estree-walker": "^3.0.3", 3312 + "magic-string": "^0.30.21" 3313 + }, 3314 + "funding": { 3315 + "url": "https://opencollective.com/vitest" 3316 + }, 3317 + "peerDependencies": { 3318 + "msw": "^2.4.9", 3319 + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" 3320 + }, 3321 + "peerDependenciesMeta": { 3322 + "msw": { 3323 + "optional": true 3324 + }, 3325 + "vite": { 3326 + "optional": true 3327 + } 3328 + } 3329 + }, 3330 + "node_modules/@vitest/pretty-format": { 3331 + "version": "4.1.4", 3332 + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", 3333 + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", 3334 + "dev": true, 3335 + "license": "MIT", 3336 + "dependencies": { 3337 + "tinyrainbow": "^3.1.0" 3338 + }, 3339 + "funding": { 3340 + "url": "https://opencollective.com/vitest" 3341 + } 3342 + }, 3343 + "node_modules/@vitest/runner": { 3344 + "version": "4.1.4", 3345 + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", 3346 + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", 3347 + "dev": true, 3348 + "license": "MIT", 3349 + "dependencies": { 3350 + "@vitest/utils": "4.1.4", 3351 + "pathe": "^2.0.3" 3352 + }, 3353 + "funding": { 3354 + "url": "https://opencollective.com/vitest" 3355 + } 3356 + }, 3357 + "node_modules/@vitest/snapshot": { 3358 + "version": "4.1.4", 3359 + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", 3360 + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", 3361 + "dev": true, 3362 + "license": "MIT", 3363 + "dependencies": { 3364 + "@vitest/pretty-format": "4.1.4", 3365 + "@vitest/utils": "4.1.4", 3366 + "magic-string": "^0.30.21", 3367 + "pathe": "^2.0.3" 3368 + }, 3369 + "funding": { 3370 + "url": "https://opencollective.com/vitest" 3371 + } 3372 + }, 3373 + "node_modules/@vitest/spy": { 3374 + "version": "4.1.4", 3375 + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", 3376 + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", 3377 + "dev": true, 3378 + "license": "MIT", 3379 + "funding": { 3380 + "url": "https://opencollective.com/vitest" 3381 + } 3382 + }, 3383 + "node_modules/@vitest/utils": { 3384 + "version": "4.1.4", 3385 + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", 3386 + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", 3387 + "dev": true, 3388 + "license": "MIT", 3389 + "dependencies": { 3390 + "@vitest/pretty-format": "4.1.4", 3391 + "convert-source-map": "^2.0.0", 3392 + "tinyrainbow": "^3.1.0" 3393 + }, 3394 + "funding": { 3395 + "url": "https://opencollective.com/vitest" 3396 + } 3397 + }, 2965 3398 "node_modules/acorn": { 2966 3399 "version": "8.16.0", 2967 3400 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", ··· 3205 3638 }, 3206 3639 "engines": { 3207 3640 "node": ">=18" 3641 + } 3642 + }, 3643 + "node_modules/assertion-error": { 3644 + "version": "2.0.1", 3645 + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", 3646 + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", 3647 + "dev": true, 3648 + "license": "MIT", 3649 + "engines": { 3650 + "node": ">=12" 3208 3651 } 3209 3652 }, 3210 3653 "node_modules/ast-types-flow": { ··· 3423 3866 ], 3424 3867 "license": "CC-BY-4.0" 3425 3868 }, 3869 + "node_modules/chai": { 3870 + "version": "6.2.2", 3871 + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", 3872 + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", 3873 + "dev": true, 3874 + "license": "MIT", 3875 + "engines": { 3876 + "node": ">=18" 3877 + } 3878 + }, 3426 3879 "node_modules/chalk": { 3427 3880 "version": "4.1.2", 3428 3881 "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", ··· 3830 4283 "engines": { 3831 4284 "node": ">= 0.4" 3832 4285 } 4286 + }, 4287 + "node_modules/es-module-lexer": { 4288 + "version": "2.0.0", 4289 + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", 4290 + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", 4291 + "dev": true, 4292 + "license": "MIT" 3833 4293 }, 3834 4294 "node_modules/es-object-atoms": { 3835 4295 "version": "1.1.1", ··· 4352 4812 "node": ">=4.0" 4353 4813 } 4354 4814 }, 4815 + "node_modules/estree-walker": { 4816 + "version": "3.0.3", 4817 + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", 4818 + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", 4819 + "dev": true, 4820 + "license": "MIT", 4821 + "dependencies": { 4822 + "@types/estree": "^1.0.0" 4823 + } 4824 + }, 4355 4825 "node_modules/esutils": { 4356 4826 "version": "2.0.3", 4357 4827 "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", ··· 4360 4830 "license": "BSD-2-Clause", 4361 4831 "engines": { 4362 4832 "node": ">=0.10.0" 4833 + } 4834 + }, 4835 + "node_modules/expect-type": { 4836 + "version": "1.3.0", 4837 + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", 4838 + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", 4839 + "dev": true, 4840 + "license": "Apache-2.0", 4841 + "engines": { 4842 + "node": ">=12.0.0" 4363 4843 } 4364 4844 }, 4365 4845 "node_modules/fast-deep-equal": { ··· 4691 5171 "funding": { 4692 5172 "url": "https://github.com/sponsors/ljharb" 4693 5173 } 5174 + }, 5175 + "node_modules/globrex": { 5176 + "version": "0.1.2", 5177 + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", 5178 + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", 5179 + "dev": true, 5180 + "license": "MIT" 4694 5181 }, 4695 5182 "node_modules/gopd": { 4696 5183 "version": "1.2.0", ··· 6147 6634 "url": "https://github.com/sponsors/ljharb" 6148 6635 } 6149 6636 }, 6637 + "node_modules/obug": { 6638 + "version": "2.1.1", 6639 + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", 6640 + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", 6641 + "dev": true, 6642 + "funding": [ 6643 + "https://github.com/sponsors/sxzz", 6644 + "https://opencollective.com/debug" 6645 + ], 6646 + "license": "MIT" 6647 + }, 6150 6648 "node_modules/optionator": { 6151 6649 "version": "0.9.4", 6152 6650 "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", ··· 6252 6750 "version": "1.0.7", 6253 6751 "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", 6254 6752 "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", 6753 + "dev": true, 6754 + "license": "MIT" 6755 + }, 6756 + "node_modules/pathe": { 6757 + "version": "2.0.3", 6758 + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", 6759 + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", 6255 6760 "dev": true, 6256 6761 "license": "MIT" 6257 6762 }, ··· 6493 6998 "node": ">=0.10.0" 6494 6999 } 6495 7000 }, 7001 + "node_modules/rolldown": { 7002 + "version": "1.0.0-rc.15", 7003 + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", 7004 + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", 7005 + "dev": true, 7006 + "license": "MIT", 7007 + "dependencies": { 7008 + "@oxc-project/types": "=0.124.0", 7009 + "@rolldown/pluginutils": "1.0.0-rc.15" 7010 + }, 7011 + "bin": { 7012 + "rolldown": "bin/cli.mjs" 7013 + }, 7014 + "engines": { 7015 + "node": "^20.19.0 || >=22.12.0" 7016 + }, 7017 + "optionalDependencies": { 7018 + "@rolldown/binding-android-arm64": "1.0.0-rc.15", 7019 + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", 7020 + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", 7021 + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", 7022 + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", 7023 + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", 7024 + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", 7025 + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", 7026 + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", 7027 + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", 7028 + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", 7029 + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", 7030 + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", 7031 + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", 7032 + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" 7033 + } 7034 + }, 6496 7035 "node_modules/run-parallel": { 6497 7036 "version": "1.2.0", 6498 7037 "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", ··· 6794 7333 "url": "https://github.com/sponsors/ljharb" 6795 7334 } 6796 7335 }, 7336 + "node_modules/siginfo": { 7337 + "version": "2.0.0", 7338 + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", 7339 + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", 7340 + "dev": true, 7341 + "license": "ISC" 7342 + }, 6797 7343 "node_modules/source-map-js": { 6798 7344 "version": "1.2.1", 6799 7345 "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", ··· 6807 7353 "version": "0.0.5", 6808 7354 "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", 6809 7355 "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", 7356 + "dev": true, 7357 + "license": "MIT" 7358 + }, 7359 + "node_modules/stackback": { 7360 + "version": "0.0.2", 7361 + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", 7362 + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", 7363 + "dev": true, 7364 + "license": "MIT" 7365 + }, 7366 + "node_modules/std-env": { 7367 + "version": "4.0.0", 7368 + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", 7369 + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", 6810 7370 "dev": true, 6811 7371 "license": "MIT" 6812 7372 }, ··· 7030 7590 "url": "https://opencollective.com/webpack" 7031 7591 } 7032 7592 }, 7593 + "node_modules/tinybench": { 7594 + "version": "2.9.0", 7595 + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", 7596 + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", 7597 + "dev": true, 7598 + "license": "MIT" 7599 + }, 7600 + "node_modules/tinyexec": { 7601 + "version": "1.1.1", 7602 + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", 7603 + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", 7604 + "dev": true, 7605 + "license": "MIT", 7606 + "engines": { 7607 + "node": ">=18" 7608 + } 7609 + }, 7033 7610 "node_modules/tinyglobby": { 7034 7611 "version": "0.2.15", 7035 7612 "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", ··· 7078 7655 "url": "https://github.com/sponsors/jonschlinkert" 7079 7656 } 7080 7657 }, 7658 + "node_modules/tinyrainbow": { 7659 + "version": "3.1.0", 7660 + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", 7661 + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", 7662 + "dev": true, 7663 + "license": "MIT", 7664 + "engines": { 7665 + "node": ">=14.0.0" 7666 + } 7667 + }, 7081 7668 "node_modules/tlds": { 7082 7669 "version": "1.261.0", 7083 7670 "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", ··· 7111 7698 }, 7112 7699 "peerDependencies": { 7113 7700 "typescript": ">=4.8.4" 7701 + } 7702 + }, 7703 + "node_modules/tsconfck": { 7704 + "version": "3.1.6", 7705 + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", 7706 + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", 7707 + "dev": true, 7708 + "license": "MIT", 7709 + "bin": { 7710 + "tsconfck": "bin/tsconfck.js" 7711 + }, 7712 + "engines": { 7713 + "node": "^18 || >=20" 7714 + }, 7715 + "peerDependencies": { 7716 + "typescript": "^5.0.0" 7717 + }, 7718 + "peerDependenciesMeta": { 7719 + "typescript": { 7720 + "optional": true 7721 + } 7114 7722 } 7115 7723 }, 7116 7724 "node_modules/tsconfig-paths": { ··· 7420 8028 "punycode": "^2.1.0" 7421 8029 } 7422 8030 }, 8031 + "node_modules/vite": { 8032 + "version": "8.0.8", 8033 + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", 8034 + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", 8035 + "dev": true, 8036 + "license": "MIT", 8037 + "dependencies": { 8038 + "lightningcss": "^1.32.0", 8039 + "picomatch": "^4.0.4", 8040 + "postcss": "^8.5.8", 8041 + "rolldown": "1.0.0-rc.15", 8042 + "tinyglobby": "^0.2.15" 8043 + }, 8044 + "bin": { 8045 + "vite": "bin/vite.js" 8046 + }, 8047 + "engines": { 8048 + "node": "^20.19.0 || >=22.12.0" 8049 + }, 8050 + "funding": { 8051 + "url": "https://github.com/vitejs/vite?sponsor=1" 8052 + }, 8053 + "optionalDependencies": { 8054 + "fsevents": "~2.3.3" 8055 + }, 8056 + "peerDependencies": { 8057 + "@types/node": "^20.19.0 || >=22.12.0", 8058 + "@vitejs/devtools": "^0.1.0", 8059 + "esbuild": "^0.27.0 || ^0.28.0", 8060 + "jiti": ">=1.21.0", 8061 + "less": "^4.0.0", 8062 + "sass": "^1.70.0", 8063 + "sass-embedded": "^1.70.0", 8064 + "stylus": ">=0.54.8", 8065 + "sugarss": "^5.0.0", 8066 + "terser": "^5.16.0", 8067 + "tsx": "^4.8.1", 8068 + "yaml": "^2.4.2" 8069 + }, 8070 + "peerDependenciesMeta": { 8071 + "@types/node": { 8072 + "optional": true 8073 + }, 8074 + "@vitejs/devtools": { 8075 + "optional": true 8076 + }, 8077 + "esbuild": { 8078 + "optional": true 8079 + }, 8080 + "jiti": { 8081 + "optional": true 8082 + }, 8083 + "less": { 8084 + "optional": true 8085 + }, 8086 + "sass": { 8087 + "optional": true 8088 + }, 8089 + "sass-embedded": { 8090 + "optional": true 8091 + }, 8092 + "stylus": { 8093 + "optional": true 8094 + }, 8095 + "sugarss": { 8096 + "optional": true 8097 + }, 8098 + "terser": { 8099 + "optional": true 8100 + }, 8101 + "tsx": { 8102 + "optional": true 8103 + }, 8104 + "yaml": { 8105 + "optional": true 8106 + } 8107 + } 8108 + }, 8109 + "node_modules/vite-tsconfig-paths": { 8110 + "version": "6.1.1", 8111 + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.1.1.tgz", 8112 + "integrity": "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==", 8113 + "dev": true, 8114 + "license": "MIT", 8115 + "dependencies": { 8116 + "debug": "^4.1.1", 8117 + "globrex": "^0.1.2", 8118 + "tsconfck": "^3.0.3" 8119 + }, 8120 + "peerDependencies": { 8121 + "vite": "*" 8122 + } 8123 + }, 8124 + "node_modules/vite/node_modules/picomatch": { 8125 + "version": "4.0.4", 8126 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", 8127 + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", 8128 + "dev": true, 8129 + "license": "MIT", 8130 + "engines": { 8131 + "node": ">=12" 8132 + }, 8133 + "funding": { 8134 + "url": "https://github.com/sponsors/jonschlinkert" 8135 + } 8136 + }, 8137 + "node_modules/vitest": { 8138 + "version": "4.1.4", 8139 + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", 8140 + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", 8141 + "dev": true, 8142 + "license": "MIT", 8143 + "dependencies": { 8144 + "@vitest/expect": "4.1.4", 8145 + "@vitest/mocker": "4.1.4", 8146 + "@vitest/pretty-format": "4.1.4", 8147 + "@vitest/runner": "4.1.4", 8148 + "@vitest/snapshot": "4.1.4", 8149 + "@vitest/spy": "4.1.4", 8150 + "@vitest/utils": "4.1.4", 8151 + "es-module-lexer": "^2.0.0", 8152 + "expect-type": "^1.3.0", 8153 + "magic-string": "^0.30.21", 8154 + "obug": "^2.1.1", 8155 + "pathe": "^2.0.3", 8156 + "picomatch": "^4.0.3", 8157 + "std-env": "^4.0.0-rc.1", 8158 + "tinybench": "^2.9.0", 8159 + "tinyexec": "^1.0.2", 8160 + "tinyglobby": "^0.2.15", 8161 + "tinyrainbow": "^3.1.0", 8162 + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", 8163 + "why-is-node-running": "^2.3.0" 8164 + }, 8165 + "bin": { 8166 + "vitest": "vitest.mjs" 8167 + }, 8168 + "engines": { 8169 + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" 8170 + }, 8171 + "funding": { 8172 + "url": "https://opencollective.com/vitest" 8173 + }, 8174 + "peerDependencies": { 8175 + "@edge-runtime/vm": "*", 8176 + "@opentelemetry/api": "^1.9.0", 8177 + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", 8178 + "@vitest/browser-playwright": "4.1.4", 8179 + "@vitest/browser-preview": "4.1.4", 8180 + "@vitest/browser-webdriverio": "4.1.4", 8181 + "@vitest/coverage-istanbul": "4.1.4", 8182 + "@vitest/coverage-v8": "4.1.4", 8183 + "@vitest/ui": "4.1.4", 8184 + "happy-dom": "*", 8185 + "jsdom": "*", 8186 + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" 8187 + }, 8188 + "peerDependenciesMeta": { 8189 + "@edge-runtime/vm": { 8190 + "optional": true 8191 + }, 8192 + "@opentelemetry/api": { 8193 + "optional": true 8194 + }, 8195 + "@types/node": { 8196 + "optional": true 8197 + }, 8198 + "@vitest/browser-playwright": { 8199 + "optional": true 8200 + }, 8201 + "@vitest/browser-preview": { 8202 + "optional": true 8203 + }, 8204 + "@vitest/browser-webdriverio": { 8205 + "optional": true 8206 + }, 8207 + "@vitest/coverage-istanbul": { 8208 + "optional": true 8209 + }, 8210 + "@vitest/coverage-v8": { 8211 + "optional": true 8212 + }, 8213 + "@vitest/ui": { 8214 + "optional": true 8215 + }, 8216 + "happy-dom": { 8217 + "optional": true 8218 + }, 8219 + "jsdom": { 8220 + "optional": true 8221 + }, 8222 + "vite": { 8223 + "optional": false 8224 + } 8225 + } 8226 + }, 8227 + "node_modules/vitest/node_modules/picomatch": { 8228 + "version": "4.0.4", 8229 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", 8230 + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", 8231 + "dev": true, 8232 + "license": "MIT", 8233 + "engines": { 8234 + "node": ">=12" 8235 + }, 8236 + "funding": { 8237 + "url": "https://github.com/sponsors/jonschlinkert" 8238 + } 8239 + }, 7423 8240 "node_modules/which": { 7424 8241 "version": "2.0.2", 7425 8242 "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", ··· 7523 8340 }, 7524 8341 "funding": { 7525 8342 "url": "https://github.com/sponsors/ljharb" 8343 + } 8344 + }, 8345 + "node_modules/why-is-node-running": { 8346 + "version": "2.3.0", 8347 + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", 8348 + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", 8349 + "dev": true, 8350 + "license": "MIT", 8351 + "dependencies": { 8352 + "siginfo": "^2.0.0", 8353 + "stackback": "0.0.2" 8354 + }, 8355 + "bin": { 8356 + "why-is-node-running": "cli.js" 8357 + }, 8358 + "engines": { 8359 + "node": ">=8" 7526 8360 } 7527 8361 }, 7528 8362 "node_modules/word-wrap": {
+6 -2
package.json
··· 8 8 "start": "next start", 9 9 "lint": "eslint", 10 10 "transcribe": "tsx scripts/transcribe.ts", 11 - "build-talk-index": "tsx scripts/build-talk-index.ts" 11 + "build-talk-index": "tsx scripts/build-talk-index.ts", 12 + "test": "vitest run", 13 + "test:watch": "vitest" 12 14 }, 13 15 "dependencies": { 14 16 "@atproto/api": "^0.19.6", ··· 29 31 "eslint-config-next": "16.2.2", 30 32 "tailwindcss": "^4", 31 33 "tsx": "^4.21.0", 32 - "typescript": "^5" 34 + "typescript": "^5", 35 + "vite-tsconfig-paths": "^6.1.1", 36 + "vitest": "^4.1.4" 33 37 } 34 38 }
+228
src/lib/scoring/combine.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + combineLayers, 4 + DEFAULT_ACTIVE_LAYERS, 5 + type ActiveLayers, 6 + } from "./combine"; 7 + import type { Layer1Result, ScoringWeights } from "./types"; 8 + 9 + const DEFAULT_WEIGHTS: ScoringWeights = { 10 + surpriseSlider: 0.5, 11 + friendsSlider: 0.5, 12 + }; 13 + 14 + function l1(attentionInverse: number): Layer1Result { 15 + return { 16 + uniqueFollows: 0, 17 + totalFollows: 0, 18 + reachRatio: 1 - attentionInverse, 19 + attentionInverse, 20 + }; 21 + } 22 + 23 + describe("combineLayers — DEFAULT_ACTIVE_LAYERS sentinel", () => { 24 + it("has both layers off by default", () => { 25 + expect(DEFAULT_ACTIVE_LAYERS).toEqual({ layer2: false, layer3: false }); 26 + }); 27 + }); 28 + 29 + describe("combineLayers — Layer 1 only (today's deployment)", () => { 30 + const active: ActiveLayers = { layer2: false, layer3: false }; 31 + 32 + it("returns layer1.attentionInverse for fully missed talk", () => { 33 + const result = combineLayers( 34 + l1(0.95), 35 + { interestScore: 0 }, 36 + { friendBoost: 0, recommenders: [] }, 37 + DEFAULT_WEIGHTS, 38 + active, 39 + ); 40 + expect(result).toBeCloseTo(0.95, 6); 41 + }); 42 + 43 + it("returns layer1.attentionInverse for partially engaged talk", () => { 44 + const result = combineLayers( 45 + l1(0.4), 46 + { interestScore: 0 }, 47 + { friendBoost: 0, recommenders: [] }, 48 + DEFAULT_WEIGHTS, 49 + active, 50 + ); 51 + expect(result).toBeCloseTo(0.4, 6); 52 + }); 53 + 54 + it("uses DEFAULT_ACTIVE_LAYERS when active arg omitted", () => { 55 + const result = combineLayers( 56 + l1(0.7), 57 + { interestScore: 1.0 }, // ignored — layer 2 is inactive 58 + { friendBoost: 1.0, recommenders: ["did:plc:x"] }, // ignored 59 + DEFAULT_WEIGHTS, 60 + ); 61 + expect(result).toBeCloseTo(0.7, 6); 62 + }); 63 + }); 64 + 65 + describe("combineLayers — Layer 1 + Layer 2 (future stage)", () => { 66 + const active: ActiveLayers = { layer2: true, layer3: false }; 67 + 68 + it("rescales weights to [0.5/0.8, 0.3/0.8] and reaches 1.0 at maximum", () => { 69 + // (1.0 * 0.5 + 1.0 * (1 - 0) * 0.3) / 0.8 = 0.8 / 0.8 = 1.0 70 + const result = combineLayers( 71 + l1(1.0), 72 + { interestScore: 1.0 }, 73 + { friendBoost: 0, recommenders: [] }, 74 + { surpriseSlider: 0, friendsSlider: 0.5 }, 75 + active, 76 + ); 77 + expect(result).toBeCloseTo(1.0, 6); 78 + }); 79 + 80 + it("returns 0.625 for fully missed talk with no interest score", () => { 81 + // (1.0 * 0.5 + 0 * 0.5 * 0.3) / 0.8 = 0.5 / 0.8 = 0.625 82 + const result = combineLayers( 83 + l1(1.0), 84 + { interestScore: 0 }, 85 + { friendBoost: 0, recommenders: [] }, 86 + DEFAULT_WEIGHTS, 87 + active, 88 + ); 89 + expect(result).toBeCloseTo(0.625, 6); 90 + }); 91 + }); 92 + 93 + describe("combineLayers — Layer 1 + Layer 3 (future stage)", () => { 94 + const active: ActiveLayers = { layer2: false, layer3: true }; 95 + 96 + it("rescales weights to [0.5/0.7, 0.2/0.7]", () => { 97 + // (0 * 0.5 + 1.0 * 1 * 0.2) / 0.7 = 0.2 / 0.7 ≈ 0.2857 98 + const result = combineLayers( 99 + l1(0.0), 100 + { interestScore: 0 }, 101 + { friendBoost: 1.0, recommenders: ["did:plc:a"] }, 102 + { surpriseSlider: 0.5, friendsSlider: 1 }, 103 + active, 104 + ); 105 + expect(result).toBeCloseTo(0.2 / 0.7, 6); 106 + }); 107 + }); 108 + 109 + describe("combineLayers — all three layers active (final stage)", () => { 110 + const active: ActiveLayers = { layer2: true, layer3: true }; 111 + 112 + it("matches the design-doc formula exactly at maximum", () => { 113 + // 1.0 * 0.5 + 1.0 * 1 * 0.3 + 1.0 * 1 * 0.2 = 1.0 114 + const result = combineLayers( 115 + l1(1.0), 116 + { interestScore: 1.0 }, 117 + { friendBoost: 1.0, recommenders: ["did:plc:a"] }, 118 + { surpriseSlider: 0, friendsSlider: 1 }, 119 + active, 120 + ); 121 + expect(result).toBeCloseTo(1.0, 6); 122 + }); 123 + }); 124 + 125 + describe("combineLayers — clamping and NaN guards", () => { 126 + it("clamps result to [0, 1] when slider drives raw above 1", () => { 127 + // surprise = -2 → (1 - (-2)) = 3 multiplier on l2 128 + // (1.0 * 0.5 + 1.0 * 3 * 0.3) / 0.8 = 1.4 / 0.8 = 1.75 → clamped to 1.0 129 + const result = combineLayers( 130 + l1(1.0), 131 + { interestScore: 1.0 }, 132 + { friendBoost: 0, recommenders: [] }, 133 + { surpriseSlider: -2, friendsSlider: 0.5 }, 134 + { layer2: true, layer3: false }, 135 + ); 136 + expect(result).toBeCloseTo(1.0, 6); 137 + }); 138 + 139 + it("coerces NaN slider to 0 (equivalent to surprise=0)", () => { 140 + // surprise=NaN → safe(NaN)=0 → (1.0 * 0.5 + 1.0 * 1 * 0.3) / 0.8 = 1.0 141 + const result = combineLayers( 142 + l1(1.0), 143 + { interestScore: 1.0 }, 144 + { friendBoost: 0, recommenders: [] }, 145 + { surpriseSlider: Number.NaN, friendsSlider: 0.5 }, 146 + { layer2: true, layer3: false }, 147 + ); 148 + expect(result).toBeCloseTo(1.0, 6); 149 + }); 150 + 151 + it("coerces ±Infinity to 0", () => { 152 + const result = combineLayers( 153 + l1(1.0), 154 + { interestScore: Number.POSITIVE_INFINITY }, 155 + { friendBoost: Number.NEGATIVE_INFINITY, recommenders: [] }, 156 + DEFAULT_WEIGHTS, 157 + { layer2: true, layer3: true }, 158 + ); 159 + // All non-finite stub values become 0; only L1 contributes. 160 + // (1.0 * 0.5 + 0 + 0) / 1.0 = 0.5 161 + expect(result).toBeCloseTo(0.5, 6); 162 + }); 163 + 164 + it("zeroes out layer 2 contribution when surpriseSlider is exactly 1", () => { 165 + // surprise = 1 → (1 - 1) = 0, so layer 2 contributes nothing even with 166 + // layer 2 active and a positive interestScore. Equivalent to 167 + // intensity = (1.0 * 0.5 + 0) / 0.8 = 0.625 168 + const result = combineLayers( 169 + l1(1.0), 170 + { interestScore: 1.0 }, 171 + { friendBoost: 0, recommenders: [] }, 172 + { surpriseSlider: 1, friendsSlider: 0.5 }, 173 + { layer2: true, layer3: false }, 174 + ); 175 + expect(result).toBeCloseTo(0.625, 6); 176 + }); 177 + 178 + it("zeroes out layer 3 contribution when friendsSlider is exactly 0", () => { 179 + // friends = 0 → friendBoost * 0 * w3 = 0, so layer 3 contributes nothing 180 + // even with layer 3 active. (0 * 0.5 + 1.0 * 0 * 0.2) / 0.7 = 0 181 + const result = combineLayers( 182 + l1(0.0), 183 + { interestScore: 0 }, 184 + { friendBoost: 1.0, recommenders: ["did:plc:a"] }, 185 + { surpriseSlider: 0.5, friendsSlider: 0 }, 186 + { layer2: false, layer3: true }, 187 + ); 188 + expect(result).toBeCloseTo(0, 6); 189 + }); 190 + }); 191 + 192 + describe("combineLayers — REGRESSION: per-talk discontinuity bug", () => { 193 + // This test specifically locks in the correct behavior the renormalization 194 + // fix introduced. If a future refactor reintroduces a per-talk `> 0` branch 195 + // on stub outputs, this assertion will fail loudly. 196 + // 197 + // The bug: with a per-talk `> 0` check, two talks with identical Layer 1 198 + // (0.95) but different Layer 2 (0 vs 0.4) would rank as: 199 + // Talk A (interest=0): takes "stub-only" branch → 0.95 200 + // Talk B (interest=0.4): takes "weighted" branch → 0.535 201 + // Talk B (the one user cares about per L2) ranks BELOW Talk A. 202 + it("ranks talk with positive interest score above identical-L1 talk with zero interest", () => { 203 + const active: ActiveLayers = { layer2: true, layer3: false }; 204 + 205 + const intensityA = combineLayers( 206 + l1(0.95), 207 + { interestScore: 0.0 }, 208 + { friendBoost: 0, recommenders: [] }, 209 + DEFAULT_WEIGHTS, 210 + active, 211 + ); 212 + 213 + const intensityB = combineLayers( 214 + l1(0.95), 215 + { interestScore: 0.4 }, 216 + { friendBoost: 0, recommenders: [] }, 217 + DEFAULT_WEIGHTS, 218 + active, 219 + ); 220 + 221 + // Pre-computed expected values from spec §11.2: 222 + // intensityA = (0.95*0.5 + 0.0*0.5*0.3) / 0.8 = 0.59375 223 + // intensityB = (0.95*0.5 + 0.4*0.5*0.3) / 0.8 = 0.66875 224 + expect(intensityA).toBeCloseTo(0.59375, 6); 225 + expect(intensityB).toBeCloseTo(0.66875, 6); 226 + expect(intensityB).toBeGreaterThan(intensityA); 227 + }); 228 + });
+90
src/lib/scoring/combine.ts
··· 1 + import type { Layer1Result, ScoringWeights } from "./types"; 2 + import type { InterestStubResult } from "./interestStub"; 3 + import type { FriendStubResult } from "./friendStub"; 4 + 5 + function clamp(n: number, min: number, max: number): number { 6 + return Math.max(min, Math.min(max, n)); 7 + } 8 + 9 + /** 10 + * Coerce non-finite numeric inputs (NaN, ±Infinity) to 0. Defense in depth 11 + * against uninitialized React state or JSON-parsed nulls slipping past 12 + * TypeScript types and propagating into the sort key. 13 + */ 14 + function safe(n: number): number { 15 + return Number.isFinite(n) ? n : 0; 16 + } 17 + 18 + /** 19 + * Which scoring layers have a live data source. Layer 1 is always live; 20 + * Layers 2 and 3 flip to true when their respective implementations land 21 + * (#21–24 for Layer 2, #18 for Layer 3). Today both are false. 22 + */ 23 + export interface ActiveLayers { 24 + readonly layer2: boolean; 25 + readonly layer3: boolean; 26 + } 27 + 28 + // Frozen so the exported sentinel can't be mutated by accident — it's used 29 + // as a default parameter value in combineLayers/scoreTalk/rankTalks, so any 30 + // mutation would corrupt every subsequent call that takes the default. 31 + export const DEFAULT_ACTIVE_LAYERS: Readonly<ActiveLayers> = Object.freeze({ 32 + layer2: false, 33 + layer3: false, 34 + }); 35 + 36 + /** 37 + * Design-doc weights from `docs/understory-design.md` §"The Scoring Algorithm". 38 + * These values are the canonical contribution shares when all three layers 39 + * are live; they are rescaled in `combineLayers` for partial deployments. 40 + */ 41 + const DESIGN_WEIGHTS = { 42 + layer1: 0.5, 43 + layer2: 0.3, 44 + layer3: 0.2, 45 + } as const; 46 + 47 + /** 48 + * Combine the three layers into a 0–1 intensity score. 49 + * 50 + * Per the design doc: 51 + * final = (attention_inverse * 0.5) 52 + * + (interest_score * (1 - surprise_slider) * 0.3) 53 + * + (friend_boost * friends_slider * 0.2) 54 + * 55 + * Weights are rescaled over the active layer set so the maximum achievable 56 + * intensity is always 1.0: 57 + * - Today (layer 1 only): w1 = 0.5/0.5 = 1.0 → intensity == attentionInverse 58 + * - Layer 1 + 2: w1 = 0.5/0.8, w2 = 0.3/0.8 (sum = 1.0) 59 + * - Layer 1 + 3: w1 = 0.5/0.7, w3 = 0.2/0.7 (sum = 1.0) 60 + * - All three: w1 = 0.5, w2 = 0.3, w3 = 0.2 (already sum to 1.0) 61 + * 62 + * Stubs are still consulted when their layer is inactive, but their values 63 + * are multiplied by a zero weight — so swapping a stub for a real 64 + * implementation is purely a data change once the active flag flips. 65 + */ 66 + export function combineLayers( 67 + layer1: Layer1Result, 68 + layer2: InterestStubResult, 69 + layer3: FriendStubResult, 70 + weights: ScoringWeights, 71 + active: ActiveLayers = DEFAULT_ACTIVE_LAYERS, 72 + ): number { 73 + const w1 = DESIGN_WEIGHTS.layer1; 74 + const w2 = active.layer2 ? DESIGN_WEIGHTS.layer2 : 0; 75 + const w3 = active.layer3 ? DESIGN_WEIGHTS.layer3 : 0; 76 + const total = w1 + w2 + w3; // always > 0 because layer 1 is always live 77 + 78 + const l1 = safe(layer1.attentionInverse); 79 + const l2 = active.layer2 ? safe(layer2.interestScore) : 0; 80 + const l3 = active.layer3 ? safe(layer3.friendBoost) : 0; 81 + const surprise = safe(weights.surpriseSlider); 82 + const friends = safe(weights.friendsSlider); 83 + 84 + const raw = 85 + l1 * w1 + 86 + l2 * (1 - surprise) * w2 + 87 + l3 * friends * w3; 88 + 89 + return clamp(raw / total, 0, 1); 90 + }
+20
src/lib/scoring/friendStub.ts
··· 1 + import type { TalkEntry } from "@/lib/types"; 2 + 3 + export interface FriendStubResult { 4 + friendBoost: number; 5 + recommenders: string[]; 6 + } 7 + 8 + /** 9 + * Layer 3 stub. Returns 0 until the following issues land: 10 + * - #18: friend recommendation reader 11 + * - #5: publish lexicons 12 + * 13 + * When implemented, this should return the normalized sum of friend 14 + * recommendation intensities (1–3 each, capped at a sensible max) and the 15 + * DIDs of the recommending follows. 16 + */ 17 + export function computeFriendStub(talk: TalkEntry): FriendStubResult { 18 + void talk; 19 + return { friendBoost: 0, recommenders: [] }; 20 + }
+21
src/lib/scoring/index.ts
··· 1 + // Public surface for the scoring module. Consumers should import from 2 + // `@/lib/scoring`, not from individual files, so refactors inside the module 3 + // don't break call sites. 4 + 5 + export type { 6 + TalkScore, 7 + TalkScoreState, 8 + Layer1Result, 9 + ScoringWeights, 10 + ScoringInputs, 11 + TalkMention, 12 + TalkMentions, 13 + } from "./types"; 14 + 15 + export { DEFAULT_WEIGHTS } from "./types"; 16 + 17 + export type { ActiveLayers } from "./combine"; 18 + export { DEFAULT_ACTIVE_LAYERS, combineLayers } from "./combine"; 19 + 20 + export { computeLayer1 } from "./networkAttention"; 21 + export { scoreTalk, rankTalks } from "./rank";
+24
src/lib/scoring/interestStub.ts
··· 1 + import type { TalkEntry } from "@/lib/types"; 2 + 3 + export interface InterestStubResult { 4 + interestScore: number; 5 + } 6 + 7 + /** 8 + * Layer 2 stub. Returns 0 until the following issues land: 9 + * - #21: generate transcript embeddings 10 + * - #22: publish topicIndex records 11 + * - #23: user interest profiling 12 + * - #24: cosine similarity matching 13 + * 14 + * When implemented, this should return cosine similarity in [0, 1] between 15 + * the user's recent-post embedding and the talk's topicIndex embedding. 16 + * 17 + * The `void talk;` statement marks the parameter as intentionally unused so 18 + * `@typescript-eslint/no-unused-vars` doesn't fire while the stub awaits its 19 + * real implementation. 20 + */ 21 + export function computeInterestStub(talk: TalkEntry): InterestStubResult { 22 + void talk; 23 + return { interestScore: 0 }; 24 + }
+56
src/lib/scoring/networkAttention.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { computeLayer1 } from "./networkAttention"; 3 + import type { TalkMention } from "@/lib/crawl/types"; 4 + 5 + // Build a TalkMention with `n` distinct follow DIDs. 6 + // Note: this is "follows engaged with this talk", NOT the user's total 7 + // follow count — that's passed separately as the second arg to computeLayer1. 8 + function makeMention(n: number): TalkMention { 9 + const follows = Array.from({ length: n }, (_, i) => `did:plc:f${i}`); 10 + return { 11 + count: n, 12 + follows, 13 + posts: [], 14 + rsvps: [], 15 + }; 16 + } 17 + 18 + describe("computeLayer1", () => { 19 + it("returns attentionInverse 1.0 when zero follows engaged", () => { 20 + const result = computeLayer1(makeMention(0), 100); 21 + expect(result.uniqueFollows).toBe(0); 22 + expect(result.totalFollows).toBe(100); 23 + expect(result.reachRatio).toBeCloseTo(0, 6); 24 + expect(result.attentionInverse).toBeCloseTo(1.0, 6); 25 + }); 26 + 27 + it("returns attentionInverse 0.5 when half engaged", () => { 28 + const result = computeLayer1(makeMention(50), 100); 29 + expect(result.reachRatio).toBeCloseTo(0.5, 6); 30 + expect(result.attentionInverse).toBeCloseTo(0.5, 6); 31 + }); 32 + 33 + it("returns attentionInverse 0.0 when fully engaged", () => { 34 + const result = computeLayer1(makeMention(100), 100); 35 + expect(result.reachRatio).toBeCloseTo(1.0, 6); 36 + expect(result.attentionInverse).toBeCloseTo(0.0, 6); 37 + }); 38 + 39 + it("returns attentionInverse 1.0 when followCount is 0 (divide-by-zero guard)", () => { 40 + const result = computeLayer1(makeMention(3), 0); 41 + expect(result.reachRatio).toBeCloseTo(0, 6); 42 + expect(result.attentionInverse).toBeCloseTo(1.0, 6); 43 + }); 44 + 45 + it("clamps reachRatio to 1 when stale data has more follows than followCount", () => { 46 + const result = computeLayer1(makeMention(110), 100); 47 + expect(result.reachRatio).toBeCloseTo(1.0, 6); 48 + expect(result.attentionInverse).toBeCloseTo(0.0, 6); 49 + }); 50 + 51 + it("treats undefined mention as zero engagement", () => { 52 + const result = computeLayer1(undefined, 100); 53 + expect(result.uniqueFollows).toBe(0); 54 + expect(result.attentionInverse).toBeCloseTo(1.0, 6); 55 + }); 56 + });
+34
src/lib/scoring/networkAttention.ts
··· 1 + import type { TalkMention } from "@/lib/crawl/types"; 2 + import type { Layer1Result } from "./types"; 3 + 4 + /** 5 + * Compute the Layer 1 (network attention, inverted) score for a single talk. 6 + * 7 + * Returns the fraction of the user's follows who engaged with the talk 8 + * (`reachRatio`) and its inverse (`attentionInverse`), where 1.0 means 9 + * "nobody in your network engaged" and 0.0 means "every single one of your 10 + * follows engaged." 11 + * 12 + * We use `mention.follows.length` rather than `mention.count` so the algorithm 13 + * is robust to a future crawler change that decouples the two. Today the 14 + * crawler enforces `count === follows.length`. 15 + */ 16 + export function computeLayer1( 17 + mention: TalkMention | undefined, 18 + followCount: number, 19 + ): Layer1Result { 20 + const uniqueFollows = mention?.follows.length ?? 0; 21 + // Clamp to [0, 1]: uniqueFollows can theoretically exceed followCount if a 22 + // CrawlResult is reused after the user's follow list changes (someone 23 + // unfollowed but still appears in cached mentions). The clamp prevents 24 + // attentionInverse from going negative in that edge case. 25 + const reachRatio = 26 + followCount > 0 ? Math.min(1, uniqueFollows / followCount) : 0; 27 + const attentionInverse = 1 - reachRatio; 28 + return { 29 + uniqueFollows, 30 + totalFollows: followCount, 31 + reachRatio, 32 + attentionInverse, 33 + }; 34 + }
+237
src/lib/scoring/rank.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { scoreTalk, rankTalks } from "./rank"; 3 + import { DEFAULT_WEIGHTS } from "./types"; 4 + import { DEFAULT_ACTIVE_LAYERS, type ActiveLayers } from "./combine"; 5 + import type { TalkEntry } from "@/lib/types"; 6 + import type { TalkMention, TalkMentions } from "@/lib/crawl/types"; 7 + 8 + function makeTalk(rkey: string, overrides: Partial<TalkEntry> = {}): TalkEntry { 9 + return { 10 + rkey, 11 + title: `Talk ${rkey}`, 12 + vodUri: `at://example/${rkey}`, 13 + vodCid: "bafy", 14 + hlsUrl: "", 15 + durationMs: 0, 16 + createdAt: "", 17 + eventUri: `at://event/${rkey}`, 18 + description: null, 19 + speakers: [], 20 + room: null, 21 + talkType: null, 22 + category: null, 23 + startsAt: null, 24 + endsAt: null, 25 + transcriptFile: null, 26 + ...overrides, 27 + }; 28 + } 29 + 30 + // Build a TalkMention with `n` distinct follow DIDs (engaged follows for 31 + // this talk, NOT the user's total follow count — that's a separate arg). 32 + function makeMention(n: number): TalkMention { 33 + const follows = Array.from({ length: n }, (_, i) => `did:plc:f${i}`); 34 + return { 35 + count: n, 36 + follows, 37 + posts: [], 38 + rsvps: [], 39 + }; 40 + } 41 + 42 + describe("scoreTalk — state derivation", () => { 43 + const talk = makeTalk("a"); 44 + 45 + it("returns unknown when mentions is null", () => { 46 + const score = scoreTalk(talk, null, 100); 47 + expect(score.state).toBe("unknown"); 48 + expect(score.intensity).toBe(0); 49 + }); 50 + 51 + it("returns unknown when followCount is 0", () => { 52 + const score = scoreTalk(talk, { a: makeMention(5) }, 0); 53 + expect(score.state).toBe("unknown"); 54 + }); 55 + 56 + it("returns unknown when the talk has no mention entry (out of crawl scope)", () => { 57 + const score = scoreTalk(talk, {}, 100); 58 + expect(score.state).toBe("unknown"); 59 + }); 60 + 61 + it("returns missed when uniqueFollows is 0 but talk is in scope", () => { 62 + const score = scoreTalk(talk, { a: makeMention(0) }, 100); 63 + expect(score.state).toBe("missed"); 64 + expect(score.intensity).toBeCloseTo(1.0, 6); 65 + }); 66 + 67 + it("returns engaged when at least one follow engaged", () => { 68 + const score = scoreTalk(talk, { a: makeMention(3) }, 100); 69 + expect(score.state).toBe("engaged"); 70 + expect(score.intensity).toBeCloseTo(0.97, 6); 71 + }); 72 + 73 + it("returns unknown when followCount is negative", () => { 74 + const score = scoreTalk(talk, { a: makeMention(5) }, -3); 75 + expect(score.state).toBe("unknown"); 76 + // totalFollows is sanitized to 0, not the bogus -3, so JSON serialization 77 + // and downstream consumers see a stable shape. 78 + expect(score.layer1.totalFollows).toBe(0); 79 + }); 80 + 81 + it("returns unknown when followCount is NaN", () => { 82 + const score = scoreTalk(talk, { a: makeMention(5) }, Number.NaN); 83 + expect(score.state).toBe("unknown"); 84 + expect(score.layer1.totalFollows).toBe(0); 85 + }); 86 + 87 + it("returns unknown when followCount is +Infinity", () => { 88 + const score = scoreTalk( 89 + talk, 90 + { a: makeMention(5) }, 91 + Number.POSITIVE_INFINITY, 92 + ); 93 + expect(score.state).toBe("unknown"); 94 + expect(score.layer1.totalFollows).toBe(0); 95 + }); 96 + 97 + it("returns unknown when followCount is -Infinity", () => { 98 + const score = scoreTalk( 99 + talk, 100 + { a: makeMention(5) }, 101 + Number.NEGATIVE_INFINITY, 102 + ); 103 + expect(score.state).toBe("unknown"); 104 + expect(score.layer1.totalFollows).toBe(0); 105 + }); 106 + }); 107 + 108 + describe("DEFAULT_WEIGHTS / DEFAULT_ACTIVE_LAYERS — frozen sentinels", () => { 109 + it("DEFAULT_WEIGHTS is frozen so accidental mutation throws or no-ops", () => { 110 + // Object.freeze makes assignment a silent no-op in sloppy mode and throws 111 + // in strict mode. Either way, the value cannot change. 112 + expect(Object.isFrozen(DEFAULT_WEIGHTS)).toBe(true); 113 + }); 114 + 115 + it("DEFAULT_ACTIVE_LAYERS is frozen so accidental mutation throws or no-ops", () => { 116 + expect(Object.isFrozen(DEFAULT_ACTIVE_LAYERS)).toBe(true); 117 + }); 118 + }); 119 + 120 + describe("scoreTalk — defaults", () => { 121 + const talk = makeTalk("a"); 122 + const mentions: TalkMentions = { a: makeMention(0) }; 123 + 124 + it("uses both DEFAULT_WEIGHTS and DEFAULT_ACTIVE_LAYERS when both omitted", () => { 125 + const score = scoreTalk(talk, mentions, 100); 126 + expect(score.intensity).toBeCloseTo(1.0, 6); 127 + }); 128 + 129 + it("uses DEFAULT_ACTIVE_LAYERS when active omitted but explicit weights supplied", () => { 130 + const score = scoreTalk(talk, mentions, 100, { 131 + surpriseSlider: 0.25, 132 + friendsSlider: 0.75, 133 + }); 134 + // active defaults to both-off → L1-only branch → weights don't enter 135 + // the math at all → intensity == layer1.attentionInverse == 1.0 136 + expect(score.intensity).toBeCloseTo(1.0, 6); 137 + }); 138 + 139 + it("uses DEFAULT_WEIGHTS when weights omitted but explicit active supplied", () => { 140 + const score = scoreTalk(talk, mentions, 100, undefined, { 141 + layer2: true, 142 + layer3: false, 143 + }); 144 + // L1+L2 active, L2 stub returns 0, default surprise=0.5 145 + // (1.0*0.5 + 0*0.5*0.3) / 0.8 = 0.625 146 + expect(score.intensity).toBeCloseTo(0.625, 6); 147 + }); 148 + }); 149 + 150 + describe("rankTalks — sort order", () => { 151 + const A = makeTalk("aaa"); 152 + const B = makeTalk("bbb"); 153 + const C = makeTalk("ccc"); 154 + const D = makeTalk("ddd"); 155 + const E = makeTalk("eee"); 156 + 157 + it("sorts missed first, then engaged (intensity desc), then unknown", () => { 158 + const mentions: TalkMentions = { 159 + aaa: makeMention(1), // engaged, intensity 0.99 160 + bbb: makeMention(0), // missed, intensity 1.0 161 + ccc: makeMention(50), // engaged, intensity 0.5 162 + // D, E: no mentions → unknown 163 + }; 164 + const result = rankTalks({ 165 + talks: [A, B, C, D, E], 166 + mentions, 167 + followCount: 100, 168 + }); 169 + 170 + expect(result.map((s) => s.rkey)).toEqual(["bbb", "aaa", "ccc", "ddd", "eee"]); 171 + }); 172 + 173 + it("uses rkey ascending as a deterministic tiebreak", () => { 174 + const Z = makeTalk("zzz"); 175 + const A = makeTalk("aaa"); 176 + const mentions: TalkMentions = { 177 + zzz: makeMention(0), 178 + aaa: makeMention(0), 179 + }; 180 + const result = rankTalks({ 181 + talks: [Z, A], // intentionally not in rkey order 182 + mentions, 183 + followCount: 100, 184 + }); 185 + 186 + // Both missed with intensity 1.0; tiebreak puts "aaa" before "zzz" 187 + expect(result[0].rkey).toBe("aaa"); 188 + expect(result[1].rkey).toBe("zzz"); 189 + }); 190 + 191 + it("threads weights and active flags through to combineLayers", () => { 192 + const active: ActiveLayers = { layer2: true, layer3: false }; 193 + const mentions: TalkMentions = { aaa: makeMention(0) }; 194 + const result = rankTalks({ 195 + talks: [A], 196 + mentions, 197 + followCount: 100, 198 + active, 199 + }); 200 + // L1 only contributes; L2 stub returns 0; rescale: 0.5/0.8 = 0.625 201 + expect(result[0].intensity).toBeCloseTo(0.625, 6); 202 + }); 203 + }); 204 + 205 + describe("rankTalks — empty / degenerate inputs", () => { 206 + it("returns [] for empty talks array", () => { 207 + const result = rankTalks({ 208 + talks: [], 209 + mentions: {}, 210 + followCount: 100, 211 + }); 212 + expect(result).toEqual([]); 213 + }); 214 + 215 + it("returns all unknown sorted by rkey when mentions is null", () => { 216 + const result = rankTalks({ 217 + talks: [makeTalk("ccc"), makeTalk("aaa"), makeTalk("bbb")], 218 + mentions: null, 219 + followCount: 100, 220 + }); 221 + expect(result.map((s) => s.state)).toEqual(["unknown", "unknown", "unknown"]); 222 + expect(result.map((s) => s.rkey)).toEqual(["aaa", "bbb", "ccc"]); 223 + }); 224 + 225 + it("returns all unknown when followCount is 0", () => { 226 + const result = rankTalks({ 227 + talks: [makeTalk("aaa"), makeTalk("bbb"), makeTalk("ccc")], 228 + mentions: { 229 + aaa: makeMention(5), 230 + bbb: makeMention(10), 231 + ccc: makeMention(0), 232 + }, 233 + followCount: 0, 234 + }); 235 + expect(result.map((s) => s.state)).toEqual(["unknown", "unknown", "unknown"]); 236 + }); 237 + });
+112
src/lib/scoring/rank.ts
··· 1 + import type { TalkEntry } from "@/lib/types"; 2 + import type { TalkMentions } from "@/lib/crawl/types"; 3 + import { 4 + type TalkScore, 5 + type TalkScoreState, 6 + type ScoringInputs, 7 + type ScoringWeights, 8 + DEFAULT_WEIGHTS, 9 + } from "./types"; 10 + import { computeLayer1 } from "./networkAttention"; 11 + import { computeInterestStub } from "./interestStub"; 12 + import { computeFriendStub } from "./friendStub"; 13 + import { 14 + type ActiveLayers, 15 + DEFAULT_ACTIVE_LAYERS, 16 + combineLayers, 17 + } from "./combine"; 18 + 19 + function unknownScore(rkey: string, followCount: number): TalkScore { 20 + // Sanitize: if followCount is non-finite or negative (e.g., from a corrupted 21 + // cache or a slider-derived value that wasn't validated upstream), don't 22 + // propagate the bad number into the result. Stash 0 so JSON serialization, 23 + // React rendering, and downstream consumers see a stable shape. 24 + const safeTotalFollows = 25 + Number.isFinite(followCount) && followCount > 0 ? followCount : 0; 26 + return { 27 + rkey, 28 + intensity: 0, 29 + state: "unknown", 30 + layer1: { 31 + uniqueFollows: 0, 32 + totalFollows: safeTotalFollows, 33 + reachRatio: 0, 34 + attentionInverse: 0, 35 + }, 36 + }; 37 + } 38 + 39 + /** 40 + * Score a single talk. Pass the full `mentions` map (or null if no crawl 41 + * has run yet) — the function looks up the talk's mention internally so 42 + * callers don't have to encode "do we have crawl data" as a separate flag. 43 + * 44 + * Returns `unknown` state when: 45 + * - mentions is null (crawl hasn't run) 46 + * - followCount is non-finite or ≤ 0 (user has no follows OR a corrupted 47 + * value snuck through — reach is undefined either way) 48 + * - mention is absent (talk is out of crawl scope, e.g. no eventUri) 49 + * 50 + * Otherwise runs Layer 1 + the two stubs through `combineLayers` with the 51 + * given weights and active layer flags. 52 + */ 53 + export function scoreTalk( 54 + talk: TalkEntry, 55 + mentions: TalkMentions | null, 56 + followCount: number, 57 + weights: ScoringWeights = DEFAULT_WEIGHTS, 58 + active: ActiveLayers = DEFAULT_ACTIVE_LAYERS, 59 + ): TalkScore { 60 + // Robust guard: catches null mentions, zero/negative followCount, NaN, and 61 + // ±Infinity in a single check. Anything that isn't a finite positive integer 62 + // routes to `unknown` rather than silently producing a wrong "missed". 63 + if (mentions === null || !Number.isFinite(followCount) || followCount <= 0) { 64 + return unknownScore(talk.rkey, followCount); 65 + } 66 + const mention = mentions[talk.rkey]; 67 + if (!mention) { 68 + // Talk is not in crawl scope (e.g. no eventUri so the crawler skipped it). 69 + return unknownScore(talk.rkey, followCount); 70 + } 71 + 72 + const layer1 = computeLayer1(mention, followCount); 73 + const layer2 = computeInterestStub(talk); 74 + const layer3 = computeFriendStub(talk); 75 + const intensity = combineLayers(layer1, layer2, layer3, weights, active); 76 + 77 + const state: TalkScoreState = 78 + layer1.uniqueFollows === 0 ? "missed" : "engaged"; 79 + 80 + return { rkey: talk.rkey, intensity, state, layer1 }; 81 + } 82 + 83 + const STATE_ORDER: Record<TalkScoreState, number> = { 84 + missed: 0, 85 + engaged: 1, 86 + unknown: 2, 87 + }; 88 + 89 + function compareTalkScores(a: TalkScore, b: TalkScore): number { 90 + // Primary: state group (missed first, then engaged, then unknown) 91 + const stateDelta = STATE_ORDER[a.state] - STATE_ORDER[b.state]; 92 + if (stateDelta !== 0) return stateDelta; 93 + // Secondary: intensity descending (highest glow first within each state) 94 + const intensityDelta = b.intensity - a.intensity; 95 + if (intensityDelta !== 0) return intensityDelta; 96 + // Tertiary: rkey ascending — deterministic tiebreak so the order is stable 97 + // across renders (matters for React reconciliation). 98 + return a.rkey.localeCompare(b.rkey); 99 + } 100 + 101 + export function rankTalks(inputs: ScoringInputs): TalkScore[] { 102 + const { 103 + talks, 104 + mentions, 105 + followCount, 106 + weights = DEFAULT_WEIGHTS, 107 + active = DEFAULT_ACTIVE_LAYERS, 108 + } = inputs; 109 + return talks 110 + .map((talk) => scoreTalk(talk, mentions, followCount, weights, active)) 111 + .sort(compareTalkScores); 112 + }
+53
src/lib/scoring/types.ts
··· 1 + import type { TalkEntry } from "@/lib/types"; 2 + import type { TalkMention, TalkMentions } from "@/lib/crawl/types"; 3 + // Local-import-then-re-export so `ScoringInputs` gets a usable local binding 4 + // for `ActiveLayers` AND callers can `import { ActiveLayers } from "@/lib/scoring/types"` 5 + // without needing to know `combine.ts` owns it. 6 + import type { ActiveLayers } from "./combine"; 7 + export type { ActiveLayers }; 8 + // Runtime re-export of the default sentinel so callers importing from 9 + // scoring/types get both the type and its default value in one place. 10 + export { DEFAULT_ACTIVE_LAYERS } from "./combine"; 11 + 12 + export type TalkScoreState = "engaged" | "missed" | "unknown"; 13 + 14 + export interface Layer1Result { 15 + uniqueFollows: number; 16 + totalFollows: number; 17 + reachRatio: number; // uniqueFollows / totalFollows, clamped to [0, 1] 18 + attentionInverse: number; // 1 - reachRatio, clamped to [0, 1] 19 + } 20 + 21 + export interface TalkScore { 22 + rkey: string; 23 + intensity: number; // 0–1; UI uses for glow + ordering 24 + state: TalkScoreState; 25 + layer1: Layer1Result; 26 + layer2?: { interestScore: number }; 27 + layer3?: { friendBoost: number; recommenders: string[] }; 28 + } 29 + 30 + export interface ScoringWeights { 31 + readonly surpriseSlider: number; // 0–1; controls Layer 2 contribution (high = serendipity) 32 + readonly friendsSlider: number; // 0–1; controls Layer 3 contribution (high = friends override) 33 + } 34 + 35 + // Frozen so the exported sentinel can't be mutated by accident — it's used 36 + // as a default parameter value in scoreTalk/rankTalks, so any mutation would 37 + // corrupt every subsequent call that takes the default. 38 + export const DEFAULT_WEIGHTS: Readonly<ScoringWeights> = Object.freeze({ 39 + surpriseSlider: 0.5, 40 + friendsSlider: 0.5, 41 + }); 42 + 43 + export interface ScoringInputs { 44 + talks: TalkEntry[]; 45 + mentions: TalkMentions | null; // null = crawl not yet completed 46 + followCount: number; // from CrawlResult.followCount 47 + weights?: ScoringWeights; 48 + active?: ActiveLayers; // omitted = layer 1 only (today's deployment) 49 + } 50 + 51 + // Re-export TalkMention for downstream consumers that import only from 52 + // scoring/types — saves them having to know about the crawl module. 53 + export type { TalkMention, TalkMentions };
+9
vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + import tsconfigPaths from "vite-tsconfig-paths"; 3 + 4 + export default defineConfig({ 5 + plugins: [tsconfigPaths()], 6 + test: { 7 + environment: "node", 8 + }, 9 + });