Files
msd-core/src/prompt-budget.cts
Tom Boucher c899f5ada3 chore(#3065): build the deterministic load-bearing-fragment contract gate (#3068)
* test(#3065): build the load-bearing contract gate ADR-1671 promised

Epic #1671 Phase 7. A post-merge audit of every promise in ADR-1671 against the
merged tree found one mitigation asserted-but-absent and two stale records.

ADR-1671 names exactly one correctness risk — trimming a load-bearing fragment,
with the recorded history of a paraphrased META.RULE causing agent violations —
and #2931 amended its mitigation to a deterministic contract gate that proves no
load-bearing fragment was omitted or shrunk, treats a floored fragment as a
success, and asserts the isolate prefix survives byte-identical, with an explicit
anti-vacuity rule.

That gate did not exist. What existed was tests/context-composer.test.cjs:
synthetic unit tests of the composeWithinBudget primitive over invented
fragments, asserting nothing about real declared strategies. The ADR asserted a
mitigation that was never built, which is the promised-but-not-built shape the
epic's own coverage discipline exists to catch.

The gate derives its load-bearing set from declared verbatim strategies rather
than a hand-maintained list, so it cannot go stale as upstream changes. It sweeps
budgets from 4x total down to a quarter of total and asserts at every step that
no load-bearing id appears in omitted or shrunk, that isolatePrefix is
byte-identical, and that hardFailed is surfaced rather than silently passed.

Both anti-vacuity guards are EXECUTABLE, not comments. One proves the empty
load-bearing set guard actually throws. The other proves a sweep that never
applies pressure is rejected — because a gate that only ever runs unpressured is
exactly how the original mitigation went missing without anyone noticing.
Measured: underPressure true at 6 of 7 budgets, false only at 4x total.

Three ADR records corrected in the same change, all doc-vs-reality drift:

  - Decision item 2 describes a composer that trims by priority to fit a measured
    per-runtime cap. composeWorkflow in fact passes MAX_SAFE_INTEGER with every
    fragment verbatim (both verified in source), so no trimming happens there;
    the emitted-byte cap is a separate measure-and-fail gate and Windsurf's limit
    a bespoke truncation. The wording described an option as shipped behavior.
  - flag:--converge never reached a terminal state. #2992 withheld six atoms;
    five were resolved explicitly. This one was resolved in code by reusing
    state:plan-strategy-converge but recorded nowhere — the same gap #2995 closed
    for flag:--verify-only, and I closed five of six.
  - The open-questions list enumerated three questions while two Resolved-by
    blocks resolved an unlisted Question 4. It is now listed.

Refs #3065

* fix(#3065): make the gate assert over production, not a copy of it

The isolated review found a blocker, and it was fatal to the gate's purpose: it
hand-copied applyBudget's fragment array into the test, so flipping a strategy in
src/prompt-budget.cts — say roadmap from verbatim to drop — would leave the gate
computing from its own untouched copy and still passing. A guard built as an
instance of the very divergence class it exists to prevent
(DEFECT.GENERATIVE-FIX) is worse than no guard, because it reports green.

Fixed by eliminating the duplicate rather than adding a parity assertion, the
same resolution used for the FAMILIES table in #2996. applyBudget's inline
construction is extracted to an exported buildBudgetFragments(), which both
applyBudget and the gate now call; the 1024 plan floor is exported as
PLAN_FLOOR_CHARS instead of being re-declared in the test. The extraction is pure
— verified behavior-preserving at budget=2000: hardFailed false, omitted
['context'], projectMd shrunk, plan truncation ~27.8%, all headers present. There
is no longer a second copy to diverge from.

Also fixed a vacuous assertion the same review caught: isolatePrefix was pinned
across the sweep, but no production fragment sets isolate:true, so the value is
always '' and the check could never fail. The pinning assertion stays, with an
honest comment that nothing in production sets it today, and a second test now
constructs an isolate:true fragment set and proves the prefix is non-empty and
byte-identical across a roomy and a severely tight budget — which is what makes
the first assertion capable of detecting a real change.

Refs #3065

* chore(#3065): backfill changeset pr number to 3068

---------

Co-authored-by: sim <sim@local>
2026-08-04 22:41:01 -04:00

376 lines
12 KiB
TypeScript

/**
* prompt-budget.cts
*
* Pure functions for assembling and trimming review prompts to fit within
* a token budget (ADR-457 build-at-publish: the hand-written
* bin/lib/prompt-budget.cjs collapsed to a TypeScript source of truth).
* Behaviour is preserved byte-for-behaviour from the prior hand-written .cjs;
* only types are added.
*
* Used by the review pipeline to support small-context models.
*
* Trim priority (in order — never violate). As of #2929 (epic #1671 Phase 2)
* the ladder itself is executed by the shared `context-composer` seam
* (`composeWithinBudget`); this module builds the fragment list in this
* exact order, delegates the decision of what survives/shrinks/truncates,
* and keeps only the prompt-specific rendering (`renderNote`,
* `assemblePrompt`) and the hard-fail response shapes here:
* 1. Instructions: ALWAYS kept verbatim
* 2. Reserve note tokens FIRST when any trim is anticipated
* 3. Roadmap: ALWAYS kept verbatim
* 4. PROJECT.md: head-shrink to projectMdHeadLines (default 40) if over budget
* 5. Plans: tail-truncate proportionally; never drop a whole plan
* 6. Context: DROP first if still over
* 7. Research: DROP second if still over
* 8. Requirements: DROP last (last-resort)
* 9. Hard-fail: if minimum-set exceeds effectiveBudget
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports -- context-composer.cjs is an export= CommonJS module
import contextComposer = require('./context-composer.cjs');
const NOTE_RESERVE_TOKENS = 80;
/**
* Floor per plan when proportionally truncating and for the minimum-set
* check. Exported so consumers (notably the load-bearing contract gate,
* issue #3065) never need to re-declare this value locally.
*/
export const PLAN_FLOOR_CHARS = 1024;
const MIN_PLAN_BYTES = PLAN_FLOOR_CHARS;
const DEFAULT_NOTE_TEMPLATE = [
'<note>',
'Prompt automatically trimmed to fit a {budget}-token budget.',
'Omitted sections: {omittedList}.',
'Plan content truncated by approximately {planTruncationPct}%.',
'Treat any missing context as out-of-scope rather than a review concern.',
'</note>',
].join('\n');
/** A single plan section with its filename and content. */
export interface PlanSection {
file: string;
content: string;
}
/** Input sections for applyBudget. */
export interface PromptSections {
instructions: string;
roadmap: string;
plans: PlanSection[];
projectMd?: string | null;
context?: string | null;
research?: string | null;
requirements?: string | null;
}
/** Options for applyBudget. */
export interface BudgetOptions {
safetyMarginPct?: number;
noteTemplate?: string;
projectMdHeadLines?: number;
}
/** Metadata about the budget application result. */
export interface BudgetMetadata {
budget: number;
effectiveBudget: number;
estimatedTokens: number;
omitted: string[];
projectMdShrunk: boolean;
planTruncationPct: number;
hardFailed: boolean;
noteInjected: boolean;
}
/** Result of applyBudget. */
export interface BudgetResult {
prompt: string;
metadata: BudgetMetadata;
}
/** Input to applyBudget. */
export interface ApplyBudgetInput {
sections: PromptSections;
budget: number;
options?: BudgetOptions;
}
/**
* Estimate tokens for a string. Chars / 4, rounded up.
*/
export function estimateTokens(text: string | null | undefined): number {
if (!text) return 0;
return Math.ceil(text.length / 4);
}
/**
* Render the trim-disclosure note.
*/
function renderNote(template: string, budget: number, omitted: string[], planTruncationPct: number): string {
const omittedList = omitted.length > 0 ? omitted.join(', ') : 'none';
return template
.replace('{budget}', String(budget))
.replace('{omittedList}', omittedList)
.replace('{planTruncationPct}', String(Math.round(planTruncationPct)));
}
/**
* Assemble the final prompt string from its sections.
*/
function assemblePrompt(parts: {
instructions: string;
note: string | null;
roadmap: string;
projectMd: string | null;
plans: PlanSection[];
context: string | null;
research: string | null;
requirements: string | null;
}): string {
const {
instructions,
note,
roadmap,
projectMd,
plans,
context,
research,
requirements,
} = parts;
const blocks: string[] = [];
blocks.push(instructions);
if (note) blocks.push(note);
blocks.push('## Roadmap\n\n' + roadmap);
if (projectMd) blocks.push('## Project\n\n' + projectMd);
const planBlocks = plans
.map((p) => '### ' + p.file + '\n\n' + p.content)
.join('\n\n');
blocks.push('## Plans\n\n' + planBlocks);
if (context) blocks.push('## Context\n\n' + context);
if (research) blocks.push('## Research\n\n' + research);
if (requirements) blocks.push('## Requirements\n\n' + requirements);
return blocks.join('\n\n');
}
/**
* Build the `composeWithinBudget` fragment array for a set of review prompt
* sections, with each fragment's declared trim strategy attached.
*
* Extracted (issue #3065) so the load-bearing contract gate
* (tests/load-bearing-contract-gate.test.cjs) can assert directly over the
* REAL declared strategies used by `applyBudget`, rather than a hand-copied
* duplicate array. A duplicate would let production silently diverge from
* the gate (e.g. flipping `roadmap` from `verbatim` to `drop` would keep the
* gate green if it read from a copy) — exactly the
* `DEFECT.GENERATIVE-FIX` divergence class this extraction exists to
* prevent. Pure; performs no I/O and has no side effects.
*/
export function buildBudgetFragments(
sections: PromptSections,
projectMdHeadLines: number
): contextComposer.Fragment[] {
const {
instructions,
roadmap,
plans,
projectMd: projectMdRaw = null,
context: contextRaw = null,
research: researchRaw = null,
requirements: requirementsRaw = null,
} = sections;
// Unique per-plan fragment ids: plain `plan:<file>` unless a filename
// collision forces disambiguation by index — composeWithinBudget throws on
// duplicate ids, and legitimate input may repeat a filename across plans.
const usedPlanIds = new Set<string>();
const planIds: string[] = plans.map((p, idx) => {
let id = `plan:${p.file}`;
if (usedPlanIds.has(id)) id = `plan:${idx}:${p.file}`;
usedPlanIds.add(id);
return id;
});
const planFragments: contextComposer.Fragment[] = plans.map((p, idx) => ({
id: planIds[idx],
content: p.content,
wrapper: `### ${p.file}\n\n`,
strategy: { kind: 'proportional-truncate', floorChars: MIN_PLAN_BYTES },
group: 'plans',
required: true,
}));
return [
{ id: 'instructions', content: instructions, wrapper: '', strategy: { kind: 'verbatim' }, required: true },
{ id: 'roadmap', content: roadmap, wrapper: '## Roadmap\n\n', strategy: { kind: 'verbatim' }, required: true },
{
id: 'projectMd',
content: projectMdRaw ?? '',
wrapper: '## Project\n\n',
strategy: { kind: 'head-shrink', maxLines: projectMdHeadLines },
},
{ id: 'plans-header', content: '', wrapper: '## Plans\n\n', strategy: { kind: 'verbatim' }, required: true },
...planFragments,
{ id: 'context', content: contextRaw ?? '', wrapper: '## Context\n\n', strategy: { kind: 'drop' } },
{ id: 'research', content: researchRaw ?? '', wrapper: '## Research\n\n', strategy: { kind: 'drop' } },
{ id: 'requirements', content: requirementsRaw ?? '', wrapper: '## Requirements\n\n', strategy: { kind: 'drop' } },
];
}
/**
* Apply a token budget to a set of review prompt sections.
* Returns the trimmed prompt and structured metadata.
*/
export function applyBudget({ sections, budget, options = {} }: ApplyBudgetInput): BudgetResult {
const {
safetyMarginPct = 10,
noteTemplate = DEFAULT_NOTE_TEMPLATE,
projectMdHeadLines = 40,
} = options;
const { plans } = sections;
const fragments: contextComposer.Fragment[] = buildBudgetFragments(sections, projectMdHeadLines);
// Recover the per-plan fragment ids buildBudgetFragments assigned (in
// `plans` declaration order) rather than re-deriving the id-collision
// logic here — a single source of truth for id assignment.
const planIds: string[] = fragments.filter((f) => f.group === 'plans').map((f) => f.id);
const composed = contextComposer.composeWithinBudget({
fragments,
budget,
measure: estimateTokens,
options: {
safetyMarginPct,
reserve: NOTE_RESERVE_TOKENS,
charsPerUnit: 4,
// Minimum-set floor: instructions + roadmap (in full) + 1KB per plan.
// NOTE_RESERVE_TOKENS is intentionally excluded — a note is only
// injected when trimming actually occurs, and a prompt that fits
// without any trim needs no note at all. Wrappers are also excluded
// (this checks the floor, not the steady-state budget).
minimumFor: (f) => {
if (f.id === 'instructions' || f.id === 'roadmap') return f.content;
if (f.id.startsWith('plan:')) return f.content.slice(0, MIN_PLAN_BYTES);
return null;
},
},
});
const effectiveBudget = composed.metadata.effectiveBudget;
if (composed.metadata.hardFailed) {
return {
prompt: '',
metadata: {
budget,
effectiveBudget,
estimatedTokens: 0,
omitted: [],
projectMdShrunk: false,
planTruncationPct: 0,
hardFailed: true,
noteInjected: false,
},
};
}
const byId = new Map(composed.fragments.map((f) => [f.id, f]));
const get = (id: string): contextComposer.ComposedFragment => {
const found = byId.get(id);
if (!found) throw new Error(`applyBudget: missing composed fragment "${id}"`);
return found;
};
const instructionsOut = get('instructions').content;
const roadmapOut = get('roadmap').content;
const projectMdFragment = get('projectMd');
const projectMd: string | null = projectMdFragment.present ? projectMdFragment.content : null;
const contextFragment = get('context');
const context: string | null = contextFragment.present ? contextFragment.content : null;
const researchFragment = get('research');
const research: string | null = researchFragment.present ? researchFragment.content : null;
const requirementsFragment = get('requirements');
const requirements: string | null = requirementsFragment.present ? requirementsFragment.content : null;
// Plans are never dropped — only their content may be truncated — so map
// deliberately by original index/file rather than relying on `present`.
const workingPlans: PlanSection[] = plans.map((p, idx) => ({
file: p.file,
content: get(planIds[idx]).content,
}));
const omitted = composed.metadata.omitted;
const projectMdShrunk = composed.metadata.shrunk.includes('projectMd');
const planTruncationPct = composed.metadata.truncationPct;
// ── Decide whether note is actually needed ────────────────────────────────
const anyTrimOccurred =
omitted.length > 0 || projectMdShrunk || planTruncationPct > 0;
let note: string | null = null;
let noteInjected = false;
if (anyTrimOccurred) {
note = renderNote(noteTemplate, budget, omitted, planTruncationPct);
noteInjected = true;
}
// ── Assemble ──────────────────────────────────────────────────────────────
const prompt = assemblePrompt({
instructions: instructionsOut,
note,
roadmap: roadmapOut,
projectMd,
plans: workingPlans,
context,
research,
requirements,
});
const estimatedTokens = estimateTokens(prompt);
if (estimatedTokens > effectiveBudget) {
return {
prompt: '',
metadata: {
budget,
effectiveBudget,
estimatedTokens,
omitted,
projectMdShrunk,
planTruncationPct,
hardFailed: true,
noteInjected,
},
};
}
return {
prompt,
metadata: {
budget,
effectiveBudget,
estimatedTokens,
omitted,
projectMdShrunk,
planTruncationPct,
hardFailed: false,
noteInjected,
},
};
}