explore-proportional-allocation.test.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. /**
  2. * Score-proportional byte allocation for codegraph_explore (CG-12 / #1500).
  3. *
  4. * `allocateExploreBudget` decides, before anything renders, how many chars of
  5. * source each ranked file may spend. Its contract is what stops the explore
  6. * envelope from following FILE SIZE — which is the bug #1500 reported: a small
  7. * weakly-relevant file shipped whole while the file that actually answered the
  8. * question was clipped at a flat per-file cap.
  9. *
  10. * These pin the allocator's invariants directly. End-to-end behaviour on the two
  11. * regression fixtures lives in `explore-allocation-1500.test.ts`.
  12. */
  13. import { describe, it, expect } from 'vitest';
  14. import { allocateExploreBudget, getExploreOutputBudget } from '../src/mcp/tools';
  15. import type { ExploreAllocationCandidate } from '../src/mcp/tools';
  16. /** A candidate with sane defaults — tests override only what they're about. */
  17. const cand = (
  18. path: string,
  19. score: number,
  20. extra: Partial<ExploreAllocationCandidate> = {},
  21. ): ExploreAllocationCandidate => ({ path, score, worth: 1, spine: false, ...extra });
  22. const TIER_FILE_COUNTS = [10, 100, 300, 1000, 4000, 10000, 20000, 60000];
  23. describe('allocateExploreBudget — proportional split', () => {
  24. const budget = getExploreOutputBudget(1000); // 24,000 / 6,500 / 8 files
  25. it('gives the higher-scoring file the bigger share', () => {
  26. const { allowances } = allocateExploreBudget(
  27. [cand('a.ts', 40), cand('b.ts', 10)],
  28. budget,
  29. 8,
  30. );
  31. expect(allowances.get('a.ts')!).toBeGreaterThan(allowances.get('b.ts')!);
  32. });
  33. it('scales the split with the score RATIO, not just the ordering', () => {
  34. // The heart of the fix. Under the old flat `maxCharsPerFile` both files got
  35. // the same cap and the split fell out of whichever happened to be small
  36. // enough to ship whole; here a 4x score buys materially more than a 1.1x one.
  37. const wide = allocateExploreBudget([cand('a.ts', 40), cand('b.ts', 10)], budget, 8).allowances;
  38. const narrow = allocateExploreBudget([cand('a.ts', 22), cand('b.ts', 20)], budget, 8).allowances;
  39. expect(wide.get('a.ts')! / wide.get('b.ts')!)
  40. .toBeGreaterThan(narrow.get('a.ts')! / narrow.get('b.ts')!);
  41. });
  42. it('never reserves more than the envelope', () => {
  43. const { allowances, pool } = allocateExploreBudget(
  44. [cand('a.ts', 90), cand('b.ts', 40), cand('c.ts', 30), cand('d.ts', 12)],
  45. budget,
  46. 8,
  47. );
  48. const reserved = [...allowances.values()].reduce((s, n) => s + n, 0);
  49. expect(reserved).toBeLessThanOrEqual(pool);
  50. expect(pool).toBeLessThanOrEqual(budget.maxOutputChars);
  51. });
  52. it('caps any single file at the MAX_SHARE safety valve', () => {
  53. // The per-file cap is retired as the primary guard, but a lone dominant file
  54. // must still not be handed the entire response.
  55. const { allowances } = allocateExploreBudget([cand('god.ts', 500)], budget, 8);
  56. expect(allowances.get('god.ts')!).toBeLessThanOrEqual(Math.round(budget.maxOutputChars * 0.7));
  57. });
  58. it('lets the top file exceed the old flat per-file cap when it earns it', () => {
  59. // The regression this task exists to fix: `maxCharsPerFile` clipped the file
  60. // that scored 4x its peers at exactly the same 6,500 as the noise.
  61. const { allowances } = allocateExploreBudget(
  62. [cand('answer.ts', 60), cand('noise.ts', 12)],
  63. budget,
  64. 8,
  65. );
  66. expect(allowances.get('answer.ts')!).toBeGreaterThan(budget.maxCharsPerFile);
  67. });
  68. });
  69. describe('allocateExploreBudget — the relative cliff', () => {
  70. const budget = getExploreOutputBudget(1000);
  71. it('gives zero source to a file far below the top score', () => {
  72. const { allowances, cliffed } = allocateExploreBudget(
  73. [cand('answer.ts', 90), cand('incidental.ts', 3)],
  74. budget,
  75. 8,
  76. );
  77. expect(cliffed).toContain('incidental.ts');
  78. expect(allowances.has('incidental.ts')).toBe(false);
  79. });
  80. it('is RELATIVE — the same score survives against weaker company', () => {
  81. const strong = allocateExploreBudget([cand('a.ts', 90), cand('b.ts', 8)], budget, 8);
  82. const even = allocateExploreBudget([cand('a.ts', 12), cand('b.ts', 8)], budget, 8);
  83. expect(strong.cliffed).toContain('b.ts');
  84. expect(even.cliffed).not.toContain('b.ts');
  85. });
  86. it('never rises above the score-floor ceiling, however dominant the top file', () => {
  87. // A 500-scoring god-file would otherwise put the cliff at 75 and silence
  88. // every peer the score floor had just deliberately admitted.
  89. const { cliffed } = allocateExploreBudget(
  90. [cand('god.ts', 500), cand('peer.ts', 13), cand('peer2.ts', 11)],
  91. budget,
  92. 8,
  93. );
  94. expect(cliffed).toEqual([]);
  95. });
  96. it('doubles the penalty on bytes that are worth less (generated / low-value)', () => {
  97. // `worth` is `rankPenalty` applied a second time: generated CRUD can rank on
  98. // name collisions while its bytes stay boilerplate. Same score, different fate.
  99. const { cliffed } = allocateExploreBudget(
  100. [cand('answer.ts', 60), cand('gen.ts', 12, { worth: 0.3 }), cand('hand.ts', 12)],
  101. budget,
  102. 8,
  103. );
  104. expect(cliffed).toContain('gen.ts');
  105. expect(cliffed).not.toContain('hand.ts');
  106. });
  107. it('exempts flow-spine files from the cliff', () => {
  108. // Clipping the spine causes the Read fallback — it IS the answer to a flow
  109. // question — so a spine file is never zeroed on relative score alone.
  110. const { allowances, cliffed } = allocateExploreBudget(
  111. [cand('a.ts', 400), cand('spine.ts', 2, { spine: true })],
  112. budget,
  113. 8,
  114. );
  115. expect(cliffed).not.toContain('spine.ts');
  116. expect(allowances.get('spine.ts')!).toBeGreaterThan(0);
  117. });
  118. it('never cliffs every candidate — an empty response costs a round-trip', () => {
  119. const { allowances, cliffed } = allocateExploreBudget([cand('only.ts', 0.5)], budget, 8);
  120. expect(cliffed).toEqual([]);
  121. expect(allowances.get('only.ts')!).toBeGreaterThan(0);
  122. });
  123. it('hands a cliffed file\'s maxFiles slot to the next file down', () => {
  124. // The mechanism that got `BuildPayslip` into the #1500 response: cliffing is
  125. // not just "spend fewer bytes here", it frees the SLOT too.
  126. const { allowances } = allocateExploreBudget(
  127. [cand('a.ts', 90), cand('noise.ts', 2), cand('b.ts', 40)],
  128. budget,
  129. 2,
  130. );
  131. expect([...allowances.keys()].sort()).toEqual(['a.ts', 'b.ts']);
  132. });
  133. });
  134. describe('allocateExploreBudget — the floor keeps diffuse questions useful', () => {
  135. const budget = getExploreOutputBudget(1000);
  136. it('gives every admitted file a slice big enough for a method', () => {
  137. // A survey question must still return a spread. The earlier design cliffed a
  138. // starved file instead of flooring it, and that CASCADED: removing the
  139. // smallest raised everyone else so little that the next-smallest starved too,
  140. // eating six legitimately-ranked peers one at a time.
  141. const files = [cand('a.ts', 100), cand('b.ts', 90), ...Array.from({ length: 6 }, (_, i) => cand(`p${i}.ts`, 20))];
  142. const { allowances } = allocateExploreBudget(files, budget, 8);
  143. expect(allowances.size).toBe(8);
  144. for (const [, chars] of allowances) expect(chars).toBeGreaterThanOrEqual(700);
  145. });
  146. it('serves fewer files well rather than many badly when the envelope cannot afford them', () => {
  147. const tiny = getExploreOutputBudget(10); // 13,000-char envelope
  148. const files = Array.from({ length: 40 }, (_, i) => cand(`f${i}.ts`, 50 - i * 0.1));
  149. const { allowances, cliffed } = allocateExploreBudget(files, tiny, 40);
  150. expect(allowances.size).toBeLessThan(40);
  151. expect(cliffed.length).toBeGreaterThan(0);
  152. for (const [, chars] of allowances) expect(chars).toBeGreaterThanOrEqual(700);
  153. const reserved = [...allowances.values()].reduce((s, n) => s + n, 0);
  154. expect(reserved).toBeLessThanOrEqual(tiny.maxOutputChars);
  155. });
  156. it('returns an empty allocation for an empty candidate list', () => {
  157. const { allowances, cliffed } = allocateExploreBudget([], budget, 8);
  158. expect(allowances.size).toBe(0);
  159. expect(cliffed).toEqual([]);
  160. });
  161. it('does not crash or over-allocate when every score is zero', () => {
  162. const { allowances } = allocateExploreBudget([cand('a.ts', 0), cand('b.ts', 0)], budget, 8);
  163. const reserved = [...allowances.values()].reduce((s, n) => s + n, 0);
  164. expect(reserved).toBeLessThanOrEqual(budget.maxOutputChars);
  165. });
  166. });
  167. describe('allocateExploreBudget — tier invariant', () => {
  168. it('never gives a larger tier a smaller allowance than a smaller tier', () => {
  169. // The standing invariant from `getExploreOutputBudget`: a bigger project must
  170. // never be served LESS per file. It held for the flat cap by inspection; with
  171. // a proportional split it has to hold for the same candidate set across every
  172. // tier, which is what this walks.
  173. const files = [cand('a.ts', 60), cand('b.ts', 30), cand('c.ts', 15)];
  174. let previous: Map<string, number> | null = null;
  175. for (const fileCount of TIER_FILE_COUNTS) {
  176. const { allowances } = allocateExploreBudget(files, getExploreOutputBudget(fileCount), 8);
  177. if (previous) {
  178. for (const [path, chars] of allowances) {
  179. expect(chars, `${path} shrank at ${fileCount} files`).toBeGreaterThanOrEqual(previous.get(path)!);
  180. }
  181. }
  182. previous = allowances;
  183. }
  184. });
  185. it('cliffs the same files at every tier — the cliff is relative, not sized', () => {
  186. const files = [cand('a.ts', 90), cand('noise.ts', 2)];
  187. const cliffs = TIER_FILE_COUNTS.map((n) =>
  188. allocateExploreBudget(files, getExploreOutputBudget(n), 8).cliffed.join(','));
  189. expect(new Set(cliffs).size).toBe(1);
  190. });
  191. });