@renseiai/agentfactory 0.8.8 → 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/dist/src/config/index.d.ts +1 -1
  2. package/dist/src/config/index.d.ts.map +1 -1
  3. package/dist/src/config/index.js +1 -1
  4. package/dist/src/config/repository-config.d.ts +23 -0
  5. package/dist/src/config/repository-config.d.ts.map +1 -1
  6. package/dist/src/config/repository-config.js +27 -0
  7. package/dist/src/config/repository-config.test.js +140 -1
  8. package/dist/src/governor/decision-engine.d.ts +3 -0
  9. package/dist/src/governor/decision-engine.d.ts.map +1 -1
  10. package/dist/src/governor/decision-engine.js +11 -0
  11. package/dist/src/governor/decision-engine.test.js +33 -0
  12. package/dist/src/governor/governor-types.d.ts +1 -1
  13. package/dist/src/governor/governor-types.d.ts.map +1 -1
  14. package/dist/src/governor/governor.d.ts +17 -1
  15. package/dist/src/governor/governor.d.ts.map +1 -1
  16. package/dist/src/governor/governor.js +112 -1
  17. package/dist/src/governor/governor.test.js +155 -0
  18. package/dist/src/index.d.ts +1 -0
  19. package/dist/src/index.d.ts.map +1 -1
  20. package/dist/src/index.js +1 -0
  21. package/dist/src/orchestrator/issue-tracker-client.d.ts +4 -0
  22. package/dist/src/orchestrator/issue-tracker-client.d.ts.map +1 -1
  23. package/dist/src/orchestrator/orchestrator.d.ts.map +1 -1
  24. package/dist/src/orchestrator/orchestrator.js +24 -0
  25. package/dist/src/orchestrator/parse-work-result.d.ts.map +1 -1
  26. package/dist/src/orchestrator/parse-work-result.js +6 -0
  27. package/dist/src/orchestrator/parse-work-result.test.js +19 -0
  28. package/dist/src/providers/index.d.ts +64 -1
  29. package/dist/src/providers/index.d.ts.map +1 -1
  30. package/dist/src/providers/index.js +132 -1
  31. package/dist/src/providers/index.test.js +340 -2
  32. package/dist/src/routing/index.d.ts +7 -0
  33. package/dist/src/routing/index.d.ts.map +1 -0
  34. package/dist/src/routing/index.js +6 -0
  35. package/dist/src/routing/observation-recorder.d.ts +19 -0
  36. package/dist/src/routing/observation-recorder.d.ts.map +1 -0
  37. package/dist/src/routing/observation-recorder.js +73 -0
  38. package/dist/src/routing/observation-recorder.test.d.ts +2 -0
  39. package/dist/src/routing/observation-recorder.test.d.ts.map +1 -0
  40. package/dist/src/routing/observation-recorder.test.js +322 -0
  41. package/dist/src/routing/observation-store.d.ts +40 -0
  42. package/dist/src/routing/observation-store.d.ts.map +1 -0
  43. package/dist/src/routing/observation-store.js +1 -0
  44. package/dist/src/routing/observation-store.test.d.ts +2 -0
  45. package/dist/src/routing/observation-store.test.d.ts.map +1 -0
  46. package/dist/src/routing/observation-store.test.js +138 -0
  47. package/dist/src/routing/posterior-store.d.ts +12 -0
  48. package/dist/src/routing/posterior-store.d.ts.map +1 -0
  49. package/dist/src/routing/posterior-store.js +13 -0
  50. package/dist/src/routing/posterior-store.test.d.ts +2 -0
  51. package/dist/src/routing/posterior-store.test.d.ts.map +1 -0
  52. package/dist/src/routing/posterior-store.test.js +37 -0
  53. package/dist/src/routing/reward.d.ts +16 -0
  54. package/dist/src/routing/reward.d.ts.map +1 -0
  55. package/dist/src/routing/reward.js +29 -0
  56. package/dist/src/routing/reward.test.d.ts +2 -0
  57. package/dist/src/routing/reward.test.d.ts.map +1 -0
  58. package/dist/src/routing/reward.test.js +210 -0
  59. package/dist/src/routing/routing-engine.d.ts +20 -0
  60. package/dist/src/routing/routing-engine.d.ts.map +1 -0
  61. package/dist/src/routing/routing-engine.js +113 -0
  62. package/dist/src/routing/routing-engine.test.d.ts +2 -0
  63. package/dist/src/routing/routing-engine.test.d.ts.map +1 -0
  64. package/dist/src/routing/routing-engine.test.js +310 -0
  65. package/dist/src/routing/types.d.ts +157 -0
  66. package/dist/src/routing/types.d.ts.map +1 -0
  67. package/dist/src/routing/types.js +68 -0
  68. package/dist/src/routing/types.test.d.ts +2 -0
  69. package/dist/src/routing/types.test.d.ts.map +1 -0
  70. package/dist/src/routing/types.test.js +184 -0
  71. package/dist/src/templates/types.d.ts +3 -0
  72. package/dist/src/templates/types.d.ts.map +1 -1
  73. package/dist/src/templates/types.js +2 -0
  74. package/dist/src/workflow/agent-cancellation.d.ts +37 -0
  75. package/dist/src/workflow/agent-cancellation.d.ts.map +1 -0
  76. package/dist/src/workflow/agent-cancellation.js +41 -0
  77. package/dist/src/workflow/agent-cancellation.test.d.ts +2 -0
  78. package/dist/src/workflow/agent-cancellation.test.d.ts.map +1 -0
  79. package/dist/src/workflow/agent-cancellation.test.js +86 -0
  80. package/dist/src/workflow/concurrency-semaphore.d.ts +21 -0
  81. package/dist/src/workflow/concurrency-semaphore.d.ts.map +1 -0
  82. package/dist/src/workflow/concurrency-semaphore.js +46 -0
  83. package/dist/src/workflow/concurrency-semaphore.test.d.ts +2 -0
  84. package/dist/src/workflow/concurrency-semaphore.test.d.ts.map +1 -0
  85. package/dist/src/workflow/concurrency-semaphore.test.js +183 -0
  86. package/dist/src/workflow/gate-state.d.ts +115 -0
  87. package/dist/src/workflow/gate-state.d.ts.map +1 -0
  88. package/dist/src/workflow/gate-state.js +185 -0
  89. package/dist/src/workflow/gate-state.test.d.ts +2 -0
  90. package/dist/src/workflow/gate-state.test.d.ts.map +1 -0
  91. package/dist/src/workflow/gate-state.test.js +251 -0
  92. package/dist/src/workflow/gates/gate-evaluator.d.ts +119 -0
  93. package/dist/src/workflow/gates/gate-evaluator.d.ts.map +1 -0
  94. package/dist/src/workflow/gates/gate-evaluator.js +243 -0
  95. package/dist/src/workflow/gates/gate-evaluator.test.d.ts +2 -0
  96. package/dist/src/workflow/gates/gate-evaluator.test.d.ts.map +1 -0
  97. package/dist/src/workflow/gates/gate-evaluator.test.js +240 -0
  98. package/dist/src/workflow/gates/signal-gate.d.ts +114 -0
  99. package/dist/src/workflow/gates/signal-gate.d.ts.map +1 -0
  100. package/dist/src/workflow/gates/signal-gate.js +216 -0
  101. package/dist/src/workflow/gates/signal-gate.test.d.ts +2 -0
  102. package/dist/src/workflow/gates/signal-gate.test.d.ts.map +1 -0
  103. package/dist/src/workflow/gates/signal-gate.test.js +199 -0
  104. package/dist/src/workflow/gates/timeout-engine.d.ts +96 -0
  105. package/dist/src/workflow/gates/timeout-engine.d.ts.map +1 -0
  106. package/dist/src/workflow/gates/timeout-engine.js +162 -0
  107. package/dist/src/workflow/gates/timeout-engine.test.d.ts +2 -0
  108. package/dist/src/workflow/gates/timeout-engine.test.d.ts.map +1 -0
  109. package/dist/src/workflow/gates/timeout-engine.test.js +186 -0
  110. package/dist/src/workflow/gates/timer-gate.d.ts +125 -0
  111. package/dist/src/workflow/gates/timer-gate.d.ts.map +1 -0
  112. package/dist/src/workflow/gates/timer-gate.js +381 -0
  113. package/dist/src/workflow/gates/timer-gate.test.d.ts +2 -0
  114. package/dist/src/workflow/gates/timer-gate.test.d.ts.map +1 -0
  115. package/dist/src/workflow/gates/timer-gate.test.js +211 -0
  116. package/dist/src/workflow/gates/webhook-gate.d.ts +132 -0
  117. package/dist/src/workflow/gates/webhook-gate.d.ts.map +1 -0
  118. package/dist/src/workflow/gates/webhook-gate.js +216 -0
  119. package/dist/src/workflow/gates/webhook-gate.test.d.ts +2 -0
  120. package/dist/src/workflow/gates/webhook-gate.test.d.ts.map +1 -0
  121. package/dist/src/workflow/gates/webhook-gate.test.js +182 -0
  122. package/dist/src/workflow/index.d.ts +23 -2
  123. package/dist/src/workflow/index.d.ts.map +1 -1
  124. package/dist/src/workflow/index.js +15 -1
  125. package/dist/src/workflow/parallelism-executor.d.ts +25 -0
  126. package/dist/src/workflow/parallelism-executor.d.ts.map +1 -0
  127. package/dist/src/workflow/parallelism-executor.js +53 -0
  128. package/dist/src/workflow/parallelism-executor.test.d.ts +2 -0
  129. package/dist/src/workflow/parallelism-executor.test.d.ts.map +1 -0
  130. package/dist/src/workflow/parallelism-executor.test.js +191 -0
  131. package/dist/src/workflow/parallelism-types.d.ts +80 -0
  132. package/dist/src/workflow/parallelism-types.d.ts.map +1 -0
  133. package/dist/src/workflow/parallelism-types.js +8 -0
  134. package/dist/src/workflow/phase-context-injector.d.ts +29 -0
  135. package/dist/src/workflow/phase-context-injector.d.ts.map +1 -0
  136. package/dist/src/workflow/phase-context-injector.js +43 -0
  137. package/dist/src/workflow/phase-context-injector.test.d.ts +2 -0
  138. package/dist/src/workflow/phase-context-injector.test.d.ts.map +1 -0
  139. package/dist/src/workflow/phase-context-injector.test.js +123 -0
  140. package/dist/src/workflow/phase-output-collector.d.ts +39 -0
  141. package/dist/src/workflow/phase-output-collector.d.ts.map +1 -0
  142. package/dist/src/workflow/phase-output-collector.js +141 -0
  143. package/dist/src/workflow/phase-output-collector.test.d.ts +2 -0
  144. package/dist/src/workflow/phase-output-collector.test.d.ts.map +1 -0
  145. package/dist/src/workflow/phase-output-collector.test.js +179 -0
  146. package/dist/src/workflow/strategies/fan-in-strategy.d.ts +21 -0
  147. package/dist/src/workflow/strategies/fan-in-strategy.d.ts.map +1 -0
  148. package/dist/src/workflow/strategies/fan-in-strategy.js +92 -0
  149. package/dist/src/workflow/strategies/fan-in-strategy.test.d.ts +2 -0
  150. package/dist/src/workflow/strategies/fan-in-strategy.test.d.ts.map +1 -0
  151. package/dist/src/workflow/strategies/fan-in-strategy.test.js +182 -0
  152. package/dist/src/workflow/strategies/fan-out-strategy.d.ts +16 -0
  153. package/dist/src/workflow/strategies/fan-out-strategy.d.ts.map +1 -0
  154. package/dist/src/workflow/strategies/fan-out-strategy.js +47 -0
  155. package/dist/src/workflow/strategies/fan-out-strategy.test.d.ts +2 -0
  156. package/dist/src/workflow/strategies/fan-out-strategy.test.d.ts.map +1 -0
  157. package/dist/src/workflow/strategies/fan-out-strategy.test.js +97 -0
  158. package/dist/src/workflow/strategies/index.d.ts +4 -0
  159. package/dist/src/workflow/strategies/index.d.ts.map +1 -0
  160. package/dist/src/workflow/strategies/index.js +3 -0
  161. package/dist/src/workflow/strategies/race-strategy.d.ts +19 -0
  162. package/dist/src/workflow/strategies/race-strategy.d.ts.map +1 -0
  163. package/dist/src/workflow/strategies/race-strategy.js +92 -0
  164. package/dist/src/workflow/strategies/race-strategy.test.d.ts +2 -0
  165. package/dist/src/workflow/strategies/race-strategy.test.d.ts.map +1 -0
  166. package/dist/src/workflow/strategies/race-strategy.test.js +318 -0
  167. package/dist/src/workflow/transition-engine.d.ts.map +1 -1
  168. package/dist/src/workflow/transition-engine.js +12 -0
  169. package/dist/src/workflow/transition-engine.test.js +92 -0
  170. package/dist/src/workflow/workflow-registry.d.ts +5 -1
  171. package/dist/src/workflow/workflow-registry.d.ts.map +1 -1
  172. package/dist/src/workflow/workflow-registry.js +8 -0
  173. package/dist/src/workflow/workflow-registry.test.js +54 -0
  174. package/dist/src/workflow/workflow-types.d.ts +151 -6
  175. package/dist/src/workflow/workflow-types.d.ts.map +1 -1
  176. package/dist/src/workflow/workflow-types.js +71 -1
  177. package/dist/src/workflow/workflow-types.test.js +293 -2
  178. package/package.json +2 -2
