/** * vector-cortex/planner/portfolio.ts — budgeted 0/1 portfolio planner * (VC5A, tasks 3 + 4). * * ORDER OF OPERATIONS IS THE CONTRACT (CONTRACTS §plan and closure): * * 1. Compute the MANDATORY cost first — the VC4C content-only closure estimate * PLUS this sprint's framing (VC5A exclusively owns framing + budget). * 2. If that alone exceeds `tokenBudget`, return * `MANDATORY_CLOSURE_OVER_BUDGET` WITHOUT dropping evidence: the mandatory * ids and their framed cost are returned intact so the caller can report * exactly what did not fit, and the adapter demotes to C. * 3. Only then do optional candidates compete for the REMAINING budget, via a * 0/1 (take-it-or-leave-it, no fractions, no splitting) selection ordered by * utility-per-framed-token DESC, then source seq ASC, then id bytes ASC. * * The selection never exceeds the budget: each candidate is admitted only if it * fits in what remains. This is a deliberate greedy-by-ratio admission rather * than an exact knapsack solve — it is O(n log n), total-ordered, and therefore * reproducible across 1,000 runs, which the sprint requires. It is documented as * ratio-greedy rather than claimed optimal. * * Pure/deterministic: no storage, no console, no network (PREVENT-PI-004). */ import type { FramingProfile, PlanCandidate, PlanOmission, PlanResult, PlanV1, } from "./types.js"; import { DEFAULT_FRAMING } from "./types.js"; /** The framed cost of one candidate: content tokens + its per-node envelope. */ export function framedCost(candidate: PlanCandidate, framing: FramingProfile): number { return candidate.tokenEstimate + framing.perNode; } /** * The framed cost of the whole mandatory set: the VC4C CONTENT-ONLY estimate, * plus one node envelope per mandatory node, plus the fixed whole-prompt * overhead. This function is the single place VC4C's content estimate becomes a * budgeted cost — VC4C itself never reasons about framing or budget. */ export function mandatoryFramedCost( mandatoryTokenEstimate: number, mandatoryCount: number, framing: FramingProfile, ): number { return mandatoryTokenEstimate + framing.perNode * mandatoryCount + framing.overhead; } /** * Total order for the 0/1 portfolio: utility-per-framed-token DESC, then source * seq ASC, then id bytes ASC. The two tie-breaks make the order TOTAL, so equal * ratios never depend on input order (the PLN-TIE-003 assertion). */ export function compareByRatio( a: PlanCandidate, b: PlanCandidate, framing: FramingProfile, ): number { const aRatio = a.utility / framedCost(a, framing); const bRatio = b.utility / framedCost(b, framing); if (aRatio !== bRatio) return aRatio > bRatio ? -1 : 1; if (a.sourceSeq !== b.sourceSeq) return a.sourceSeq < b.sourceSeq ? -1 : 1; return a.nodeId < b.nodeId ? -1 : a.nodeId > b.nodeId ? 1 : 0; } /** Sorted copy (bytewise) — every plan output is order-stable. */ function sortedIds(ids: Iterable): string[] { return [...ids].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); } /** The inputs one planning run consumes. */ export interface PlanInput { /** Digest of the DAG being planned over (binds plan to structure). */ readonly dagDigest: string; /** Every candidate, mandatory and optional. */ readonly candidates: readonly PlanCandidate[]; /** * The VC4C CONTENT-ONLY mandatory token estimate, handed over unchanged. * Framing is added here; VC4C never truncates and never sees a budget. */ readonly mandatoryTokenEstimate: number; /** The hard token budget the framed plan must not exceed. */ readonly tokenBudget: number; /** Durable authority high-water the evidence depends on. */ readonly dependencyHighWater: bigint; /** Framing cost model; defaults to the conservative baseline. */ readonly framing?: FramingProfile; /** Node IDs declared mutually incompatible, as `ab` pair keys. */ readonly incompatiblePairs?: readonly (readonly [string, string])[]; } /** Canonical key for an unordered incompatibility pair. */ function pairKey(a: string, b: string): string { return a < b ? `${a}${b}` : `${b}${a}`; } /** * Run the budgeted 0/1 portfolio (tasks 3 + 4). * * Mode A. Mandatory closure is admitted first and in full; optional candidates * then compete for the remainder in ratio order. The returned plan is CLOSED * (every mandatory node present), DETERMINISTIC (total ordering throughout), and * its framed `tokenTotal` is guaranteed `<= tokenBudget`. */ export function planPortfolio(input: PlanInput): PlanResult { const framing = input.framing ?? DEFAULT_FRAMING; const { tokenBudget } = input; const mandatory = input.candidates.filter((c) => c.mandatory); const mandatoryIds = sortedIds(mandatory.map((c) => c.nodeId)); // A malformed budget is rejected before any selection is attempted. if (!Number.isFinite(tokenBudget) || tokenBudget < 0) { return { ok: false, code: "PLN_INVALID_BUDGET", mandatory: mandatoryIds, mandatoryCost: 0, tokenBudget, }; } // ── 1. Mandatory cost FIRST, with framing added (VC5A owns admission) ────── const mandatoryCost = mandatoryFramedCost( input.mandatoryTokenEstimate, mandatory.length, framing, ); // ── 2. Over budget ⇒ demote, WITHOUT dropping evidence ───────────────────── if (mandatoryCost > tokenBudget) { return { ok: false, code: "MANDATORY_CLOSURE_OVER_BUDGET", // Evidence intact: the caller learns exactly which nodes did not fit. mandatory: mandatoryIds, mandatoryCost, tokenBudget, }; } // ── 3. Optional candidates compete for the REMAINING budget ──────────────── const incompatible = new Set(); for (const [a, b] of input.incompatiblePairs ?? []) incompatible.add(pairKey(a, b)); const selected = new Set(mandatoryIds); const omissions: PlanOmission[] = []; let tokenTotal = mandatoryCost; let utilityTotal = mandatory.reduce((sum, c) => sum + c.utility, 0); const optional = input.candidates .filter((c) => !c.mandatory) .sort((a, b) => compareByRatio(a, b, framing)); for (const candidate of optional) { // A zero/negative-utility candidate never earns budget. if (candidate.utility <= 0) { omissions.push({ nodeId: candidate.nodeId, reason: "zero-utility" }); continue; } // Mutual exclusion against anything already selected. let blocked = false; for (const chosen of selected) { if (incompatible.has(pairKey(candidate.nodeId, chosen))) { blocked = true; break; } } if (blocked) { omissions.push({ nodeId: candidate.nodeId, reason: "incompatible" }); continue; } // 0/1: take it whole, or not at all — never exceed the budget. const cost = framedCost(candidate, framing); if (tokenTotal + cost > tokenBudget) { omissions.push({ nodeId: candidate.nodeId, reason: "over-budget" }); continue; } selected.add(candidate.nodeId); tokenTotal += cost; utilityTotal += candidate.utility; } const plan: PlanV1 = { schema: "plan-v1", dagDigest: input.dagDigest, selectedNodeIds: sortedIds(selected), tokenBudget, tokenTotal, utilityTotal, dependencyHighWater: input.dependencyHighWater, // Omissions sorted for a stable manifest. omissions: [...omissions].sort((a, b) => a.nodeId < b.nodeId ? -1 : a.nodeId > b.nodeId ? 1 : 0, ), }; return { ok: true, plan }; } /** * Mode B: a STABLE GREEDY CLOSED planner, forced by an exception in A. * * Independent of A by construction (TRIAD_RESILIENCE: A/B must not share an * algorithm): B never computes a utility ratio and never consults utility for * ordering. It admits the mandatory closure, then walks the optional candidates * strictly in SOURCE ORDER (seq, then id bytes) taking whatever fits. It is * therefore a different selection rule reachable without A's scoring path — a * genuinely separate fallback, not a re-run of A. */ export function planGreedyClosed(input: PlanInput): PlanResult { const framing = input.framing ?? DEFAULT_FRAMING; const { tokenBudget } = input; const mandatory = input.candidates.filter((c) => c.mandatory); const mandatoryIds = sortedIds(mandatory.map((c) => c.nodeId)); if (!Number.isFinite(tokenBudget) || tokenBudget < 0) { return { ok: false, code: "PLN_INVALID_BUDGET", mandatory: mandatoryIds, mandatoryCost: 0, tokenBudget, }; } const mandatoryCost = mandatoryFramedCost( input.mandatoryTokenEstimate, mandatory.length, framing, ); if (mandatoryCost > tokenBudget) { return { ok: false, code: "MANDATORY_CLOSURE_OVER_BUDGET", mandatory: mandatoryIds, mandatoryCost, tokenBudget, }; } const selected = new Set(mandatoryIds); const omissions: PlanOmission[] = []; let tokenTotal = mandatoryCost; let utilityTotal = mandatory.reduce((sum, c) => sum + c.utility, 0); // SOURCE ORDER — no ratio anywhere in this path. const optional = input.candidates .filter((c) => !c.mandatory) .sort((a, b) => { if (a.sourceSeq !== b.sourceSeq) return a.sourceSeq < b.sourceSeq ? -1 : 1; return a.nodeId < b.nodeId ? -1 : a.nodeId > b.nodeId ? 1 : 0; }); for (const candidate of optional) { const cost = framedCost(candidate, framing); if (tokenTotal + cost > tokenBudget) { omissions.push({ nodeId: candidate.nodeId, reason: "over-budget" }); continue; } selected.add(candidate.nodeId); tokenTotal += cost; utilityTotal += candidate.utility; } return { ok: true, plan: { schema: "plan-v1", dagDigest: input.dagDigest, selectedNodeIds: sortedIds(selected), tokenBudget, tokenTotal, utilityTotal, dependencyHighWater: input.dependencyHighWater, omissions: [...omissions].sort((a, b) => a.nodeId < b.nodeId ? -1 : a.nodeId > b.nodeId ? 1 : 0, ), }, }; } // Plan IDENTITY + pre-provider revalidation live in ./manifest.ts (one concern // per file). Re-exported here so consumers have a single planner entry point. export { planManifestDigest, validatePlanManifest } from "./manifest.js"; export type { PlanManifestValidation } from "./manifest.js";