pi-background-tasks 0.7.4 → 0.7.6

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.
@@ -1,10 +1,31 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ FUSION_CANDIDATE_SYSTEM_PROMPT,
4
+ FUSION_EVALUATION_REPAIR_SYSTEM_PROMPT,
5
+ FUSION_EVALUATOR_SYSTEM_PROMPT,
6
+ FUSION_MERGER_SYSTEM_PROMPT,
7
+ buildBlindEvaluationInput,
8
+ buildCandidatePrompt,
9
+ buildEvaluationPrompt,
10
+ buildEvaluationRepairPrompt,
11
+ buildMergeInput,
12
+ buildMergePrompt,
13
+ type AnonymousFusionCandidate,
14
+ } from './prompts.js';
1
15
  import {
2
16
  FUSION_BUDGET_PLAN_SCHEMA_VERSION,
17
+ FUSION_EVALUATION_SCHEMA_VERSION,
3
18
  FusionError,
19
+ type FusionBudgetBlocker,
20
+ type FusionBudgetEmptyRequestVerdict,
4
21
  type FusionBudgetErrorDetail,
5
22
  type FusionBudgetPlanV1,
6
23
  type FusionBudgetPolicyDescriptor,
7
24
  type FusionBudgetStage,
25
+ type FusionBudgetStageComposition,
26
+ type FusionBudgetWarning,
27
+ type FusionCanonicalInputV3,
28
+ type FusionEvaluationV1,
8
29
  type FusionRouteCapacity,
9
30
  type FusionStage,
10
31
  type FusionStageBudgetPlanEntry,
@@ -12,159 +33,123 @@ import {
12
33
  type ResolvedFusionModels,
13
34
  } from './types.js';
14
35
 
15
- /**
16
- * Conservative lower bound on UTF-8 bytes per input token.
17
- *
18
- * Token upper bound = ceil(utf8Bytes / FUSION_BYTES_PER_TOKEN_DIVISOR).
19
- *
20
- * Across 159 real large Fusion prompts (50 KB–1.4 MB) recorded in
21
- * `.pi/fusion/**\/manifest.json`, the smallest observed ratio was 3.552 bytes
22
- * per reported input token. A divisor of 2 therefore leaves roughly a 1.7x
23
- * margin against the densest real prompt and still bounds pathological input
24
- * such as dense CJK (3 UTF-8 bytes producing at most ~1.5 tokens) or long runs
25
- * of punctuation. It is not an estimate of typical usage; it is a ceiling.
26
- */
27
36
  export const FUSION_BYTES_PER_TOKEN_DIVISOR = 2;
28
37
 
29
- /**
30
- * Enforced maximum size of one child's response, measured in **JSON-rendered
31
- * transfer bytes** — the bytes the response actually contributes when a later
32
- * stage embeds it, escaping included.
33
- *
34
- * Measuring the rendered form rather than the raw form removes the need to
35
- * guess an escaping factor: quotes, backslashes, and newlines expand 2x and
36
- * control characters up to 6x, so a raw-byte contract would not bound the
37
- * embedded size. `assertChildOutputWithinContract` rejects a response that
38
- * exceeds its stage bound, so a later stage can never be handed more embedded
39
- * text than is budgeted here. Nothing is truncated to fit: an oversized
40
- * response is a loud failure.
41
- *
42
- * Sized above the largest real responses recorded across `.pi/fusion`:
43
- * candidate 45,434 B, evaluator 54,829 B, merged 56,846 B.
44
- */
45
38
  export const FUSION_CANDIDATE_MAX_OUTPUT_BYTES = 48 * 1024;
46
39
  export const FUSION_EVALUATION_MAX_OUTPUT_BYTES = 64 * 1024;
47
40
  export const FUSION_MERGE_MAX_OUTPUT_BYTES = 64 * 1024;
48
-
49
- /** `boundedEvaluationErrors` caps repair diagnostics far below this. */
50
41
  export const FUSION_DIAGNOSTICS_MAX_BYTES = 8 * 1024;
51
42
 
52
- /**
53
- * Tokens reserved so a child can emit a response up to its byte contract.
54
- *
55
- * Derived from the largest output contract through the same conservative
56
- * byte-to-token conversion used to measure prompts, so it cannot be understated.
57
- */
58
43
  export const FUSION_RESERVED_OUTPUT_TOKENS = Math.ceil(
59
44
  FUSION_MERGE_MAX_OUTPUT_BYTES / FUSION_BYTES_PER_TOKEN_DIVISOR,
60
45
  );
61
-
62
- /**
63
- * Upper bound on Pi/provider framing that is not represented in the prompt
64
- * bytes Fusion renders: the child system prompt is passed via argv (counted
65
- * separately below), but chat framing, tool-free scaffolding, and provider
66
- * envelope overhead are not visible to this package.
67
- */
68
46
  export const FUSION_FRAMING_RESERVE_TOKENS = 4_096;
69
-
70
- /** Additional margin for provider-side tokenizer differences. */
71
47
  export const FUSION_SAFETY_RESERVE_TOKENS = 4_096;
72
-
73
- /**
74
- * Fixed structural overhead of the blind-candidate and repair wrappers: schema
75
- * version strings, candidate_id keys, JSON punctuation, and the evaluation
76
- * object's own keys. These are constant-size, not content-dependent; 16 KiB is
77
- * an order of magnitude above the real wrapper size.
78
- */
79
- export const FUSION_WRAPPER_OVERHEAD_BYTES = 16 * 1024;
80
-
81
- /**
82
- * Bytes of downstream growth the canonical input must leave room for.
83
- *
84
- * The widest stage prompt is the evaluation repair, which embeds the canonical
85
- * input plus three candidate answers, the invalid evaluator output, and bounded
86
- * validation errors. The merge prompt is strictly smaller.
87
- *
88
- * Because the output contracts above are enforced against JSON-rendered bytes,
89
- * this is an exact sum rather than a raw size inflated by an estimated escaping
90
- * factor. No content can expand past it, and the exact rendered prompt is still
91
- * re-measured before every spawn as defence in depth.
92
- */
93
- export const FUSION_DOWNSTREAM_RESERVE_BYTES =
94
- 3 * FUSION_CANDIDATE_MAX_OUTPUT_BYTES +
95
- FUSION_EVALUATION_MAX_OUTPUT_BYTES +
96
- FUSION_DIAGNOSTICS_MAX_BYTES +
97
- FUSION_WRAPPER_OVERHEAD_BYTES;
98
-
99
- /**
100
- * Tokens withheld from the canonical input for that growth.
101
- *
102
- * This converts the reserve through the same byte-to-token function used to
103
- * measure prompts. Reserving output *tokens* directly would understate the cost
104
- * of re-embedding those bytes by the ratio between a provider's real
105
- * tokenization and this conservative ceiling.
106
- */
107
- export const FUSION_DOWNSTREAM_RESERVE_TOKENS = Math.ceil(
108
- FUSION_DOWNSTREAM_RESERVE_BYTES / FUSION_BYTES_PER_TOKEN_DIVISOR,
109
- );
110
-
111
- /** Minimum usable canonical-input room a configured route must still offer. */
112
48
  export const FUSION_MIN_CANONICAL_INPUT_TOKENS = 8_192;
113
-
114
- /**
115
- * Smallest context window that can host the complete workflow under this policy.
116
- *
117
- * This is a consequence of uniformly conservative accounting, not a preference
118
- * for large models: the downstream reserve, the output reserve, framing, safety,
119
- * and a usable amount of canonical input must all fit. Routes below this are
120
- * rejected at configuration time with an actionable error rather than being
121
- * silently accepted and failing later at the provider.
122
- */
123
49
  export const FUSION_MIN_CONTEXT_WINDOW_TOKENS =
124
- FUSION_DOWNSTREAM_RESERVE_TOKENS +
125
50
  FUSION_MIN_CANONICAL_INPUT_TOKENS +
126
51
  FUSION_RESERVED_OUTPUT_TOKENS +
127
52
  FUSION_FRAMING_RESERVE_TOKENS +
128
53
  FUSION_SAFETY_RESERVE_TOKENS;
54
+ export const FUSION_UTILIZATION_WARNING_THRESHOLD = 0.8;
129
55
 
130
56
  export const FUSION_BUDGET_POLICY: FusionBudgetPolicyDescriptor = {
131
- id: 'fusion-budget-policy-v1',
57
+ id: 'fusion-budget-policy-v2',
132
58
  bytes_per_token_divisor: FUSION_BYTES_PER_TOKEN_DIVISOR,
133
59
  reserved_output_tokens: FUSION_RESERVED_OUTPUT_TOKENS,
134
60
  framing_reserve_tokens: FUSION_FRAMING_RESERVE_TOKENS,
135
61
  safety_reserve_tokens: FUSION_SAFETY_RESERVE_TOKENS,
136
- downstream_reserve_bytes: FUSION_DOWNSTREAM_RESERVE_BYTES,
137
- downstream_reserve_tokens: FUSION_DOWNSTREAM_RESERVE_TOKENS,
62
+ candidate_output_contract_bytes: FUSION_CANDIDATE_MAX_OUTPUT_BYTES,
63
+ evaluation_output_contract_bytes: FUSION_EVALUATION_MAX_OUTPUT_BYTES,
64
+ merge_output_contract_bytes: FUSION_MERGE_MAX_OUTPUT_BYTES,
65
+ diagnostics_contract_bytes: FUSION_DIAGNOSTICS_MAX_BYTES,
66
+ utilization_warning_threshold: FUSION_UTILIZATION_WARNING_THRESHOLD,
138
67
  };
139
68
 
140
- const REMEDIATION: readonly string[] = Object.freeze([
69
+ const EMPTY_REMEDIATION: readonly string[] = Object.freeze([
141
70
  'Start a fresh Pi conversation, or run Fusion earlier in the session.',
142
- 'Provide a shorter, self-contained fusion_brainstorm prompt.',
71
+ "Raise the route's context window with a larger-context model via /fusion-models.",
143
72
  'Restate only the required prior findings as visible conversation text.',
144
- 'Configure a larger-context model for the limiting Fusion slot via /fusion-models.',
145
73
  ]);
146
74
 
75
+ const REQUEST_REMEDIATION: readonly string[] = Object.freeze([
76
+ 'Provide a shorter, self-contained fusion_brainstorm prompt.',
77
+ 'Start a fresh Pi conversation, or run Fusion earlier in the session.',
78
+ "Raise the route's context window with a larger-context model via /fusion-models.",
79
+ ]);
80
+
81
+ const EMPTY_CANDIDATES: readonly [
82
+ AnonymousFusionCandidate,
83
+ AnonymousFusionCandidate,
84
+ AnonymousFusionCandidate,
85
+ ] = Object.freeze([
86
+ Object.freeze({ candidate_id: 'A', response: '' }),
87
+ Object.freeze({ candidate_id: 'B', response: '' }),
88
+ Object.freeze({ candidate_id: 'C', response: '' }),
89
+ ]);
90
+
91
+ const EMPTY_EVALUATION: FusionEvaluationV1 = Object.freeze({
92
+ schema_version: FUSION_EVALUATION_SCHEMA_VERSION,
93
+ candidate_assessments: Object.freeze([
94
+ Object.freeze({
95
+ candidate_id: 'A',
96
+ summary: '',
97
+ strengths: Object.freeze([]),
98
+ limitations: Object.freeze([]),
99
+ useful_contributions: Object.freeze([]),
100
+ risks: Object.freeze([]),
101
+ }),
102
+ Object.freeze({
103
+ candidate_id: 'B',
104
+ summary: '',
105
+ strengths: Object.freeze([]),
106
+ limitations: Object.freeze([]),
107
+ useful_contributions: Object.freeze([]),
108
+ risks: Object.freeze([]),
109
+ }),
110
+ Object.freeze({
111
+ candidate_id: 'C',
112
+ summary: '',
113
+ strengths: Object.freeze([]),
114
+ limitations: Object.freeze([]),
115
+ useful_contributions: Object.freeze([]),
116
+ risks: Object.freeze([]),
117
+ }),
118
+ ]) as readonly [
119
+ FusionEvaluationV1['candidate_assessments'][0],
120
+ FusionEvaluationV1['candidate_assessments'][1],
121
+ FusionEvaluationV1['candidate_assessments'][2],
122
+ ],
123
+ agreements: Object.freeze([]),
124
+ conflicts: Object.freeze([]),
125
+ synthesis_plan: Object.freeze({
126
+ must_include: Object.freeze([]),
127
+ must_resolve: Object.freeze([]),
128
+ must_avoid: Object.freeze([]),
129
+ }),
130
+ });
131
+
132
+ interface StageForecastDraft {
133
+ budget_stage: FusionBudgetStage;
134
+ slot?: 1 | 2 | 3;
135
+ route: FusionRouteCapacity;
136
+ conditional: boolean;
137
+ system_prompt: string;
138
+ empty_user_prompt: string;
139
+ upstream_output_contract_bytes: number;
140
+ }
141
+
147
142
  export function fusionTokenUpperBound(utf8Bytes: number): number {
148
143
  return Math.ceil(utf8Bytes / FUSION_BYTES_PER_TOKEN_DIVISOR);
149
144
  }
150
145
 
151
- /** Enforced response-size contract for one stage, in UTF-8 bytes. */
152
146
  export function fusionOutputContractBytes(stage: FusionStage): number {
153
147
  if (stage === 'candidate') return FUSION_CANDIDATE_MAX_OUTPUT_BYTES;
154
148
  if (stage === 'evaluation') return FUSION_EVALUATION_MAX_OUTPUT_BYTES;
155
149
  return FUSION_MERGE_MAX_OUTPUT_BYTES;
156
150
  }
157
151
 
158
- /**
159
- * Reject a child response larger than its stage contract.
160
- *
161
- * This is what makes the downstream reserve a guarantee rather than a hope: a
162
- * later stage can never embed more bytes than were budgeted. The oversized text
163
- * is never sliced and never forwarded; the run fails loudly instead.
164
- */
165
152
  export function assertChildOutputWithinContract(stage: FusionStage, text: string): void {
166
- // Measure what the response costs once embedded, escaping included, so the
167
- // downstream reserve is an exact bound rather than an estimate.
168
153
  const bytes = Buffer.byteLength(JSON.stringify(text), 'utf8');
169
154
  const allowed = fusionOutputContractBytes(stage);
170
155
  if (bytes <= allowed) return;
@@ -174,6 +159,14 @@ export function assertChildOutputWithinContract(stage: FusionStage, text: string
174
159
  );
175
160
  }
176
161
 
162
+ function utf8Bytes(value: string): number {
163
+ return Buffer.byteLength(value, 'utf8');
164
+ }
165
+
166
+ function sha256Hex(value: string): string {
167
+ return createHash('sha256').update(value, 'utf8').digest('hex');
168
+ }
169
+
177
170
  function requirePositiveContextWindow(model: ResolvedFusionModel, role: string): number {
178
171
  const value = model.contextWindow;
179
172
  if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
@@ -195,11 +188,9 @@ function routeCapacity(
195
188
  FUSION_RESERVED_OUTPUT_TOKENS -
196
189
  FUSION_FRAMING_RESERVE_TOKENS -
197
190
  FUSION_SAFETY_RESERVE_TOKENS;
198
- // The route must hold the downstream reserve plus a usable amount of canonical
199
- // input, otherwise the configured panel can never complete a workflow.
200
- if (allowed < FUSION_DOWNSTREAM_RESERVE_TOKENS + FUSION_MIN_CANONICAL_INPUT_TOKENS) {
191
+ if (allowed < FUSION_MIN_CANONICAL_INPUT_TOKENS) {
201
192
  throw new FusionError(
202
- `fusion ${role} route ${model.qualifiedId} has a ${String(contextWindow)}-token context window, but the Fusion workflow requires at least ${String(FUSION_MIN_CONTEXT_WINDOW_TOKENS)} tokens per configured route: ${String(FUSION_RESERVED_OUTPUT_TOKENS)} output + ${String(FUSION_FRAMING_RESERVE_TOKENS)} framing + ${String(FUSION_SAFETY_RESERVE_TOKENS)} safety + ${String(FUSION_DOWNSTREAM_RESERVE_TOKENS)} for evaluator/repair/merger expansion + ${String(FUSION_MIN_CANONICAL_INPUT_TOKENS)} usable canonical input. Choose a larger-context model for this slot with /fusion-models.`,
193
+ `fusion ${role} route ${model.qualifiedId} has a ${String(contextWindow)}-token context window, but Fusion requires at least ${String(FUSION_MIN_CONTEXT_WINDOW_TOKENS)} tokens per configured route: ${String(FUSION_RESERVED_OUTPUT_TOKENS)} output + ${String(FUSION_FRAMING_RESERVE_TOKENS)} framing + ${String(FUSION_SAFETY_RESERVE_TOKENS)} safety + ${String(FUSION_MIN_CANONICAL_INPUT_TOKENS)} usable input. Choose a larger-context model for this slot with /fusion-models.`,
203
194
  { code: 'model_capacity_unknown', childCreated: false },
204
195
  );
205
196
  }
@@ -226,11 +217,6 @@ export function fusionRouteCapacities(models: ResolvedFusionModels): readonly Fu
226
217
  ];
227
218
  }
228
219
 
229
- /**
230
- * The limiting route is the configured participant with the smallest input
231
- * budget — never the largest-context model. Ties resolve to the first route in
232
- * declaration order so the plan is deterministic.
233
- */
234
220
  export function fusionLimitingRoute(
235
221
  routes: readonly FusionRouteCapacity[],
236
222
  ): FusionRouteCapacity {
@@ -249,6 +235,155 @@ export function fusionLimitingRoute(
249
235
  return limiting;
250
236
  }
251
237
 
238
+ function routeByRole(
239
+ routes: readonly FusionRouteCapacity[],
240
+ role: FusionRouteCapacity['role'],
241
+ ): FusionRouteCapacity {
242
+ const route = routes.find((item) => item.role === role);
243
+ if (route === undefined) {
244
+ throw new FusionError(`fusion budget route ${role} is missing`, {
245
+ code: 'model_capacity_unknown',
246
+ childCreated: false,
247
+ });
248
+ }
249
+ return route;
250
+ }
251
+
252
+ function candidateRole(slot: 1 | 2 | 3): FusionRouteCapacity['role'] {
253
+ if (slot === 1) return 'candidate-1';
254
+ if (slot === 2) return 'candidate-2';
255
+ return 'candidate-3';
256
+ }
257
+
258
+ function forecastEntry(draft: StageForecastDraft): FusionStageBudgetPlanEntry {
259
+ const forecastUtf8Bytes =
260
+ utf8Bytes(draft.system_prompt) +
261
+ utf8Bytes(draft.empty_user_prompt) +
262
+ draft.upstream_output_contract_bytes;
263
+ const tokens = fusionTokenUpperBound(forecastUtf8Bytes);
264
+ const entry: FusionStageBudgetPlanEntry = {
265
+ budget_stage: draft.budget_stage,
266
+ route: draft.route,
267
+ conditional: draft.conditional,
268
+ forecast_utf8_bytes: forecastUtf8Bytes,
269
+ forecast_input_tokens_upper_bound: tokens,
270
+ allowed_input_tokens: draft.route.allowed_input_tokens,
271
+ signed_headroom_tokens: draft.route.allowed_input_tokens - tokens,
272
+ utilization: tokens / draft.route.allowed_input_tokens,
273
+ fits: tokens <= draft.route.allowed_input_tokens,
274
+ };
275
+ if (draft.slot !== undefined) entry.slot = draft.slot;
276
+ return entry;
277
+ }
278
+
279
+ function blockerFromEntry(entry: FusionStageBudgetPlanEntry): FusionBudgetBlocker {
280
+ return { ...entry, overage_tokens: Math.max(0, -entry.signed_headroom_tokens) };
281
+ }
282
+
283
+ function blockerOrder(blocker: FusionBudgetBlocker): number {
284
+ if (blocker.budget_stage === 'candidate') return blocker.slot ?? 1;
285
+ if (blocker.budget_stage === 'evaluation') return 4;
286
+ if (blocker.budget_stage === 'merge') return 5;
287
+ return 6;
288
+ }
289
+
290
+ function selectBlockers(entries: readonly FusionStageBudgetPlanEntry[]): readonly FusionBudgetBlocker[] {
291
+ return entries.filter((entry) => !entry.fits).map(blockerFromEntry).sort((left, right) => {
292
+ const byOrder = blockerOrder(left) - blockerOrder(right);
293
+ if (byOrder !== 0) return byOrder;
294
+ return left.route.role.localeCompare(right.route.role);
295
+ });
296
+ }
297
+
298
+ function selectPrimaryBlocker(
299
+ blockers: readonly FusionBudgetBlocker[],
300
+ ): FusionBudgetBlocker | undefined {
301
+ const mandatory = blockers.find((blocker) => !blocker.conditional);
302
+ return mandatory ?? blockers[0];
303
+ }
304
+
305
+ function replaceRequestText(input: FusionCanonicalInputV3, text: string): FusionCanonicalInputV3 {
306
+ return {
307
+ ...input,
308
+ request: {
309
+ ...input.request,
310
+ text,
311
+ sha256: sha256Hex(text),
312
+ },
313
+ };
314
+ }
315
+
316
+ function visibleTextBytes(input: FusionCanonicalInputV3): number {
317
+ let total = 0;
318
+ for (const entry of input.conversation_projection.entries) {
319
+ if (entry.kind === 'text') total += utf8Bytes(JSON.stringify(entry.text));
320
+ }
321
+ return total;
322
+ }
323
+
324
+ function omissionReceiptBytes(input: FusionCanonicalInputV3): number {
325
+ let total = 0;
326
+ for (const entry of input.conversation_projection.entries) {
327
+ if (entry.kind === 'omitted_activity') total += utf8Bytes(JSON.stringify(entry));
328
+ }
329
+ return total;
330
+ }
331
+
332
+ function warningFromEntry(entry: FusionStageBudgetPlanEntry): FusionBudgetWarning {
333
+ return { ...entry, threshold: FUSION_UTILIZATION_WARNING_THRESHOLD };
334
+ }
335
+
336
+ function warningsFor(entries: readonly FusionStageBudgetPlanEntry[]): readonly FusionBudgetWarning[] {
337
+ return entries
338
+ .filter((entry) => entry.fits && entry.utilization >= FUSION_UTILIZATION_WARNING_THRESHOLD)
339
+ .map(warningFromEntry);
340
+ }
341
+
342
+ function entryLabel(entry: FusionStageBudgetPlanEntry): string {
343
+ const slot = entry.slot === undefined ? '' : `-${String(entry.slot)}`;
344
+ const conditional = entry.conditional ? ' (conditional)' : '';
345
+ return `${entry.budget_stage}${slot}${conditional}`;
346
+ }
347
+
348
+ function formatTable(entries: readonly FusionStageBudgetPlanEntry[]): string {
349
+ const lines = entries.map(
350
+ (entry) =>
351
+ `${entryLabel(entry)} | route=${entry.route.qualified_id} | forecast=${String(entry.forecast_input_tokens_upper_bound)} | allowed=${String(entry.allowed_input_tokens)} | headroom=${String(entry.signed_headroom_tokens)} | ${entry.fits ? 'fits' : 'over'}`,
352
+ );
353
+ return lines.join('\n');
354
+ }
355
+
356
+ function formatComposition(composition: FusionBudgetStageComposition): string {
357
+ return [
358
+ `visible text ${String(composition.visible_text_bytes)} B`,
359
+ `omission receipts ${String(composition.omission_receipt_bytes)} B`,
360
+ `projection metadata ${String(composition.projection_metadata_bytes)} B`,
361
+ `request ${String(composition.request_bytes)} B`,
362
+ `static stage framing ${String(composition.static_stage_framing_bytes)} B`,
363
+ `upstream output contracts ${String(composition.upstream_output_contract_bytes)} B`,
364
+ ].join('; ');
365
+ }
366
+
367
+ function remediationFor(verdict: FusionBudgetEmptyRequestVerdict): readonly string[] {
368
+ return verdict.still_fails_with_empty_request ? EMPTY_REMEDIATION : REQUEST_REMEDIATION;
369
+ }
370
+
371
+ function formatEmptyRequestVerdict(verdict: FusionBudgetEmptyRequestVerdict): string {
372
+ if (verdict.still_fails_with_empty_request) {
373
+ return `Empty-request counterfactual: still fails with ${String(verdict.blockers_with_empty_request.length)} blocking stage(s), so shortening the request cannot make this workflow fit.`;
374
+ }
375
+ if (verdict.minimum_request_byte_reduction === 0) {
376
+ return 'Empty-request counterfactual: fits; no request reduction is required by the current plan.';
377
+ }
378
+ return `Empty-request counterfactual: fits, so request size determines feasibility; reduce the request by at least ${String(verdict.minimum_request_byte_reduction)} UTF-8 bytes, leaving at most ${String(verdict.maximum_safe_request_utf8_bytes)} UTF-8 bytes.`;
379
+ }
380
+
381
+ function stageFromBudgetStage(stage: FusionBudgetStage): FusionStage {
382
+ if (stage === 'merge') return 'merge';
383
+ if (stage === 'candidate') return 'candidate';
384
+ return 'evaluation';
385
+ }
386
+
252
387
  export class FusionBudget {
253
388
  readonly routes: readonly FusionRouteCapacity[];
254
389
  readonly limiting: FusionRouteCapacity;
@@ -260,113 +395,262 @@ export class FusionBudget {
260
395
  this.contextPolicyId = contextPolicyId;
261
396
  }
262
397
 
263
- /** Full input budget of the limiting route, in tokens. */
264
398
  get allowedInputTokens(): number {
265
399
  return this.limiting.allowed_input_tokens;
266
400
  }
267
401
 
268
- /**
269
- * Budget for the canonical input alone, holding back the derived reserve that
270
- * downstream evaluator/repair/merger expansion provably needs.
271
- */
272
- get allowedCanonicalInputTokens(): number {
273
- return this.allowedInputTokens - FUSION_DOWNSTREAM_RESERVE_TOKENS;
402
+ private routeForStage(stage: FusionBudgetStage, slot?: 1 | 2 | 3): FusionRouteCapacity {
403
+ if (stage === 'candidate') return routeByRole(this.routes, candidateRole(slot ?? 1));
404
+ if (stage === 'merge') return routeByRole(this.routes, 'merger');
405
+ return routeByRole(this.routes, 'evaluator');
406
+ }
407
+
408
+ private drafts(input: FusionCanonicalInputV3): readonly StageForecastDraft[] {
409
+ const candidatePrompt = buildCandidatePrompt(input);
410
+ const blindInput = buildBlindEvaluationInput(input, EMPTY_CANDIDATES);
411
+ const evaluationPrompt = buildEvaluationPrompt(blindInput);
412
+ const repairPrompt = buildEvaluationRepairPrompt({
413
+ schema_version: 'pi-background-tasks.fusion-evaluation-repair-input.v1',
414
+ original_blind_input: blindInput,
415
+ invalid_output: '',
416
+ validation_errors: [],
417
+ });
418
+ const mergePrompt = buildMergePrompt(buildMergeInput(input, EMPTY_CANDIDATES, EMPTY_EVALUATION));
419
+ return [
420
+ {
421
+ budget_stage: 'candidate',
422
+ slot: 1,
423
+ route: this.routeForStage('candidate', 1),
424
+ conditional: false,
425
+ system_prompt: FUSION_CANDIDATE_SYSTEM_PROMPT,
426
+ empty_user_prompt: candidatePrompt,
427
+ upstream_output_contract_bytes: 0,
428
+ },
429
+ {
430
+ budget_stage: 'candidate',
431
+ slot: 2,
432
+ route: this.routeForStage('candidate', 2),
433
+ conditional: false,
434
+ system_prompt: FUSION_CANDIDATE_SYSTEM_PROMPT,
435
+ empty_user_prompt: candidatePrompt,
436
+ upstream_output_contract_bytes: 0,
437
+ },
438
+ {
439
+ budget_stage: 'candidate',
440
+ slot: 3,
441
+ route: this.routeForStage('candidate', 3),
442
+ conditional: false,
443
+ system_prompt: FUSION_CANDIDATE_SYSTEM_PROMPT,
444
+ empty_user_prompt: candidatePrompt,
445
+ upstream_output_contract_bytes: 0,
446
+ },
447
+ {
448
+ budget_stage: 'evaluation',
449
+ route: this.routeForStage('evaluation'),
450
+ conditional: false,
451
+ system_prompt: FUSION_EVALUATOR_SYSTEM_PROMPT,
452
+ empty_user_prompt: evaluationPrompt,
453
+ upstream_output_contract_bytes: 3 * FUSION_CANDIDATE_MAX_OUTPUT_BYTES,
454
+ },
455
+ {
456
+ budget_stage: 'merge',
457
+ route: this.routeForStage('merge'),
458
+ conditional: false,
459
+ system_prompt: FUSION_MERGER_SYSTEM_PROMPT,
460
+ empty_user_prompt: mergePrompt,
461
+ upstream_output_contract_bytes:
462
+ 3 * FUSION_CANDIDATE_MAX_OUTPUT_BYTES + FUSION_EVALUATION_MAX_OUTPUT_BYTES,
463
+ },
464
+ {
465
+ budget_stage: 'evaluation_repair',
466
+ route: this.routeForStage('evaluation_repair'),
467
+ conditional: true,
468
+ system_prompt: FUSION_EVALUATION_REPAIR_SYSTEM_PROMPT,
469
+ empty_user_prompt: repairPrompt,
470
+ upstream_output_contract_bytes:
471
+ 3 * FUSION_CANDIDATE_MAX_OUTPUT_BYTES +
472
+ FUSION_EVALUATION_MAX_OUTPUT_BYTES +
473
+ FUSION_DIAGNOSTICS_MAX_BYTES,
474
+ },
475
+ ];
476
+ }
477
+
478
+ private entries(input: FusionCanonicalInputV3): readonly FusionStageBudgetPlanEntry[] {
479
+ return this.drafts(input).map(forecastEntry);
480
+ }
481
+
482
+ private composition(
483
+ input: FusionCanonicalInputV3,
484
+ blocker: FusionBudgetBlocker,
485
+ ): FusionBudgetStageComposition {
486
+ const canonicalBytes = utf8Bytes(buildCandidatePrompt(input));
487
+ const emptyRequestCanonicalBytes = utf8Bytes(buildCandidatePrompt(replaceRequestText(input, '')));
488
+ const requestBytes = canonicalBytes - emptyRequestCanonicalBytes;
489
+ const visible = visibleTextBytes(input);
490
+ const omissions = omissionReceiptBytes(input);
491
+ const projectionMetadata = canonicalBytes - requestBytes - visible - omissions;
492
+ const draft = this.drafts(input).find(
493
+ (item) => item.budget_stage === blocker.budget_stage && item.slot === blocker.slot,
494
+ );
495
+ if (draft === undefined) {
496
+ throw new FusionError('primary budget blocker disappeared during composition', {
497
+ code: 'orchestration_failed',
498
+ childCreated: false,
499
+ });
500
+ }
501
+ return {
502
+ visible_text_bytes: visible,
503
+ omission_receipt_bytes: omissions,
504
+ projection_metadata_bytes: projectionMetadata,
505
+ request_bytes: requestBytes,
506
+ static_stage_framing_bytes:
507
+ utf8Bytes(draft.system_prompt) + utf8Bytes(draft.empty_user_prompt) - canonicalBytes,
508
+ upstream_output_contract_bytes: draft.upstream_output_contract_bytes,
509
+ };
510
+ }
511
+
512
+ private emptyRequestVerdict(
513
+ input: FusionCanonicalInputV3,
514
+ entries: readonly FusionStageBudgetPlanEntry[],
515
+ ): FusionBudgetEmptyRequestVerdict {
516
+ const emptyEntries = this.entries(replaceRequestText(input, ''));
517
+ const emptyBlockers = selectBlockers(emptyEntries);
518
+ let reduction = 0;
519
+ for (const entry of entries) {
520
+ const byteLimit = entry.allowed_input_tokens * FUSION_BYTES_PER_TOKEN_DIVISOR;
521
+ reduction = Math.max(reduction, entry.forecast_utf8_bytes - byteLimit);
522
+ }
523
+ reduction = Math.max(0, reduction);
524
+ const requestBytes = utf8Bytes(input.request.text);
525
+ return {
526
+ request_utf8_bytes: requestBytes,
527
+ still_fails_with_empty_request: emptyBlockers.length > 0,
528
+ shortening_request_can_help: emptyBlockers.length === 0,
529
+ minimum_request_byte_reduction: reduction,
530
+ maximum_safe_request_utf8_bytes: Math.max(0, requestBytes - reduction),
531
+ blockers_with_empty_request: emptyBlockers,
532
+ };
274
533
  }
275
534
 
276
535
  private failure(
277
- stage: FusionBudgetStage,
536
+ primary: FusionBudgetBlocker,
537
+ plan: FusionBudgetPlanV1,
538
+ artifactDir: string,
278
539
  measurementKind: FusionBudgetErrorDetail['measurement_kind'],
279
- utf8Bytes: number,
280
- allowedTokens: number,
281
- label: string,
282
540
  ): FusionError {
283
- const tokens = fusionTokenUpperBound(utf8Bytes);
541
+ const remediation = remediationFor(plan.empty_request);
284
542
  const budget: FusionBudgetErrorDetail = {
285
- budget_stage: stage,
543
+ budget_stage: primary.budget_stage,
286
544
  measurement_kind: measurementKind,
287
- measured_utf8_bytes: utf8Bytes,
288
- measured_input_tokens_upper_bound: tokens,
289
- allowed_input_tokens: allowedTokens,
545
+ measured_utf8_bytes: primary.forecast_utf8_bytes,
546
+ measured_input_tokens_upper_bound: primary.forecast_input_tokens_upper_bound,
547
+ allowed_input_tokens: primary.allowed_input_tokens,
290
548
  limiting_model: {
291
- provider: this.limiting.provider,
292
- model: this.limiting.model,
293
- qualified_id: this.limiting.qualified_id,
294
- context_window_tokens: this.limiting.context_window_tokens,
549
+ provider: primary.route.provider,
550
+ model: primary.route.model,
551
+ qualified_id: primary.route.qualified_id,
552
+ context_window_tokens: primary.route.context_window_tokens,
295
553
  },
296
554
  context_policy_id: this.contextPolicyId,
297
- remediation: REMEDIATION,
555
+ remediation,
556
+ blockers: plan.blockers,
557
+ artifact_dir: artifactDir,
298
558
  };
559
+ if (primary.slot !== undefined) budget.slot = primary.slot;
560
+ const composition = plan.primary_blocker_composition;
561
+ const compositionText = composition === undefined ? 'unavailable' : formatComposition(composition);
562
+ const additional = plan.blockers
563
+ .filter((blocker) => blocker !== primary)
564
+ .map((blocker) => `${entryLabel(blocker)} route=${blocker.route.qualified_id}`)
565
+ .join('; ');
566
+ const overage = primary.forecast_input_tokens_upper_bound - primary.allowed_input_tokens;
299
567
  const message =
300
- `fusion ${label} exceeds the safe input budget before child creation: ` +
301
- `measured ${String(utf8Bytes)} UTF-8 bytes (<= ${String(tokens)} input tokens) ` +
302
- `against ${String(allowedTokens)} allowed input tokens for the limiting configured model ` +
303
- `${this.limiting.qualified_id} (context window ${String(this.limiting.context_window_tokens)} tokens, ` +
304
- `reserving ${String(this.limiting.reserved_output_tokens)} output + ` +
305
- `${String(this.limiting.framing_reserve_tokens)} framing + ` +
306
- `${String(this.limiting.safety_reserve_tokens)} safety tokens). ` +
307
- `Remediation: ${REMEDIATION.join(' ')}`;
308
- return new FusionError(message, {
309
- code: 'prompt_budget_exceeded',
568
+ `Fusion prompt budget exceeded before child creation. Primary blocking stage: ${entryLabel(primary)} on route ${primary.route.qualified_id}. ` +
569
+ `Forecast ${String(primary.forecast_utf8_bytes)} UTF-8 bytes (<= ${String(primary.forecast_input_tokens_upper_bound)} input tokens) against ${String(primary.allowed_input_tokens)} allowed input tokens, over by ${String(overage)} tokens. ` +
570
+ `No child was created. Nothing was clipped, dropped, or substituted. Artifact directory: ${artifactDir}.\n` +
571
+ `Per-stage forecast table:\n${formatTable(plan.stages)}\n` +
572
+ `Primary blocker byte composition: ${compositionText}.\n` +
573
+ `Additional blockers: ${additional.length === 0 ? 'none' : additional}.\n` +
574
+ `${formatEmptyRequestVerdict(plan.empty_request)}\n` +
575
+ `Remediation: ${remediation.join(' ')}`;
576
+ const details = {
577
+ code: 'prompt_budget_exceeded' as const,
310
578
  childCreated: false,
311
579
  budget,
312
- });
580
+ stage: stageFromBudgetStage(primary.budget_stage),
581
+ };
582
+ if (primary.slot !== undefined) return new FusionError(message, { ...details, slot: primary.slot });
583
+ return new FusionError(message, details);
313
584
  }
314
585
 
315
- /**
316
- * Whole-DAG feasibility check run before the first candidate spawns. Proving the
317
- * canonical input fits within its reserved share proves every downstream stage
318
- * has room for its expansion, because the reserved remainder exceeds the
319
- * largest possible candidate/evaluation growth by construction.
320
- */
321
- assertBaseContext(canonicalInputSerialized: string, systemPromptBytes: number): void {
322
- const bytes = Buffer.byteLength(canonicalInputSerialized, 'utf8') + systemPromptBytes;
323
- if (fusionTokenUpperBound(bytes) > this.allowedCanonicalInputTokens) {
324
- throw this.failure(
325
- 'candidate',
326
- 'worst_case_envelope',
327
- bytes,
328
- this.allowedCanonicalInputTokens,
329
- 'conversation projection plus request',
330
- );
331
- }
586
+ plan(input: FusionCanonicalInputV3): FusionBudgetPlanV1 {
587
+ const stages = this.entries(input);
588
+ const blockers = selectBlockers(stages);
589
+ const primary = selectPrimaryBlocker(blockers);
590
+ const emptyRequest = this.emptyRequestVerdict(input, stages);
591
+ const base: FusionBudgetPlanV1 = {
592
+ schema_version: FUSION_BUDGET_PLAN_SCHEMA_VERSION,
593
+ policy: FUSION_BUDGET_POLICY,
594
+ routes: this.routes,
595
+ stages,
596
+ blockers,
597
+ empty_request: emptyRequest,
598
+ warnings: warningsFor(stages),
599
+ };
600
+ if (primary === undefined) return base;
601
+ return {
602
+ ...base,
603
+ primary_blocker: primary,
604
+ primary_blocker_composition: this.composition(input, primary),
605
+ };
332
606
  }
333
607
 
334
- /**
335
- * Exact preflight for one rendered stage prompt, measured on the same bytes
336
- * that will be written to the child's stdin and persisted as the artifact.
337
- */
338
- assertStagePrompt(stage: FusionBudgetStage, systemPrompt: string, userPrompt: string): void {
339
- const bytes =
340
- Buffer.byteLength(systemPrompt, 'utf8') + Buffer.byteLength(userPrompt, 'utf8');
341
- if (fusionTokenUpperBound(bytes) > this.allowedInputTokens) {
342
- throw this.failure(
343
- stage,
344
- 'rendered_prompt',
345
- bytes,
346
- this.allowedInputTokens,
347
- `${stage} prompt`,
348
- );
608
+ assertPlanFits(plan: FusionBudgetPlanV1, artifactDir: string): void {
609
+ if (plan.primary_blocker !== undefined) {
610
+ throw this.failure(plan.primary_blocker, plan, artifactDir, 'stage_forecast');
349
611
  }
350
612
  }
351
613
 
352
- plan(canonicalInputSerialized: string, systemPromptBytes: number): FusionBudgetPlanV1 {
353
- const bytes = Buffer.byteLength(canonicalInputSerialized, 'utf8') + systemPromptBytes;
354
- const tokens = fusionTokenUpperBound(bytes);
355
- const base: FusionStageBudgetPlanEntry = {
356
- budget_stage: 'candidate',
357
- measurement_kind: 'worst_case_envelope',
358
- measured_utf8_bytes: bytes,
359
- measured_input_tokens_upper_bound: tokens,
360
- allowed_input_tokens: this.allowedCanonicalInputTokens,
361
- limiting_qualified_id: this.limiting.qualified_id,
362
- slack_tokens: this.allowedCanonicalInputTokens - tokens,
614
+ assertStagePrompt(
615
+ stage: FusionBudgetStage,
616
+ systemPrompt: string,
617
+ userPrompt: string,
618
+ slot?: 1 | 2 | 3,
619
+ ): void {
620
+ const route = this.routeForStage(stage, slot);
621
+ const forecastUtf8Bytes = utf8Bytes(systemPrompt) + utf8Bytes(userPrompt);
622
+ const tokens = fusionTokenUpperBound(forecastUtf8Bytes);
623
+ if (tokens <= route.allowed_input_tokens) return;
624
+ const entry: FusionStageBudgetPlanEntry = {
625
+ budget_stage: stage,
626
+ route,
627
+ conditional: stage === 'evaluation_repair',
628
+ forecast_utf8_bytes: forecastUtf8Bytes,
629
+ forecast_input_tokens_upper_bound: tokens,
630
+ allowed_input_tokens: route.allowed_input_tokens,
631
+ signed_headroom_tokens: route.allowed_input_tokens - tokens,
632
+ utilization: tokens / route.allowed_input_tokens,
633
+ fits: false,
363
634
  };
364
- return {
635
+ if (slot !== undefined) entry.slot = slot;
636
+ const blocker = blockerFromEntry(entry);
637
+ const plan: FusionBudgetPlanV1 = {
365
638
  schema_version: FUSION_BUDGET_PLAN_SCHEMA_VERSION,
366
639
  policy: FUSION_BUDGET_POLICY,
367
640
  routes: this.routes,
368
- limiting_qualified_id: this.limiting.qualified_id,
369
- base_context: base,
641
+ stages: [entry],
642
+ blockers: [blocker],
643
+ primary_blocker: blocker,
644
+ empty_request: {
645
+ request_utf8_bytes: 0,
646
+ still_fails_with_empty_request: true,
647
+ shortening_request_can_help: false,
648
+ minimum_request_byte_reduction: forecastUtf8Bytes - route.allowed_input_tokens * FUSION_BYTES_PER_TOKEN_DIVISOR,
649
+ maximum_safe_request_utf8_bytes: 0,
650
+ blockers_with_empty_request: [blocker],
651
+ },
652
+ warnings: [],
370
653
  };
654
+ throw this.failure(blocker, plan, 'stage prompt re-measurement', 'rendered_prompt');
371
655
  }
372
656
  }