@@ -0,0 +1,210 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { computeReward, extractRewardFromProcess, MAX_EXPECTED_COST } from './reward.js';
3
+ function makeReward(overrides = {}) {
4
+ return {
5
+ taskCompleted: false,
6
+ prCreated: false,
7
+ qaResult: 'unknown',
8
+ totalCostUsd: 0,
9
+ inputTokens: 0,
10
+ outputTokens: 0,
11
+ wallClockTimeMs: 0,
12
+ requiredRefinements: 0,
13
+ humanEscalations: 0,
14
+ ...overrides,
15
+ };
16
+ }
17
+ function makeProcess(overrides = {}) {
18
+ return {
19
+ issueId: 'issue-1',
20
+ identifier: 'SUP-100',
21
+ pid: 1234,
22
+ status: 'running',
23
+ startedAt: new Date('2024-01-01T00:00:00Z'),
24
+ lastActivityAt: new Date('2024-01-01T00:01:00Z'),
25
+ ...overrides,
26
+ };
27
+ }
28
+ describe('computeReward', () => {
29
+ it('returns ~1.0 for full success with zero cost', () => {
30
+ const reward = computeReward(makeReward({
31
+ taskCompleted: true,
32
+ prCreated: true,
33
+ qaResult: 'passed',
34
+ totalCostUsd: 0,
35
+ }));
36
+ // 0.5 + 0.2 + 0.3 - 0 = 1.0
37
+ expect(reward).toBe(1.0);
38
+ });
39
+ it('returns 0.0 for full failure', () => {
40
+ const reward = computeReward(makeReward());
41
+ expect(reward).toBe(0.0);
42
+ });
43
+ it('returns 0.5 for taskCompleted only', () => {
44
+ const reward = computeReward(makeReward({ taskCompleted: true }));
45
+ expect(reward).toBe(0.5);
46
+ });
47
+ it('returns 0.2 for prCreated only', () => {
48
+ const reward = computeReward(makeReward({ prCreated: true }));
49
+ expect(reward).toBe(0.2);
50
+ });
51
+ it('returns 0.3 for qaResult passed only', () => {
52
+ const reward = computeReward(makeReward({ qaResult: 'passed' }));
53
+ expect(reward).toBe(0.3);
54
+ });
55
+ it('returns 0.7 for taskCompleted + prCreated', () => {
56
+ const reward = computeReward(makeReward({ taskCompleted: true, prCreated: true }));
57
+ expect(reward).toBe(0.7);
58
+ });
59
+ it('applies full cost penalty of 0.1 when cost equals MAX_EXPECTED_COST', () => {
60
+ const reward = computeReward(makeReward({
61
+ taskCompleted: true,
62
+ prCreated: true,
63
+ qaResult: 'passed',
64
+ totalCostUsd: MAX_EXPECTED_COST,
65
+ }));
66
+ // 0.5 + 0.2 + 0.3 - 0.1 * 1.0 = 0.9
67
+ expect(reward).toBeCloseTo(0.9);
68
+ });
69
+ it('caps cost penalty when cost exceeds MAX_EXPECTED_COST', () => {
70
+ const reward = computeReward(makeReward({
71
+ taskCompleted: true,
72
+ prCreated: true,
73
+ qaResult: 'passed',
74
+ totalCostUsd: 100,
75
+ }));
76
+ // Cost penalty capped at 0.1, so 1.0 - 0.1 = 0.9
77
+ expect(reward).toBeCloseTo(0.9);
78
+ });
79
+ it('applies proportional cost penalty', () => {
80
+ const reward = computeReward(makeReward({
81
+ taskCompleted: true,
82
+ totalCostUsd: MAX_EXPECTED_COST / 2,
83
+ }));
84
+ // 0.5 - 0.1 * 0.5 = 0.45
85
+ expect(reward).toBeCloseTo(0.45);
86
+ });
87
+ it('clamps reward to minimum of 0', () => {
88
+ // Only cost, no success signals: 0 - 0.1 = -0.1 => clamped to 0
89
+ const reward = computeReward(makeReward({ totalCostUsd: MAX_EXPECTED_COST }));
90
+ expect(reward).toBe(0);
91
+ });
92
+ it('clamps reward to maximum of 1', () => {
93
+ // Full success with zero cost should be exactly 1.0 (0.5 + 0.2 + 0.3)
94
+ const reward = computeReward(makeReward({
95
+ taskCompleted: true,
96
+ prCreated: true,
97
+ qaResult: 'passed',
98
+ totalCostUsd: 0,
99
+ }));
100
+ expect(reward).toBeLessThanOrEqual(1);
101
+ expect(reward).toBeGreaterThanOrEqual(0);
102
+ });
103
+ it('does not add reward for qaResult failed', () => {
104
+ const reward = computeReward(makeReward({ qaResult: 'failed' }));
105
+ expect(reward).toBe(0);
106
+ });
107
+ it('does not add reward for qaResult unknown', () => {
108
+ const reward = computeReward(makeReward({ qaResult: 'unknown' }));
109
+ expect(reward).toBe(0);
110
+ });
111
+ });
112
+ describe('extractRewardFromProcess', () => {
113
+ it('maps completed process with PR and passed QA', () => {
114
+ const process = makeProcess({
115
+ status: 'completed',
116
+ pullRequestUrl: 'https://github.com/org/repo/pull/1',
117
+ workResult: 'passed',
118
+ totalCostUsd: 1.5,
119
+ inputTokens: 10000,
120
+ outputTokens: 5000,
121
+ startedAt: new Date('2024-01-01T00:00:00Z'),
122
+ completedAt: new Date('2024-01-01T00:05:00Z'),
123
+ });
124
+ const result = extractRewardFromProcess(process);
125
+ expect(result.taskCompleted).toBe(true);
126
+ expect(result.prCreated).toBe(true);
127
+ expect(result.qaResult).toBe('passed');
128
+ expect(result.totalCostUsd).toBe(1.5);
129
+ expect(result.inputTokens).toBe(10000);
130
+ expect(result.outputTokens).toBe(5000);
131
+ expect(result.wallClockTimeMs).toBe(300000); // 5 minutes
132
+ expect(result.requiredRefinements).toBe(0);
133
+ expect(result.humanEscalations).toBe(0);
134
+ });
135
+ it('maps failed process', () => {
136
+ const process = makeProcess({
137
+ status: 'failed',
138
+ });
139
+ const result = extractRewardFromProcess(process);
140
+ expect(result.taskCompleted).toBe(false);
141
+ expect(result.prCreated).toBe(false);
142
+ expect(result.qaResult).toBe('unknown');
143
+ });
144
+ it('maps running process as not completed', () => {
145
+ const result = extractRewardFromProcess(makeProcess({ status: 'running' }));
146
+ expect(result.taskCompleted).toBe(false);
147
+ });
148
+ it('maps incomplete process as not completed', () => {
149
+ const result = extractRewardFromProcess(makeProcess({ status: 'incomplete' }));
150
+ expect(result.taskCompleted).toBe(false);
151
+ });
152
+ it('maps stopped process as not completed', () => {
153
+ const result = extractRewardFromProcess(makeProcess({ status: 'stopped' }));
154
+ expect(result.taskCompleted).toBe(false);
155
+ });
156
+ it('handles undefined pullRequestUrl', () => {
157
+ const result = extractRewardFromProcess(makeProcess({ pullRequestUrl: undefined }));
158
+ expect(result.prCreated).toBe(false);
159
+ });
160
+ it('handles undefined workResult', () => {
161
+ const result = extractRewardFromProcess(makeProcess({ workResult: undefined }));
162
+ expect(result.qaResult).toBe('unknown');
163
+ });
164
+ it('handles undefined totalCostUsd', () => {
165
+ const result = extractRewardFromProcess(makeProcess({ totalCostUsd: undefined }));
166
+ expect(result.totalCostUsd).toBe(0);
167
+ });
168
+ it('handles undefined inputTokens', () => {
169
+ const result = extractRewardFromProcess(makeProcess({ inputTokens: undefined }));
170
+ expect(result.inputTokens).toBe(0);
171
+ });
172
+ it('handles undefined outputTokens', () => {
173
+ const result = extractRewardFromProcess(makeProcess({ outputTokens: undefined }));
174
+ expect(result.outputTokens).toBe(0);
175
+ });
176
+ it('handles undefined completedAt (wallClockTimeMs is 0)', () => {
177
+ const result = extractRewardFromProcess(makeProcess({
178
+ startedAt: new Date('2024-01-01T00:00:00Z'),
179
+ completedAt: undefined,
180
+ }));
181
+ expect(result.wallClockTimeMs).toBe(0);
182
+ });
183
+ it('computes correct wallClockTimeMs from startedAt and completedAt', () => {
184
+ const result = extractRewardFromProcess(makeProcess({
185
+ startedAt: new Date('2024-01-01T00:00:00Z'),
186
+ completedAt: new Date('2024-01-01T01:00:00Z'),
187
+ }));
188
+ expect(result.wallClockTimeMs).toBe(3600000); // 1 hour
189
+ });
190
+ it('end-to-end: extractRewardFromProcess feeds into computeReward correctly', () => {
191
+ const process = makeProcess({
192
+ status: 'completed',
193
+ pullRequestUrl: 'https://github.com/org/repo/pull/1',
194
+ workResult: 'passed',
195
+ totalCostUsd: 0,
196
+ });
197
+ const routingReward = extractRewardFromProcess(process);
198
+ const reward = computeReward(routingReward);
199
+ expect(reward).toBe(1.0);
200
+ });
201
+ it('end-to-end: failed process with high cost yields 0', () => {
202
+ const process = makeProcess({
203
+ status: 'failed',
204
+ totalCostUsd: 10,
205
+ });
206
+ const routingReward = extractRewardFromProcess(process);
207
+ const reward = computeReward(routingReward);
208
+ expect(reward).toBe(0);
209
+ });
210
+ });
@@ -0,0 +1,20 @@
1
+ import type { AgentProviderName } from '../providers/types.js';
2
+ import type { AgentWorkType } from '../orchestrator/work-types.js';
3
+ import type { RoutingDecision, RoutingConfig } from './types.js';
4
+ import type { PosteriorStore } from './posterior-store.js';
5
+ export declare const DEFAULT_ROUTING_CONFIG: RoutingConfig;
6
+ export declare function betaMean(alpha: number, beta: number): number;
7
+ export declare function betaVariance(alpha: number, beta: number): number;
8
+ /**
9
+ * Sample from a Beta distribution using the ratio of Gamma variates.
10
+ * If X ~ Gamma(alpha), Y ~ Gamma(beta), then X/(X+Y) ~ Beta(alpha, beta).
11
+ *
12
+ * If a random generator is provided, use it for deterministic testing.
13
+ */
14
+ export declare function betaSample(alpha: number, beta: number, rng?: () => number): number;
15
+ export interface SelectProviderOptions {
16
+ forcedExploration?: boolean;
17
+ rng?: () => number;
18
+ }
19
+ export declare function selectProvider(posteriorStore: PosteriorStore, workType: AgentWorkType, availableProviders: AgentProviderName[], config?: RoutingConfig, opts?: SelectProviderOptions): Promise<RoutingDecision>;
20
+ //# sourceMappingURL=routing-engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routing-engine.d.ts","sourceRoot":"","sources":["../../../src/routing/routing-engine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAA;AAC9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAA;AAClE,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAChE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAE1D,eAAO,MAAM,sBAAsB,EAAE,aAOpC,CAAA;AAGD,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE5D;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAGhE;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,MAAM,GAAG,MAAM,CAQlF;AAkCD,MAAM,WAAW,qBAAqB;IACpC,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED,wBAAsB,cAAc,CAClC,cAAc,EAAE,cAAc,EAC9B,QAAQ,EAAE,aAAa,EACvB,kBAAkB,EAAE,iBAAiB,EAAE,EACvC,MAAM,GAAE,aAAsC,EAC9C,IAAI,CAAC,EAAE,qBAAqB,GAC3B,OAAO,CAAC,eAAe,CAAC,CAgE1B"}
@@ -0,0 +1,113 @@
1
+ export const DEFAULT_ROUTING_CONFIG = {
2
+ enabled: false,
3
+ explorationRate: 0.1,
4
+ windowSize: 100,
5
+ discountFactor: 0.99,
6
+ minObservationsForExploit: 5,
7
+ changeDetectionThreshold: 0.2,
8
+ };
9
+ // Beta distribution utilities - pure math, no external dependencies
10
+ export function betaMean(alpha, beta) {
11
+ return alpha / (alpha + beta);
12
+ }
13
+ export function betaVariance(alpha, beta) {
14
+ const ab = alpha + beta;
15
+ return (alpha * beta) / (ab * ab * (ab + 1));
16
+ }
17
+ /**
18
+ * Sample from a Beta distribution using the ratio of Gamma variates.
19
+ * If X ~ Gamma(alpha), Y ~ Gamma(beta), then X/(X+Y) ~ Beta(alpha, beta).
20
+ *
21
+ * If a random generator is provided, use it for deterministic testing.
22
+ */
23
+ export function betaSample(alpha, beta, rng) {
24
+ const random = rng ?? Math.random;
25
+ const x = gammaSample(alpha, random);
26
+ const y = gammaSample(beta, random);
27
+ if (x + y === 0)
28
+ return 0.5; // Edge case
29
+ return x / (x + y);
30
+ }
31
+ /**
32
+ * Sample from a Gamma distribution using Marsaglia and Tsang's method.
33
+ */
34
+ function gammaSample(shape, random) {
35
+ if (shape < 1) {
36
+ // For shape < 1, use the trick: Gamma(shape) = Gamma(shape+1) * U^(1/shape)
37
+ return gammaSample(shape + 1, random) * Math.pow(random(), 1 / shape);
38
+ }
39
+ const d = shape - 1 / 3;
40
+ const c = 1 / Math.sqrt(9 * d);
41
+ while (true) {
42
+ let x;
43
+ let v;
44
+ do {
45
+ // Generate standard normal using Box-Muller
46
+ const u1 = random();
47
+ const u2 = random();
48
+ x = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
49
+ v = 1 + c * x;
50
+ } while (v <= 0);
51
+ v = v * v * v;
52
+ const u = random();
53
+ if (u < 1 - 0.0331 * (x * x) * (x * x))
54
+ return d * v;
55
+ if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v)))
56
+ return d * v;
57
+ }
58
+ }
59
+ export async function selectProvider(posteriorStore, workType, availableProviders, config = DEFAULT_ROUTING_CONFIG, opts) {
60
+ const posteriors = await Promise.all(availableProviders.map(p => posteriorStore.getPosterior(p, workType)));
61
+ const rng = opts?.rng;
62
+ // Forced exploration: random selection
63
+ if (opts?.forcedExploration || (rng ?? Math.random)() < config.explorationRate) {
64
+ const idx = Math.floor((rng ?? Math.random)() * availableProviders.length);
65
+ const chosen = posteriors[idx];
66
+ const mean = betaMean(chosen.alpha, chosen.beta);
67
+ const confidence = 1 - Math.sqrt(betaVariance(chosen.alpha, chosen.beta));
68
+ const others = posteriors
69
+ .filter((_, i) => i !== idx)
70
+ .map(p => ({
71
+ provider: p.provider,
72
+ expectedReward: betaMean(p.alpha, p.beta),
73
+ confidence: 1 - Math.sqrt(betaVariance(p.alpha, p.beta)),
74
+ }));
75
+ return {
76
+ selectedProvider: chosen.provider,
77
+ confidence,
78
+ expectedReward: mean,
79
+ explorationReason: 'forced',
80
+ source: 'mab-routing',
81
+ alternatives: others,
82
+ };
83
+ }
84
+ // Thompson Sampling: draw from each posterior's Beta distribution
85
+ const samples = posteriors.map(post => ({
86
+ provider: post.provider,
87
+ sample: betaSample(post.alpha, post.beta, rng),
88
+ confidence: 1 - Math.sqrt(betaVariance(post.alpha, post.beta)),
89
+ expectedReward: betaMean(post.alpha, post.beta),
90
+ }));
91
+ // Select highest sample
92
+ samples.sort((a, b) => b.sample - a.sample);
93
+ const selected = samples[0];
94
+ // Only trust posteriors with enough observations
95
+ const selectedPosterior = posteriors.find(p => p.provider === selected.provider);
96
+ const isTrusted = selectedPosterior.totalObservations >= config.minObservationsForExploit;
97
+ return {
98
+ selectedProvider: selected.provider,
99
+ confidence: selected.confidence,
100
+ expectedReward: selected.expectedReward,
101
+ explorationReason: !isTrusted
102
+ ? 'uncertainty'
103
+ : selected.confidence < 0.3
104
+ ? 'uncertainty'
105
+ : undefined,
106
+ source: 'mab-routing',
107
+ alternatives: samples.slice(1).map(s => ({
108
+ provider: s.provider,
109
+ expectedReward: s.expectedReward,
110
+ confidence: s.confidence,
111
+ })),
112
+ };
113
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=routing-engine.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routing-engine.test.d.ts","sourceRoot":"","sources":["../../../src/routing/routing-engine.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,310 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { betaMean, betaVariance, betaSample, selectProvider, DEFAULT_ROUTING_CONFIG, } from './routing-engine.js';
3
+ function makePosterior(overrides = {}) {
4
+ return {
5
+ provider: 'claude',
6
+ workType: 'development',
7
+ alpha: 1,
8
+ beta: 1,
9
+ totalObservations: 0,
10
+ avgReward: 0,
11
+ avgCostUsd: 0,
12
+ lastUpdated: Date.now(),
13
+ ...overrides,
14
+ };
15
+ }
16
+ function makeMockStore(posteriors) {
17
+ return {
18
+ getPosterior: vi.fn(async (provider, workType) => {
19
+ const key = `${provider}:${workType}`;
20
+ return posteriors[key] ?? makePosterior({ provider, workType });
21
+ }),
22
+ updatePosterior: vi.fn(async () => makePosterior()),
23
+ getAllPosteriors: vi.fn(async () => Object.values(posteriors)),
24
+ resetPosterior: vi.fn(async () => { }),
25
+ };
26
+ }
27
+ /**
28
+ * Create a deterministic RNG from a seed using a simple LCG.
29
+ * This allows reproducible tests.
30
+ */
31
+ function seededRng(seed) {
32
+ let state = seed;
33
+ return () => {
34
+ // LCG parameters (Numerical Recipes)
35
+ state = (state * 1664525 + 1013904223) & 0xffffffff;
36
+ return (state >>> 0) / 0x100000000;
37
+ };
38
+ }
39
+ describe('betaMean', () => {
40
+ it('returns correct mean for Beta(1,1) = 0.5', () => {
41
+ expect(betaMean(1, 1)).toBe(0.5);
42
+ });
43
+ it('returns correct mean for Beta(2,1) ~ 0.667', () => {
44
+ expect(betaMean(2, 1)).toBeCloseTo(2 / 3, 5);
45
+ });
46
+ it('returns correct mean for Beta(5,5) = 0.5', () => {
47
+ expect(betaMean(5, 5)).toBe(0.5);
48
+ });
49
+ it('returns correct mean for Beta(10,2) ~ 0.833', () => {
50
+ expect(betaMean(10, 2)).toBeCloseTo(10 / 12, 5);
51
+ });
52
+ });
53
+ describe('betaVariance', () => {
54
+ it('returns correct variance for Beta(1,1) = 1/12', () => {
55
+ expect(betaVariance(1, 1)).toBeCloseTo(1 / 12, 10);
56
+ });
57
+ it('returns correct variance for Beta(2,2) = 1/20', () => {
58
+ // Var = 2*2 / (4*4*5) = 4/80 = 1/20
59
+ expect(betaVariance(2, 2)).toBeCloseTo(1 / 20, 10);
60
+ });
61
+ it('returns smaller variance with more observations (higher alpha+beta)', () => {
62
+ const varLow = betaVariance(2, 2);
63
+ const varHigh = betaVariance(20, 20);
64
+ expect(varHigh).toBeLessThan(varLow);
65
+ });
66
+ });
67
+ describe('betaSample', () => {
68
+ it('returns values between 0 and 1', () => {
69
+ const rng = seededRng(42);
70
+ for (let i = 0; i < 100; i++) {
71
+ const sample = betaSample(2, 3, rng);
72
+ expect(sample).toBeGreaterThanOrEqual(0);
73
+ expect(sample).toBeLessThanOrEqual(1);
74
+ }
75
+ });
76
+ it('with deterministic RNG produces consistent results', () => {
77
+ const rng1 = seededRng(12345);
78
+ const rng2 = seededRng(12345);
79
+ const sample1 = betaSample(3, 5, rng1);
80
+ const sample2 = betaSample(3, 5, rng2);
81
+ expect(sample1).toBe(sample2);
82
+ });
83
+ it('mean approximates betaMean over many samples (statistical test)', () => {
84
+ const alpha = 4;
85
+ const beta = 8;
86
+ const rng = seededRng(999);
87
+ const n = 5000;
88
+ let sum = 0;
89
+ for (let i = 0; i < n; i++) {
90
+ sum += betaSample(alpha, beta, rng);
91
+ }
92
+ const empiricalMean = sum / n;
93
+ const theoreticalMean = betaMean(alpha, beta);
94
+ // Allow 5% tolerance for statistical convergence
95
+ expect(empiricalMean).toBeCloseTo(theoreticalMean, 1);
96
+ });
97
+ it('works with alpha < 1 and beta < 1', () => {
98
+ const rng = seededRng(77);
99
+ for (let i = 0; i < 50; i++) {
100
+ const sample = betaSample(0.5, 0.5, rng);
101
+ expect(sample).toBeGreaterThanOrEqual(0);
102
+ expect(sample).toBeLessThanOrEqual(1);
103
+ }
104
+ });
105
+ it('works with large alpha and beta', () => {
106
+ const rng = seededRng(88);
107
+ for (let i = 0; i < 50; i++) {
108
+ const sample = betaSample(100, 100, rng);
109
+ expect(sample).toBeGreaterThanOrEqual(0);
110
+ expect(sample).toBeLessThanOrEqual(1);
111
+ }
112
+ });
113
+ });
114
+ describe('selectProvider', () => {
115
+ const workType = 'development';
116
+ const providers = ['claude', 'codex', 'amp'];
117
+ it('returns a valid RoutingDecision', async () => {
118
+ const store = makeMockStore({});
119
+ const rng = seededRng(42);
120
+ const decision = await selectProvider(store, workType, providers, DEFAULT_ROUTING_CONFIG, {
121
+ rng,
122
+ });
123
+ expect(decision.selectedProvider).toBeDefined();
124
+ expect(providers).toContain(decision.selectedProvider);
125
+ expect(decision.confidence).toBeGreaterThanOrEqual(0);
126
+ expect(decision.confidence).toBeLessThanOrEqual(1);
127
+ expect(decision.expectedReward).toBeGreaterThanOrEqual(0);
128
+ expect(decision.expectedReward).toBeLessThanOrEqual(1);
129
+ expect(decision.source).toBe('mab-routing');
130
+ expect(Array.isArray(decision.alternatives)).toBe(true);
131
+ });
132
+ it('with forced exploration selects randomly and marks explorationReason as forced', async () => {
133
+ const store = makeMockStore({
134
+ 'claude:development': makePosterior({
135
+ provider: 'claude',
136
+ workType: 'development',
137
+ alpha: 100,
138
+ beta: 1,
139
+ totalObservations: 100,
140
+ }),
141
+ 'codex:development': makePosterior({
142
+ provider: 'codex',
143
+ workType: 'development',
144
+ alpha: 1,
145
+ beta: 100,
146
+ totalObservations: 100,
147
+ }),
148
+ });
149
+ const rng = seededRng(42);
150
+ const decision = await selectProvider(store, workType, ['claude', 'codex'], DEFAULT_ROUTING_CONFIG, {
151
+ forcedExploration: true,
152
+ rng,
153
+ });
154
+ expect(decision.explorationReason).toBe('forced');
155
+ expect(['claude', 'codex']).toContain(decision.selectedProvider);
156
+ });
157
+ it('picks provider with highest sampled reward (deterministic RNG)', async () => {
158
+ // Give claude a very strong posterior (alpha=50, beta=2) -- mean ~0.96
159
+ // Give codex a very weak posterior (alpha=2, beta=50) -- mean ~0.04
160
+ const store = makeMockStore({
161
+ 'claude:development': makePosterior({
162
+ provider: 'claude',
163
+ workType: 'development',
164
+ alpha: 50,
165
+ beta: 2,
166
+ totalObservations: 50,
167
+ }),
168
+ 'codex:development': makePosterior({
169
+ provider: 'codex',
170
+ workType: 'development',
171
+ alpha: 2,
172
+ beta: 50,
173
+ totalObservations: 50,
174
+ }),
175
+ });
176
+ // Use a config with 0 exploration rate so TS runs
177
+ const config = { ...DEFAULT_ROUTING_CONFIG, explorationRate: 0 };
178
+ // Run multiple times -- claude should almost always be picked
179
+ let claudeCount = 0;
180
+ for (let i = 0; i < 20; i++) {
181
+ const rng = seededRng(i * 137);
182
+ const decision = await selectProvider(store, workType, ['claude', 'codex'], config, { rng });
183
+ if (decision.selectedProvider === 'claude')
184
+ claudeCount++;
185
+ }
186
+ // Claude should win the vast majority of the time
187
+ expect(claudeCount).toBeGreaterThanOrEqual(18);
188
+ });
189
+ it('marks low-observation providers with explorationReason uncertainty', async () => {
190
+ const store = makeMockStore({
191
+ 'claude:development': makePosterior({
192
+ provider: 'claude',
193
+ workType: 'development',
194
+ alpha: 2,
195
+ beta: 1,
196
+ totalObservations: 2, // Below minObservationsForExploit (5)
197
+ }),
198
+ });
199
+ const config = { ...DEFAULT_ROUTING_CONFIG, explorationRate: 0 };
200
+ const rng = seededRng(42);
201
+ const decision = await selectProvider(store, workType, ['claude'], config, { rng });
202
+ expect(decision.explorationReason).toBe('uncertainty');
203
+ });
204
+ it('explores with probability = explorationRate', async () => {
205
+ const store = makeMockStore({
206
+ 'claude:development': makePosterior({
207
+ provider: 'claude',
208
+ workType: 'development',
209
+ alpha: 10,
210
+ beta: 10,
211
+ totalObservations: 20,
212
+ }),
213
+ 'codex:development': makePosterior({
214
+ provider: 'codex',
215
+ workType: 'development',
216
+ alpha: 10,
217
+ beta: 10,
218
+ totalObservations: 20,
219
+ }),
220
+ });
221
+ const explorationRate = 0.5;
222
+ const config = { ...DEFAULT_ROUTING_CONFIG, explorationRate };
223
+ let forcedCount = 0;
224
+ const n = 200;
225
+ for (let i = 0; i < n; i++) {
226
+ const rng = seededRng(i * 31);
227
+ const decision = await selectProvider(store, workType, ['claude', 'codex'], config, { rng });
228
+ if (decision.explorationReason === 'forced')
229
+ forcedCount++;
230
+ }
231
+ // Exploration should happen roughly explorationRate fraction of the time
232
+ // Allow generous tolerance for randomness
233
+ const fraction = forcedCount / n;
234
+ expect(fraction).toBeGreaterThan(0.3);
235
+ expect(fraction).toBeLessThan(0.7);
236
+ });
237
+ it('includes alternatives array excluding the selected provider', async () => {
238
+ const store = makeMockStore({});
239
+ const rng = seededRng(42);
240
+ const decision = await selectProvider(store, workType, providers, DEFAULT_ROUTING_CONFIG, {
241
+ rng,
242
+ });
243
+ // Alternatives should not contain the selected provider
244
+ const altProviders = decision.alternatives.map(a => a.provider);
245
+ expect(altProviders).not.toContain(decision.selectedProvider);
246
+ // Alternatives + selected should cover all providers
247
+ expect(altProviders.length).toBe(providers.length - 1);
248
+ });
249
+ it('handles single provider (no alternatives)', async () => {
250
+ const store = makeMockStore({});
251
+ const rng = seededRng(42);
252
+ const decision = await selectProvider(store, workType, ['claude'], DEFAULT_ROUTING_CONFIG, { rng });
253
+ expect(decision.selectedProvider).toBe('claude');
254
+ expect(decision.alternatives).toHaveLength(0);
255
+ });
256
+ it('with deterministic RNG produces consistent selection', async () => {
257
+ const store = makeMockStore({
258
+ 'claude:development': makePosterior({
259
+ provider: 'claude',
260
+ workType: 'development',
261
+ alpha: 5,
262
+ beta: 5,
263
+ totalObservations: 10,
264
+ }),
265
+ 'codex:development': makePosterior({
266
+ provider: 'codex',
267
+ workType: 'development',
268
+ alpha: 3,
269
+ beta: 7,
270
+ totalObservations: 10,
271
+ }),
272
+ });
273
+ const config = { ...DEFAULT_ROUTING_CONFIG, explorationRate: 0 };
274
+ const rng1 = seededRng(555);
275
+ const decision1 = await selectProvider(store, workType, ['claude', 'codex'], config, {
276
+ rng: rng1,
277
+ });
278
+ const rng2 = seededRng(555);
279
+ const decision2 = await selectProvider(store, workType, ['claude', 'codex'], config, {
280
+ rng: rng2,
281
+ });
282
+ expect(decision1.selectedProvider).toBe(decision2.selectedProvider);
283
+ expect(decision1.expectedReward).toBe(decision2.expectedReward);
284
+ expect(decision1.confidence).toBe(decision2.confidence);
285
+ });
286
+ it('does not mark explorationReason when provider has sufficient observations and high confidence', async () => {
287
+ const store = makeMockStore({
288
+ 'claude:development': makePosterior({
289
+ provider: 'claude',
290
+ workType: 'development',
291
+ alpha: 50,
292
+ beta: 5,
293
+ totalObservations: 55, // Well above minObservationsForExploit
294
+ }),
295
+ });
296
+ const config = { ...DEFAULT_ROUTING_CONFIG, explorationRate: 0 };
297
+ const rng = seededRng(42);
298
+ const decision = await selectProvider(store, workType, ['claude'], config, { rng });
299
+ expect(decision.explorationReason).toBeUndefined();
300
+ });
301
+ it('calls getPosterior for each available provider', async () => {
302
+ const store = makeMockStore({});
303
+ const rng = seededRng(42);
304
+ await selectProvider(store, workType, providers, DEFAULT_ROUTING_CONFIG, { rng });
305
+ expect(store.getPosterior).toHaveBeenCalledTimes(3);
306
+ expect(store.getPosterior).toHaveBeenCalledWith('claude', 'development');
307
+ expect(store.getPosterior).toHaveBeenCalledWith('codex', 'development');
308
+ expect(store.getPosterior).toHaveBeenCalledWith('amp', 'development');
309
+ });
310
+ });