decision.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. import { describe, expect, it } from "vitest";
  2. import {
  3. decideCacheHitRateAnomalies,
  4. type CacheHitRateAlertMetric,
  5. type CacheHitRateAlertDecisionSettings,
  6. } from "@/lib/cache-hit-rate-alert/decision";
  7. function metric(
  8. input: Partial<CacheHitRateAlertMetric> & { providerId: number; model: string }
  9. ): CacheHitRateAlertMetric {
  10. return {
  11. providerId: input.providerId,
  12. model: input.model,
  13. totalRequests: input.totalRequests ?? 100,
  14. denominatorTokens: input.denominatorTokens ?? 10000,
  15. hitRateTokens: input.hitRateTokens ?? 0,
  16. eligibleRequests: input.eligibleRequests ?? 100,
  17. eligibleDenominatorTokens: input.eligibleDenominatorTokens ?? 10000,
  18. hitRateTokensEligible: input.hitRateTokensEligible ?? input.hitRateTokens ?? 0,
  19. };
  20. }
  21. const defaultSettings: CacheHitRateAlertDecisionSettings = {
  22. absMin: 0.05,
  23. dropRel: 0.3,
  24. dropAbs: 0.1,
  25. minEligibleRequests: 20,
  26. minEligibleTokens: 0,
  27. topN: 10,
  28. };
  29. describe("decideCacheHitRateAnomalies", () => {
  30. it("should return empty when topN is 0", () => {
  31. const anomalies = decideCacheHitRateAnomalies({
  32. current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.2 })],
  33. prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.5 })],
  34. today: [],
  35. historical: [],
  36. settings: { ...defaultSettings, absMin: 0.01, topN: 0 },
  37. });
  38. expect(anomalies).toHaveLength(0);
  39. });
  40. it("should prefer historical baseline over today/prev", () => {
  41. const anomalies = decideCacheHitRateAnomalies({
  42. current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.2 })],
  43. prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.4 })],
  44. today: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.35 })],
  45. historical: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.5 })],
  46. settings: defaultSettings,
  47. });
  48. expect(anomalies).toHaveLength(1);
  49. expect(anomalies[0].baselineSource).toBe("historical");
  50. });
  51. it("should fall back to today baseline when historical kind-sample is insufficient", () => {
  52. const anomalies = decideCacheHitRateAnomalies({
  53. current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.2 })],
  54. prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.4 })],
  55. today: [
  56. metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.5, eligibleRequests: 50 }),
  57. ],
  58. historical: [
  59. metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.9, eligibleRequests: 1 }),
  60. ],
  61. settings: defaultSettings,
  62. });
  63. expect(anomalies).toHaveLength(1);
  64. expect(anomalies[0].baselineSource).toBe("today");
  65. });
  66. it("should fall back to prev baseline when historical/today kind-samples are insufficient", () => {
  67. const anomalies = decideCacheHitRateAnomalies({
  68. current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.1 })],
  69. prev: [
  70. metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.6, eligibleRequests: 50 }),
  71. ],
  72. today: [
  73. metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.9, eligibleRequests: 1 }),
  74. ],
  75. historical: [
  76. metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.9, eligibleRequests: 1 }),
  77. ],
  78. settings: { ...defaultSettings, absMin: 0.01 },
  79. });
  80. expect(anomalies).toHaveLength(1);
  81. expect(anomalies[0].baselineSource).toBe("prev");
  82. });
  83. it("should treat baseline as insufficient when eligible tokens below minEligibleTokens", () => {
  84. const anomalies = decideCacheHitRateAnomalies({
  85. current: [
  86. metric({
  87. providerId: 1,
  88. model: "m",
  89. eligibleRequests: 50,
  90. eligibleDenominatorTokens: 2000,
  91. hitRateTokensEligible: 0.1,
  92. }),
  93. ],
  94. prev: [],
  95. today: [
  96. metric({
  97. providerId: 1,
  98. model: "m",
  99. eligibleRequests: 50,
  100. eligibleDenominatorTokens: 2000,
  101. hitRateTokensEligible: 0.6,
  102. }),
  103. ],
  104. historical: [
  105. metric({
  106. providerId: 1,
  107. model: "m",
  108. eligibleRequests: 50,
  109. eligibleDenominatorTokens: 10,
  110. hitRateTokensEligible: 0.9,
  111. }),
  112. ],
  113. settings: { ...defaultSettings, absMin: 0.01, minEligibleTokens: 1000 },
  114. });
  115. expect(anomalies).toHaveLength(1);
  116. expect(anomalies[0].baselineSource).toBe("today");
  117. });
  118. it("should fall back to overall when eligible sample is insufficient", () => {
  119. const anomalies = decideCacheHitRateAnomalies({
  120. current: [
  121. metric({
  122. providerId: 1,
  123. model: "m",
  124. totalRequests: 100,
  125. denominatorTokens: 10000,
  126. hitRateTokens: 0.1,
  127. eligibleRequests: 1,
  128. eligibleDenominatorTokens: 100,
  129. hitRateTokensEligible: 0,
  130. }),
  131. ],
  132. prev: [
  133. metric({
  134. providerId: 1,
  135. model: "m",
  136. totalRequests: 100,
  137. denominatorTokens: 10000,
  138. hitRateTokens: 0.5,
  139. eligibleRequests: 1,
  140. eligibleDenominatorTokens: 100,
  141. hitRateTokensEligible: 0.5,
  142. }),
  143. ],
  144. today: [],
  145. historical: [],
  146. settings: defaultSettings,
  147. });
  148. expect(anomalies).toHaveLength(1);
  149. expect(anomalies[0].current.kind).toBe("overall");
  150. expect(anomalies[0].baseline?.kind).toBe("overall");
  151. expect(anomalies[0].reasonCodes).toContain("eligible_insufficient");
  152. });
  153. it("should fall back to overall when eligible tokens are insufficient", () => {
  154. const anomalies = decideCacheHitRateAnomalies({
  155. current: [
  156. metric({
  157. providerId: 1,
  158. model: "m",
  159. totalRequests: 50,
  160. denominatorTokens: 2000,
  161. hitRateTokens: 0.1,
  162. eligibleRequests: 50,
  163. eligibleDenominatorTokens: 10,
  164. hitRateTokensEligible: 0.9,
  165. }),
  166. ],
  167. prev: [
  168. metric({
  169. providerId: 1,
  170. model: "m",
  171. totalRequests: 50,
  172. denominatorTokens: 2000,
  173. hitRateTokens: 0.6,
  174. eligibleRequests: 50,
  175. eligibleDenominatorTokens: 10,
  176. hitRateTokensEligible: 0.9,
  177. }),
  178. ],
  179. today: [],
  180. historical: [],
  181. settings: { ...defaultSettings, absMin: 0.01, minEligibleTokens: 1000 },
  182. });
  183. expect(anomalies).toHaveLength(1);
  184. expect(anomalies[0].current.kind).toBe("overall");
  185. expect(anomalies[0].baseline?.kind).toBe("overall");
  186. expect(anomalies[0].reasonCodes).toContain("eligible_insufficient");
  187. expect(anomalies[0].reasonCodes).toContain("use_overall");
  188. });
  189. it("should not compare eligible current against overall baseline", () => {
  190. const anomalies = decideCacheHitRateAnomalies({
  191. current: [
  192. metric({
  193. providerId: 1,
  194. model: "m",
  195. eligibleRequests: 100,
  196. eligibleDenominatorTokens: 10000,
  197. hitRateTokensEligible: 0.2,
  198. totalRequests: 100,
  199. denominatorTokens: 10000,
  200. hitRateTokens: 0.2,
  201. }),
  202. ],
  203. prev: [
  204. metric({
  205. providerId: 1,
  206. model: "m",
  207. // baseline eligible 不足,但 overall 足够
  208. eligibleRequests: 1,
  209. eligibleDenominatorTokens: 100,
  210. hitRateTokensEligible: 0.9,
  211. totalRequests: 100,
  212. denominatorTokens: 10000,
  213. hitRateTokens: 0.9,
  214. }),
  215. ],
  216. today: [],
  217. historical: [],
  218. settings: defaultSettings,
  219. });
  220. expect(anomalies).toHaveLength(0);
  221. });
  222. it("should filter invalid metrics in map inputs", () => {
  223. const current = new Map<string, CacheHitRateAlertMetric>([
  224. ["k1", metric({ providerId: 1, model: "", hitRateTokensEligible: 0 })],
  225. ["k2", metric({ providerId: 2, model: "m", hitRateTokensEligible: 0 })],
  226. ]);
  227. const prev = new Map<string, CacheHitRateAlertMetric>([
  228. ["k1", metric({ providerId: 1, model: "", hitRateTokensEligible: 0.2 })],
  229. ["k2", metric({ providerId: 2, model: "m", hitRateTokensEligible: 0.2 })],
  230. ]);
  231. const anomalies = decideCacheHitRateAnomalies({
  232. current,
  233. prev,
  234. today: new Map<string, CacheHitRateAlertMetric>(),
  235. historical: new Map<string, CacheHitRateAlertMetric>(),
  236. settings: { ...defaultSettings, dropAbs: 0.9, dropRel: 0.9 },
  237. });
  238. expect(anomalies).toHaveLength(1);
  239. expect(anomalies[0].providerId).toBe(2);
  240. expect(anomalies[0].model).toBe("m");
  241. });
  242. it("should return empty when eligible and overall samples are insufficient", () => {
  243. const anomalies = decideCacheHitRateAnomalies({
  244. current: [
  245. metric({
  246. providerId: 1,
  247. model: "m",
  248. totalRequests: 1,
  249. denominatorTokens: 10,
  250. hitRateTokens: 0,
  251. eligibleRequests: 1,
  252. eligibleDenominatorTokens: 10,
  253. hitRateTokensEligible: 0,
  254. }),
  255. ],
  256. prev: [],
  257. today: [],
  258. historical: [],
  259. settings: { ...defaultSettings, minEligibleRequests: 20, minEligibleTokens: 1000 },
  260. });
  261. expect(anomalies).toHaveLength(0);
  262. });
  263. it("should trigger drop_abs_rel when thresholds are met", () => {
  264. const anomalies = decideCacheHitRateAnomalies({
  265. current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.2 })],
  266. prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.5 })],
  267. today: [],
  268. historical: [],
  269. settings: { ...defaultSettings, absMin: 0.01 },
  270. });
  271. expect(anomalies).toHaveLength(1);
  272. expect(anomalies[0].reasonCodes).toContain("drop_abs_rel");
  273. expect(anomalies[0].dropAbs).toBeCloseTo(0.3, 10);
  274. });
  275. it("should not trigger drop_abs_rel when only dropAbs is met (AND)", () => {
  276. // baseline=0.5, current=0.375
  277. // dropAbs=0.125 >= 0.1(满足),dropRel=0.125/0.5=0.25 < 0.3(不满足)
  278. const anomalies = decideCacheHitRateAnomalies({
  279. current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.375 })],
  280. prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.5 })],
  281. today: [],
  282. historical: [],
  283. settings: { ...defaultSettings, absMin: 0.01, dropAbs: 0.1, dropRel: 0.3 },
  284. });
  285. expect(anomalies).toHaveLength(0);
  286. });
  287. it("should not trigger drop_abs_rel when only dropRel is met (AND)", () => {
  288. // baseline=0.25, current=0.15625
  289. // dropAbs=0.09375 < 0.1(不满足),dropRel=0.09375/0.25=0.375 >= 0.3(满足)
  290. const anomalies = decideCacheHitRateAnomalies({
  291. current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.15625 })],
  292. prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.25 })],
  293. today: [],
  294. historical: [],
  295. settings: { ...defaultSettings, absMin: 0.01, dropAbs: 0.1, dropRel: 0.3 },
  296. });
  297. expect(anomalies).toHaveLength(0);
  298. });
  299. it("should trigger abs_min when current is below absMin", () => {
  300. const shouldTrigger = decideCacheHitRateAnomalies({
  301. current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.03 })],
  302. prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.2 })],
  303. today: [],
  304. historical: [],
  305. settings: { ...defaultSettings, dropAbs: 0.9, dropRel: 0.9 },
  306. });
  307. expect(shouldTrigger).toHaveLength(1);
  308. expect(shouldTrigger[0].reasonCodes).toContain("abs_min");
  309. const shouldNotTrigger = decideCacheHitRateAnomalies({
  310. current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.06 })],
  311. prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.04 })],
  312. today: [],
  313. historical: [],
  314. settings: { ...defaultSettings, dropAbs: 0.9, dropRel: 0.9 },
  315. });
  316. expect(shouldNotTrigger).toHaveLength(0);
  317. });
  318. it("abs_min should not trigger when current equals absMin", () => {
  319. const anomalies = decideCacheHitRateAnomalies({
  320. current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.05 })],
  321. prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.5 })],
  322. today: [],
  323. historical: [],
  324. settings: { ...defaultSettings, absMin: 0.05, dropAbs: 0.9, dropRel: 0.9 },
  325. });
  326. expect(anomalies).toHaveLength(0);
  327. });
  328. it("abs_min 在缺失基线时也应触发", () => {
  329. const anomalies = decideCacheHitRateAnomalies({
  330. current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.01 })],
  331. prev: [],
  332. today: [],
  333. historical: [],
  334. settings: { ...defaultSettings, dropAbs: 0.9, dropRel: 0.9 },
  335. });
  336. expect(anomalies).toHaveLength(1);
  337. expect(anomalies[0].baselineSource).toBeNull();
  338. expect(anomalies[0].baseline).toBeNull();
  339. expect(anomalies[0].deltaAbs).toBeNull();
  340. expect(anomalies[0].deltaRel).toBeNull();
  341. expect(anomalies[0].dropAbs).toBeNull();
  342. expect(anomalies[0].reasonCodes).toContain("baseline_missing");
  343. expect(anomalies[0].reasonCodes).toContain("abs_min");
  344. });
  345. it("dropAbs 在 current 高于 baseline 且仅触发 abs_min 时应 clamp 为 0", () => {
  346. const anomalies = decideCacheHitRateAnomalies({
  347. current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.04 })],
  348. prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.01 })],
  349. today: [],
  350. historical: [],
  351. settings: { ...defaultSettings, absMin: 0.05, dropAbs: 0.1, dropRel: 0.3 },
  352. });
  353. expect(anomalies).toHaveLength(1);
  354. expect(anomalies[0].reasonCodes).toContain("abs_min");
  355. expect(anomalies[0].reasonCodes).not.toContain("drop_abs_rel");
  356. expect(anomalies[0].dropAbs).toBe(0);
  357. });
  358. it("should set deltaRel to null when baseline hit rate is 0", () => {
  359. const anomalies = decideCacheHitRateAnomalies({
  360. current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0 })],
  361. prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0 })],
  362. today: [],
  363. historical: [],
  364. settings: { ...defaultSettings, dropAbs: 0.9, dropRel: 0.9 },
  365. });
  366. expect(anomalies).toHaveLength(1);
  367. expect(anomalies[0].baseline?.hitRateTokens).toBe(0);
  368. expect(anomalies[0].deltaRel).toBeNull();
  369. });
  370. it("should trigger drop_abs_rel when thresholds are met exactly (>=)", () => {
  371. const anomalies = decideCacheHitRateAnomalies({
  372. current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.3 })],
  373. prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.4 })],
  374. today: [],
  375. historical: [],
  376. settings: { ...defaultSettings, absMin: 0.01, dropAbs: 0.1, dropRel: 0.25 },
  377. });
  378. expect(anomalies).toHaveLength(1);
  379. expect(anomalies[0].reasonCodes).toContain("drop_abs_rel");
  380. expect(anomalies[0].dropAbs).toBeCloseTo(0.1, 10);
  381. });
  382. it("should not add drop_abs_rel when only dropAbs is met (AND) even if abs_min triggers", () => {
  383. const anomalies = decideCacheHitRateAnomalies({
  384. current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.04 })],
  385. prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.06 })],
  386. today: [],
  387. historical: [],
  388. settings: { ...defaultSettings, absMin: 0.05, dropAbs: 0.01, dropRel: 0.5 },
  389. });
  390. expect(anomalies).toHaveLength(1);
  391. expect(anomalies[0].reasonCodes).toContain("abs_min");
  392. expect(anomalies[0].reasonCodes).not.toContain("drop_abs_rel");
  393. });
  394. it("should not add drop_abs_rel when only dropRel is met (AND) even if abs_min triggers", () => {
  395. const anomalies = decideCacheHitRateAnomalies({
  396. current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.02 })],
  397. prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.04 })],
  398. today: [],
  399. historical: [],
  400. settings: { ...defaultSettings, absMin: 0.05, dropAbs: 0.03, dropRel: 0.5 },
  401. });
  402. expect(anomalies).toHaveLength(1);
  403. expect(anomalies[0].reasonCodes).toContain("abs_min");
  404. expect(anomalies[0].reasonCodes).not.toContain("drop_abs_rel");
  405. });
  406. it("should sort by severity and respect topN", () => {
  407. const anomalies = decideCacheHitRateAnomalies({
  408. current: [
  409. metric({ providerId: 1, model: "a", hitRateTokensEligible: 0.1 }),
  410. metric({ providerId: 2, model: "b", hitRateTokensEligible: 0.25 }),
  411. ],
  412. prev: [
  413. metric({ providerId: 1, model: "a", hitRateTokensEligible: 0.6 }),
  414. metric({ providerId: 2, model: "b", hitRateTokensEligible: 0.5 }),
  415. ],
  416. today: [],
  417. historical: [],
  418. settings: { ...defaultSettings, absMin: 0.01, topN: 1 },
  419. });
  420. expect(anomalies).toHaveLength(1);
  421. expect(anomalies[0].providerId).toBe(1);
  422. expect(anomalies[0].model).toBe("a");
  423. });
  424. it("should break severity ties by providerId/model for deterministic ordering", () => {
  425. const anomalies = decideCacheHitRateAnomalies({
  426. current: [
  427. metric({ providerId: 2, model: "b", hitRateTokensEligible: 0.1 }),
  428. metric({ providerId: 1, model: "a", hitRateTokensEligible: 0.1 }),
  429. ],
  430. prev: [
  431. metric({ providerId: 2, model: "b", hitRateTokensEligible: 0.6 }),
  432. metric({ providerId: 1, model: "a", hitRateTokensEligible: 0.6 }),
  433. ],
  434. today: [],
  435. historical: [],
  436. settings: { ...defaultSettings, absMin: 0.01, topN: 2 },
  437. });
  438. expect(anomalies).toHaveLength(2);
  439. expect(anomalies[0].providerId).toBe(1);
  440. expect(anomalies[0].model).toBe("a");
  441. expect(anomalies[1].providerId).toBe(2);
  442. expect(anomalies[1].model).toBe("b");
  443. });
  444. });