From 8576b2199ad9fe6ee09a8c4ab98d5cb0ed574eae Mon Sep 17 00:00:00 2001 From: cryptoxdog <31744795+cryptoxdog@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:23:18 -0400 Subject: [PATCH 1/5] pec: TASK-001 output --- docs/program-execution/TASK-001.md | 32 +++++++++++++++++++ src/index.ts | 18 +++++------ src/matrices/capabilities.ts | 34 +++++++++++++++++++++ src/types.ts | 15 +++++++++ tests/capabilities.test.ts | 49 ++++++++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 docs/program-execution/TASK-001.md create mode 100644 src/matrices/capabilities.ts create mode 100644 tests/capabilities.test.ts diff --git a/docs/program-execution/TASK-001.md b/docs/program-execution/TASK-001.md new file mode 100644 index 0000000..d1ac8b6 --- /dev/null +++ b/docs/program-execution/TASK-001.md @@ -0,0 +1,32 @@ +# TASK-001 — Canonical capability resolver + +Campaign: `pe-router-capability-safety` · Base SHA `14c7b83` · Branch `pec/w0/task-001` + +## What changed + +- `src/types.ts` — added `ResolvedCapabilities` (searchRequired, searchPolicySource, + visionRequired) next to `SearchPolicyResolution`, so the resolver's output is a + first-class shared type. +- `src/matrices/capabilities.ts` (new) — `VISION_TASKS` exported as the single + authority for vision task types (VISUAL_QA, SCREENSHOT_ANALYSIS, + LAYOUT_VALIDATION) and the canonical `resolveCapabilities(task)` resolver. + Search truth delegates to `resolveSearchPolicy` — exactly one implementation of + the search rule; `searchPolicySource` preserves the existing EXPLICIT / + TASK_DEFAULT enum shape. +- `src/index.ts` — `resolveRoute` now consumes `resolveCapabilities` instead of + re-deriving the search policy locally; the private `VISION_TASKS` constant was + removed in favor of the exported one. The fail-closed search+vision guard and + the dispatch behavior are unchanged. +- `tests/capabilities.test.ts` (new) — focused coverage: explicit true/false + flags report EXPLICIT source, undefined flags report TASK_DEFAULT with the + TaskType default, exactly the vision types are vision-required, and a vision + task with an explicit search=false keeps both truths distinct. + +## Validation (run on the finished tree) + +- `npm run verify:types` — PASS (tsc --noEmit) +- `npm test` — PASS (23 files, 165 tests) +- `npm run lint` — PASS (eslint src/) + +Existing search-policy tests and routing behavior are unchanged: the resolver +reuses `resolveSearchPolicy` and the same guard fires in the same cases. diff --git a/src/index.ts b/src/index.ts index 0e860b8..2289712 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,8 @@ import { import { CircuitBreaker, CircuitOpenError, type CircuitPermit } from './circuit-breaker.js'; import { resolveGeneralConfig, getFallbackChain } from './matrices/general-matrix.js'; import { resolvePerplexityConfig } from './matrices/perplexity-matrix.js'; -import { resolveSearchPolicy, UnsupportedCapabilityCombinationError } from './matrices/search-policy.js'; +import { UnsupportedCapabilityCombinationError } from './matrices/search-policy.js'; +import { resolveCapabilities, VISION_TASKS } from './matrices/capabilities.js'; import { classifyProviderError, isCircuitFailure } from './provider-errors.js'; import { OpenRouterClient, validateImageUrl, type OpenRouterClientLike } from './providers/openrouter.js'; import { PerplexityClient, type PerplexityClientLike } from './providers/perplexity.js'; @@ -19,18 +20,16 @@ import { GeneralModel, Provider, SonarModel, - TaskType, type BudgetConfig, type LLMResponse, type RouterConfig, type RoutingDecision, type RoutingResolution, type TaskDescriptor, + type TaskType, } from './types.js'; import { generateFullSiteQAPlan, resolveVisionConfig, VIEWPORTS, type FullSiteQAConfig, type VisualQATask } from './vision/index.js'; -const VISION_TASKS = new Set([TaskType.VISUAL_QA, TaskType.SCREENSHOT_ANALYSIS, TaskType.LAYOUT_VALIDATION]); - export interface RouterDependencies { clock?: () => Date; idFactory?: () => string; @@ -41,25 +40,26 @@ export interface RouterDependencies { } export function resolveRoute(task: TaskDescriptor): RoutingResolution { - const policy = resolveSearchPolicy(task); - const audit = { taskType: task.type, complexity: task.complexity, searchRequired: policy.required, searchPolicySource: policy.source }; + const capabilities = resolveCapabilities(task); + const { searchRequired, searchPolicySource, visionRequired } = capabilities; + const audit = { taskType: task.type, complexity: task.complexity, searchRequired, searchPolicySource }; const imageCount = task.images?.length ?? 0; // Fail closed before either capability can be silently discarded. The search // plane has no multimodal transport, so a visual task carrying images cannot // also be answered by web search. - if (policy.required && VISION_TASKS.has(task.type) && imageCount > 0) { + if (searchRequired && visionRequired && imageCount > 0) { throw new UnsupportedCapabilityCombinationError( `Task[${task.type}] supplied ${imageCount} image(s) and requires search, but no provider in this router serves search and vision together. Split the work into a vision task and a search task.`, { taskType: task.type, searchRequired: true, imageCount }, ); } - if (policy.required) { + if (searchRequired) { const config = resolvePerplexityConfig(task); return { ...audit, provider: Provider.PERPLEXITY, model: config.model, estimatedCost: config.estimatedCostPerCall, reason: config.resolutionReason }; } - if (VISION_TASKS.has(task.type)) { + if (visionRequired) { const config = resolveVisionConfig(task.type as TaskType.VISUAL_QA | TaskType.SCREENSHOT_ANALYSIS | TaskType.LAYOUT_VALIDATION, task.complexity, task.images?.length ?? 1); return { ...audit, provider: Provider.OPENROUTER, model: config.model, estimatedCost: config.estimatedCostPerCall, reason: config.resolutionReason }; } diff --git a/src/matrices/capabilities.ts b/src/matrices/capabilities.ts new file mode 100644 index 0000000..4c675a1 --- /dev/null +++ b/src/matrices/capabilities.ts @@ -0,0 +1,34 @@ +import { TaskType, type ResolvedCapabilities, type TaskDescriptor } from '../types.js'; +import { resolveSearchPolicy } from './search-policy.js'; + +/** + * Task types whose images are consumed by the OpenRouter vision branch. + * + * This is the single authority for `visionRequired`; it was previously a + * private constant inside the router class file and is now exported so the + * canonical capability resolver and the dispatch code share one definition. + */ +export const VISION_TASKS = new Set([ + TaskType.VISUAL_QA, + TaskType.SCREENSHOT_ANALYSIS, + TaskType.LAYOUT_VALIDATION, +]); + +/** + * Canonical capability resolver — one source of truth for what a task needs. + * + * `searchRequired` and `searchPolicySource` delegate to the search-policy + * resolver (`resolveSearchPolicy`), so there is exactly one implementation of + * the search rule. `visionRequired` is true exactly for the vision task types. + * + * Routing, failure semantics, and the audit trail all consume this shape so a + * decision reports the same capabilities everywhere without re-deriving them. + */ +export function resolveCapabilities(task: TaskDescriptor): ResolvedCapabilities { + const policy = resolveSearchPolicy(task); + return { + searchRequired: policy.required, + searchPolicySource: policy.source, + visionRequired: VISION_TASKS.has(task.type), + }; +} diff --git a/src/types.ts b/src/types.ts index b5154d7..860bed8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,6 +46,21 @@ export interface SearchPolicyResolution { source: SearchPolicySource; } +/** + * The router's resolved view of the capabilities a task requests. + * + * One canonical resolver (`resolveCapabilities`) derives this so routing, + * failure semantics, and the audit trail all read the same truth: + * `searchRequired` and `searchPolicySource` reuse the search-policy resolver, + * and `visionRequired` is true exactly for the task types whose images the + * OpenRouter vision branch consumes. + */ +export interface ResolvedCapabilities { + searchRequired: boolean; + searchPolicySource: SearchPolicySource; + visionRequired: boolean; +} + export enum SearchContextSize { LOW = 'low', MEDIUM = 'medium', HIGH = 'high' } export enum SearchMode { WEB = 'web', ACADEMIC = 'academic', SEC = 'sec' } export enum RecencyFilter { HOUR = 'hour', DAY = 'day', WEEK = 'week', MONTH = 'month', YEAR = 'year', NONE = 'none' } diff --git a/tests/capabilities.test.ts b/tests/capabilities.test.ts new file mode 100644 index 0000000..a4d4d36 --- /dev/null +++ b/tests/capabilities.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { resolveCapabilities, VISION_TASKS } from '../src/matrices/capabilities.js'; +import { SearchPolicySource, TaskType, type TaskDescriptor } from '../src/types.js'; + +function task(overrides: Partial = {}): TaskDescriptor { + return { type: TaskType.STRATEGIC_REASONING, complexity: 'fast', ...overrides }; +} + +describe('resolveCapabilities', () => { + it('derives search truth from the explicit flag and reports the EXPLICIT source', () => { + expect(resolveCapabilities(task({ requiresSearch: true }))).toEqual({ + searchRequired: true, + searchPolicySource: SearchPolicySource.EXPLICIT, + visionRequired: false, + }); + expect(resolveCapabilities(task({ requiresSearch: false }))).toEqual({ + searchRequired: false, + searchPolicySource: SearchPolicySource.EXPLICIT, + visionRequired: false, + }); + }); + + it('falls back to the TaskType default and reports TASK_DEFAULT when the flag is undefined', () => { + expect(resolveCapabilities(task({ type: TaskType.MARKET_RESEARCH }))).toEqual({ + searchRequired: true, + searchPolicySource: SearchPolicySource.TASK_DEFAULT, + visionRequired: false, + }); + expect(resolveCapabilities(task({ type: TaskType.CONTENT_GENERATION }))).toEqual({ + searchRequired: false, + searchPolicySource: SearchPolicySource.TASK_DEFAULT, + visionRequired: false, + }); + }); + + it('marks exactly the vision task types as vision-required', () => { + for (const type of VISION_TASKS) { + expect(resolveCapabilities(task({ type })).visionRequired).toBe(true); + } + expect(resolveCapabilities(task({ type: TaskType.STRATEGIC_REASONING })).visionRequired).toBe(false); + }); + + it('does not conflate a vision task with a search requirement', () => { + const resolved = resolveCapabilities(task({ type: TaskType.SCREENSHOT_ANALYSIS, requiresSearch: false })); + expect(resolved.visionRequired).toBe(true); + expect(resolved.searchRequired).toBe(false); + expect(resolved.searchPolicySource).toBe(SearchPolicySource.EXPLICIT); + }); +}); From c8766ef9ab333174a2bf79581f43a8da9a94378b Mon Sep 17 00:00:00 2001 From: cryptoxdog <31744795+cryptoxdog@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:26:39 -0400 Subject: [PATCH 2/5] pec: TASK-002 output --- docs/program-execution/TASK-002.md | 36 +++++++++++++++++++ src/index.ts | 23 ++++++------ src/matrices/capabilities.ts | 52 +++++++++++++++++++++++++++- tests/routing-matrix.test.ts | 27 +++++++++++++-- tests/search-policy-dispatch.test.ts | 28 +++++++++------ 5 files changed, 139 insertions(+), 27 deletions(-) create mode 100644 docs/program-execution/TASK-002.md diff --git a/docs/program-execution/TASK-002.md b/docs/program-execution/TASK-002.md new file mode 100644 index 0000000..a1ccc40 --- /dev/null +++ b/docs/program-execution/TASK-002.md @@ -0,0 +1,36 @@ +# TASK-002 — Fail unsupported capability combinations + +Campaign: `pe-router-capability-safety-v4` · Stacked on TASK-001 + +## What changed + +- `src/matrices/capabilities.ts` — added `VisionInputRequiredError` + (code `VISION_INPUT_REQUIRED`) and `assertSupportedCapabilities(task, + capabilities)`, which refuses before any reservation, circuit permit, or + provider dispatch: + - visionRequired + searchRequired → `UNSUPPORTED_CAPABILITY_COMBINATION` + (no provider serves search and vision together); + - images on a non-vision task type → `UNSUPPORTED_CAPABILITY_COMBINATION` + (images are only consumed by the vision branch); + - a vision task with no images → `VISION_INPUT_REQUIRED`. +- `src/index.ts` — `resolveRoute` now calls `assertSupportedCapabilities` + instead of the narrower images-present guard; `dispatchProvider` gained the + vision dispatch invariant (a vision task must dispatch on the OpenRouter + vision plane, never Perplexity or the general path). +- `tests/routing-matrix.test.ts` — matrix rows added: screenshot analysis + without images fails closed, content generation with images fails closed, + visual QA with images routes to vision; FAIL_CLOSED assertions now check the + failure code (`UNSUPPORTED_CAPABILITY_COMBINATION` or + `VISION_INPUT_REQUIRED`). +- `tests/search-policy-dispatch.test.ts` — updated the two legacy cases the + contract supersedes: a visual task with no images is now refused + (`VISION_INPUT_REQUIRED`) instead of routing with a defaulted image count, + and a visual task with no images plus explicit search is now an + `UNSUPPORTED_CAPABILITY_COMBINATION` instead of a plain search request; the + image-count regression guard now asserts the no-image refusal explicitly. + +## Validation (run on the finished tree) + +- `npm run verify:types` — PASS +- `npm test` — PASS (23 files, 169 tests) +- `npm run lint` — PASS diff --git a/src/index.ts b/src/index.ts index 2289712..cc76340 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,8 +10,7 @@ import { import { CircuitBreaker, CircuitOpenError, type CircuitPermit } from './circuit-breaker.js'; import { resolveGeneralConfig, getFallbackChain } from './matrices/general-matrix.js'; import { resolvePerplexityConfig } from './matrices/perplexity-matrix.js'; -import { UnsupportedCapabilityCombinationError } from './matrices/search-policy.js'; -import { resolveCapabilities, VISION_TASKS } from './matrices/capabilities.js'; +import { assertSupportedCapabilities, resolveCapabilities, VISION_TASKS } from './matrices/capabilities.js'; import { classifyProviderError, isCircuitFailure } from './provider-errors.js'; import { OpenRouterClient, validateImageUrl, type OpenRouterClientLike } from './providers/openrouter.js'; import { PerplexityClient, type PerplexityClientLike } from './providers/perplexity.js'; @@ -43,17 +42,11 @@ export function resolveRoute(task: TaskDescriptor): RoutingResolution { const capabilities = resolveCapabilities(task); const { searchRequired, searchPolicySource, visionRequired } = capabilities; const audit = { taskType: task.type, complexity: task.complexity, searchRequired, searchPolicySource }; - const imageCount = task.images?.length ?? 0; - // Fail closed before either capability can be silently discarded. The search - // plane has no multimodal transport, so a visual task carrying images cannot - // also be answered by web search. - if (searchRequired && visionRequired && imageCount > 0) { - throw new UnsupportedCapabilityCombinationError( - `Task[${task.type}] supplied ${imageCount} image(s) and requires search, but no provider in this router serves search and vision together. Split the work into a vision task and a search task.`, - { taskType: task.type, searchRequired: true, imageCount }, - ); - } + // Fail closed before any capability can be silently discarded: search+vision + // together, images on a non-vision task, or a vision task without images are + // caller-side contract errors refused before any reservation or dispatch. + assertSupportedCapabilities(task, capabilities); if (searchRequired) { const config = resolvePerplexityConfig(task); @@ -180,6 +173,12 @@ export class L9LLMRouter { if (decision.searchRequired !== (decision.provider === Provider.PERPLEXITY)) { throw new Error(`Routing decision searchRequired=${decision.searchRequired} disagrees with provider ${decision.provider}`); } + // A vision task must dispatch on the OpenRouter vision plane; Perplexity + // has no multimodal transport, and a vision task must never fall through + // to the general text path. + if (VISION_TASKS.has(task.type) && decision.provider !== Provider.OPENROUTER) { + throw new Error(`Vision task ${task.type} disagrees with provider ${decision.provider}`); + } if (decision.provider === Provider.PERPLEXITY) { const config = resolvePerplexityConfig(task); if (!Object.values(SonarModel).includes(decision.model as SonarModel)) throw new Error('Perplexity route resolved a non-Sonar model'); diff --git a/src/matrices/capabilities.ts b/src/matrices/capabilities.ts index 4c675a1..c36a332 100644 --- a/src/matrices/capabilities.ts +++ b/src/matrices/capabilities.ts @@ -1,5 +1,5 @@ import { TaskType, type ResolvedCapabilities, type TaskDescriptor } from '../types.js'; -import { resolveSearchPolicy } from './search-policy.js'; +import { resolveSearchPolicy, UnsupportedCapabilityCombinationError } from './search-policy.js'; /** * Task types whose images are consumed by the OpenRouter vision branch. @@ -32,3 +32,53 @@ export function resolveCapabilities(task: TaskDescriptor): ResolvedCapabilities visionRequired: VISION_TASKS.has(task.type), }; } + +/** + * Fail-closed error for a vision task that supplied no images. + * + * A visual task dispatched without images would run ordinary text completion + * on the vision plane, silently pretending the visual analysis happened. + * Refuse it before any reservation, circuit permit, or provider dispatch. + */ +export class VisionInputRequiredError extends Error { + public readonly code = 'VISION_INPUT_REQUIRED'; + constructor(public readonly taskType: TaskType) { + super(`Task[${taskType}] is a vision task but supplied no images; the vision plane cannot execute without visual input.`); + this.name = 'VisionInputRequiredError'; + } + toJSON(): Record { + return { name: this.name, code: this.code, taskType: this.taskType, message: this.message }; + } +} + +/** + * Fail unsupported capability combinations before routing. + * + * Until a governed multimodal-search provider contract exists, the router + * refuses requests it cannot honour without silently dropping a capability: + * + * - visionRequired + searchRequired — no provider serves search and vision + * together; routing one capability would discard the other. + * - images on a non-vision task type — images are only consumed by the vision + * branch, so they would be silently ignored anywhere else. + * - a vision task with no images — the vision plane cannot execute without + * visual input. + */ +export function assertSupportedCapabilities(task: TaskDescriptor, capabilities: ResolvedCapabilities): void { + const imageCount = task.images?.length ?? 0; + if (capabilities.searchRequired && capabilities.visionRequired) { + throw new UnsupportedCapabilityCombinationError( + `Task[${task.type}] requires search and vision together, but no provider in this router serves both. Split the work into a vision task and a search task.`, + { taskType: task.type, searchRequired: true, imageCount }, + ); + } + if (imageCount > 0 && !capabilities.visionRequired) { + throw new UnsupportedCapabilityCombinationError( + `Task[${task.type}] supplied ${imageCount} image(s) but is not a vision task; images are only consumed by the vision branch.`, + { taskType: task.type, searchRequired: capabilities.searchRequired, imageCount }, + ); + } + if (capabilities.visionRequired && imageCount === 0) { + throw new VisionInputRequiredError(task.type); + } +} diff --git a/tests/routing-matrix.test.ts b/tests/routing-matrix.test.ts index 36faa36..cf4a4c9 100644 --- a/tests/routing-matrix.test.ts +++ b/tests/routing-matrix.test.ts @@ -8,7 +8,7 @@ import { type GeneralModel, type TaskDescriptor, } from '../src/types.js'; -import { L9LLMRouter, resolveRoute, UnsupportedCapabilityCombinationError } from '../src/index.js'; +import { L9LLMRouter, resolveRoute } from '../src/index.js'; import { resolveGeneralConfig } from '../src/matrices/general-matrix.js'; import { resolveVisionConfig } from '../src/vision/index.js'; @@ -47,6 +47,11 @@ const MATRIX: MatrixCase[] = [ { id: 'K SCREENSHOT_ANALYSIS+imgs requiresSearch=false', task: task({ type: TaskType.SCREENSHOT_ANALYSIS, images: IMAGES, requiresSearch: false }), expected: 'VISION', expectedSource: SearchPolicySource.EXPLICIT }, { id: 'L SCREENSHOT_ANALYSIS+imgs requiresSearch=undefined', task: task({ type: TaskType.SCREENSHOT_ANALYSIS, images: IMAGES }), expected: 'VISION', expectedSource: SearchPolicySource.TASK_DEFAULT }, { id: 'M SCREENSHOT_ANALYSIS+imgs requiresSearch=true', task: task({ type: TaskType.SCREENSHOT_ANALYSIS, images: IMAGES, requiresSearch: true }), expected: 'FAIL_CLOSED', expectedSource: SearchPolicySource.EXPLICIT }, + // Capability-loss hole rows: vision without input fails, images on a + // non-vision task fail, and a vision task with images routes to vision. + { id: 'R SCREENSHOT_ANALYSIS no imgs requiresSearch=false', task: task({ type: TaskType.SCREENSHOT_ANALYSIS, requiresSearch: false }), expected: 'FAIL_CLOSED', expectedSource: SearchPolicySource.EXPLICIT }, + { id: 'S CONTENT_GENERATION+imgs requiresSearch=false', task: task({ type: TaskType.CONTENT_GENERATION, images: IMAGES, requiresSearch: false }), expected: 'FAIL_CLOSED', expectedSource: SearchPolicySource.EXPLICIT }, + { id: 'T VISUAL_QA+imgs requiresSearch=false', task: task({ type: TaskType.VISUAL_QA, images: IMAGES, requiresSearch: false }), expected: 'VISION', expectedSource: SearchPolicySource.EXPLICIT }, // Extra coverage required by §5: explicit true lifts otherwise-general task types. { id: 'N EXTRACTION requiresSearch=true', task: task({ type: TaskType.EXTRACTION, requiresSearch: true }), expected: 'SEARCH', expectedSource: SearchPolicySource.EXPLICIT }, { id: 'O CLASSIFICATION requiresSearch=true', task: task({ type: TaskType.CLASSIFICATION, requiresSearch: true }), expected: 'SEARCH', expectedSource: SearchPolicySource.EXPLICIT }, @@ -75,10 +80,26 @@ function planeOf(descriptor: TaskDescriptor): Plane { const VISION_TYPES = new Set([TaskType.VISUAL_QA, TaskType.SCREENSHOT_ANALYSIS, TaskType.LAYOUT_VALIDATION]); +const CAPABILITY_FAILURE_CODES = ['UNSUPPORTED_CAPABILITY_COMBINATION', 'VISION_INPUT_REQUIRED']; + +function expectCapabilityFailure(run: () => unknown, id?: string): void { + const thrown = (() => { + try { + run(); + return undefined; + } catch (error) { + return error; + } + })(); + expect(thrown, id).toBeInstanceOf(Error); + const code = (thrown as Error & { code?: string } | undefined)?.code; + expect(CAPABILITY_FAILURE_CODES, `${id ?? 'case'} threw code=${String(code)}`).toContain(code); +} + describe('§16 routing matrix — explicit requiresSearch is authoritative', () => { it.each(MATRIX)('$id -> $expected', ({ task: descriptor, expected }) => { if (expected === 'FAIL_CLOSED') { - expect(() => resolveRoute(descriptor)).toThrow(UnsupportedCapabilityCombinationError); + expectCapabilityFailure(() => resolveRoute(descriptor)); return; } expect(planeOf(descriptor)).toBe(expected); @@ -88,7 +109,7 @@ describe('§16 routing matrix — explicit requiresSearch is authoritative', () const router = new L9LLMRouter({ perplexityApiKey: 'p', openrouterApiKey: 'o' }, { idFactory: () => 'id', clock: () => new Date('2026-01-01T00:00:00Z') }); for (const entry of MATRIX) { if (entry.expected === 'FAIL_CLOSED') { - expect(() => router.route(entry.task), entry.id).toThrow(UnsupportedCapabilityCombinationError); + expectCapabilityFailure(() => router.route(entry.task), entry.id); continue; } const viaRouter = router.route(entry.task); diff --git a/tests/search-policy-dispatch.test.ts b/tests/search-policy-dispatch.test.ts index 644f8b6..46e3e69 100644 --- a/tests/search-policy-dispatch.test.ts +++ b/tests/search-policy-dispatch.test.ts @@ -150,25 +150,31 @@ describe('§6 search + vision fails closed instead of losing a capability', () = it('leaves vision model selection for a given image count exactly as it was', () => { const { router } = harness(); - // Regression guard: the conflict check must not perturb the image count the - // vision matrix sees, including the empty-array edge case. - for (const images of [undefined, [], ['https://cdn.example.com/a.png'], ['https://cdn.example.com/a.png', 'https://cdn.example.com/b.png']]) { + // Regression guard: the capability assertion must not perturb the image + // count the vision matrix sees. Vision tasks without images are refused + // by the capability contract, so only image-bearing cases route. + for (const images of [['https://cdn.example.com/a.png'], ['https://cdn.example.com/a.png', 'https://cdn.example.com/b.png']]) { for (const complexity of Object.values(TaskComplexity)) { const decision = router.route({ clientId: 'c', type: TaskType.SCREENSHOT_ANALYSIS, complexity, images }); - const expected = resolveVisionConfig(TaskType.SCREENSHOT_ANALYSIS, complexity, images?.length ?? 1); + const expected = resolveVisionConfig(TaskType.SCREENSHOT_ANALYSIS, complexity, images.length); expect({ model: decision.model, cost: decision.estimatedCost }).toEqual({ model: expected.model, cost: expected.estimatedCostPerCall }); } } + for (const images of [undefined, []]) { + expect(() => router.route({ clientId: 'c', type: TaskType.SCREENSHOT_ANALYSIS, complexity: TaskComplexity.MEDIUM, images })).toThrow(expect.objectContaining({ code: 'VISION_INPUT_REQUIRED' })); + } }); - it('a visual TaskType with no images and explicit search is a plain search request', async () => { + it('a visual TaskType with no images and explicit search fails as an unsupported combination', async () => { const { router, calls } = harness(); - await router.execute( - { clientId: 'c', type: TaskType.SCREENSHOT_ANALYSIS, complexity: TaskComplexity.MEDIUM, requiresSearch: true }, - 's', 'u', - ); - // Nothing visual was supplied, so nothing visual is discarded. - expect(calls.search).toHaveLength(1); + await expect( + router.execute( + { clientId: 'c', type: TaskType.SCREENSHOT_ANALYSIS, complexity: TaskComplexity.MEDIUM, requiresSearch: true }, + 's', 'u', + ), + ).rejects.toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CAPABILITY_COMBINATION' })); + // No provider plane may pretend the combined request was honoured. + expect(calls.search).toHaveLength(0); expect(calls.vision).toHaveLength(0); }); }); From 0ed9504be658cdf4e2bd7d22a8e5736a2141c7bc Mon Sep 17 00:00:00 2001 From: cryptoxdog <31744795+cryptoxdog@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:28:36 -0400 Subject: [PATCH 3/5] pec: TASK-003 output --- docs/program-execution/TASK-003.md | 25 +++++++++++++++++++++++++ src/index.ts | 2 +- src/types.ts | 2 ++ tests/routing-matrix.test.ts | 12 ++++++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 docs/program-execution/TASK-003.md diff --git a/docs/program-execution/TASK-003.md b/docs/program-execution/TASK-003.md new file mode 100644 index 0000000..46d861e --- /dev/null +++ b/docs/program-execution/TASK-003.md @@ -0,0 +1,25 @@ +# TASK-003 — Capability truth in RoutingDecision + +Campaign: `pe-router-capability-safety-v4` · Stacked on TASK-002 + +## What changed + +- `src/types.ts` — `RoutingResolution` gained `visionRequired: boolean` + (documented), inherited by `RoutingDecision`, alongside the existing + `searchRequired` and `searchPolicySource` audit fields from the merged + search-policy work. The `SearchPolicySource` enum shape is unchanged. +- `src/index.ts` — `resolveRoute` populates `visionRequired` from the + canonical `resolveCapabilities` output, so every decision reports the full + capability truth without re-deriving it at the call site. +- `tests/routing-matrix.test.ts` — added a regression test asserting + capability truth on every routing decision: a vision route reports + `visionRequired: true` with `searchRequired: false` and EXPLICIT source + (proving the SEO_CONTENT_BLUEPRINT-style audit shape without inferring + policy from the model name), a general route reports `visionRequired: + false`, and a search route reports `visionRequired: false` on Perplexity. + +## Validation (run on the finished tree) + +- `npm run verify:types` — PASS +- `npm test` — PASS +- `npm run lint` — PASS diff --git a/src/index.ts b/src/index.ts index cc76340..badcc17 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,7 +41,7 @@ export interface RouterDependencies { export function resolveRoute(task: TaskDescriptor): RoutingResolution { const capabilities = resolveCapabilities(task); const { searchRequired, searchPolicySource, visionRequired } = capabilities; - const audit = { taskType: task.type, complexity: task.complexity, searchRequired, searchPolicySource }; + const audit = { taskType: task.type, complexity: task.complexity, searchRequired, searchPolicySource, visionRequired }; // Fail closed before any capability can be silently discarded: search+vision // together, images on a non-vision task, or a vision task without images are diff --git a/src/types.ts b/src/types.ts index 860bed8..3673d53 100644 --- a/src/types.ts +++ b/src/types.ts @@ -222,6 +222,8 @@ export interface RoutingResolution { searchRequired: boolean; /** Whether `searchRequired` came from the caller or from the TaskType default. */ searchPolicySource: SearchPolicySource; + /** Whether this decision resolved to the vision-capable provider plane. */ + visionRequired: boolean; } export interface RoutingDecision extends RoutingResolution { diff --git a/tests/routing-matrix.test.ts b/tests/routing-matrix.test.ts index cf4a4c9..3b67081 100644 --- a/tests/routing-matrix.test.ts +++ b/tests/routing-matrix.test.ts @@ -160,4 +160,16 @@ describe('§17 routing audit — searchRequired and searchPolicySource', () => { expect(serialized).not.toContain('pplx-secret'); expect(serialized).not.toContain('or-secret'); }); + + it('reports capability truth on every routing decision', () => { + const router = new L9LLMRouter({ perplexityApiKey: 'p', openrouterApiKey: 'o' }, { idFactory: () => 'id', clock: () => new Date('2026-01-01T00:00:00Z') }); + const vision = router.route(task({ type: TaskType.SCREENSHOT_ANALYSIS, images: IMAGES, requiresSearch: false })); + expect(vision).toMatchObject({ provider: Provider.OPENROUTER, searchRequired: false, searchPolicySource: SearchPolicySource.EXPLICIT, visionRequired: true }); + + const general = router.route(task({ type: TaskType.CONTENT_GENERATION, requiresSearch: false })); + expect(general).toMatchObject({ provider: Provider.OPENROUTER, searchRequired: false, searchPolicySource: SearchPolicySource.EXPLICIT, visionRequired: false }); + + const search = router.route(task({ type: TaskType.STRATEGIC_REASONING, requiresSearch: true })); + expect(search).toMatchObject({ provider: Provider.PERPLEXITY, searchRequired: true, searchPolicySource: SearchPolicySource.EXPLICIT, visionRequired: false }); + }); }); From b21422b86d8f8fbc8a92d49a4853957cf42a3db0 Mon Sep 17 00:00:00 2001 From: cryptoxdog <31744795+cryptoxdog@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:19:33 -0400 Subject: [PATCH 4/5] pec: TASK-004 output --- docs/program-execution/TASK-004.md | 22 ++++++++++++++++++++++ tests/routing-matrix.test.ts | 1 + 2 files changed, 23 insertions(+) create mode 100644 docs/program-execution/TASK-004.md diff --git a/docs/program-execution/TASK-004.md b/docs/program-execution/TASK-004.md new file mode 100644 index 0000000..f827e90 --- /dev/null +++ b/docs/program-execution/TASK-004.md @@ -0,0 +1,22 @@ +# TASK-004 — Required routing matrix regression tests + +Campaign: `pe-router-capability-safety-v4` · Stacked on TASK-003 + +## What changed + +- `tests/routing-matrix.test.ts` — completed the required routing matrix + from the Website Contract: added the VISUAL_QA with **multiple** images + case (row U) routing to vision with requiresSearch=false. Together with + the rows landed in TASK-002, the matrix now covers every required row: + STRATEGIC_REASONING with false → general and true → search; + COMPETITOR_RESEARCH undefined → search and false → general; + SCREENSHOT_ANALYSIS with image+false → vision, image+true → unsupported + combination, no images → vision input required; CONTENT_GENERATION with + images → unsupported combination; VISUAL_QA with multiple images+false → + vision. All prior matrix rows stay green. + +## Validation (run on the finished tree) + +- `npm run verify:types` — PASS +- `npm test` — PASS +- `npm run lint` — PASS diff --git a/tests/routing-matrix.test.ts b/tests/routing-matrix.test.ts index 3b67081..b9a4dfb 100644 --- a/tests/routing-matrix.test.ts +++ b/tests/routing-matrix.test.ts @@ -52,6 +52,7 @@ const MATRIX: MatrixCase[] = [ { id: 'R SCREENSHOT_ANALYSIS no imgs requiresSearch=false', task: task({ type: TaskType.SCREENSHOT_ANALYSIS, requiresSearch: false }), expected: 'FAIL_CLOSED', expectedSource: SearchPolicySource.EXPLICIT }, { id: 'S CONTENT_GENERATION+imgs requiresSearch=false', task: task({ type: TaskType.CONTENT_GENERATION, images: IMAGES, requiresSearch: false }), expected: 'FAIL_CLOSED', expectedSource: SearchPolicySource.EXPLICIT }, { id: 'T VISUAL_QA+imgs requiresSearch=false', task: task({ type: TaskType.VISUAL_QA, images: IMAGES, requiresSearch: false }), expected: 'VISION', expectedSource: SearchPolicySource.EXPLICIT }, + { id: 'U VISUAL_QA+multiple imgs requiresSearch=false', task: task({ type: TaskType.VISUAL_QA, images: [...IMAGES, 'https://cdn.example.com/shot2.png'], requiresSearch: false }), expected: 'VISION', expectedSource: SearchPolicySource.EXPLICIT }, // Extra coverage required by §5: explicit true lifts otherwise-general task types. { id: 'N EXTRACTION requiresSearch=true', task: task({ type: TaskType.EXTRACTION, requiresSearch: true }), expected: 'SEARCH', expectedSource: SearchPolicySource.EXPLICIT }, { id: 'O CLASSIFICATION requiresSearch=true', task: task({ type: TaskType.CLASSIFICATION, requiresSearch: true }), expected: 'SEARCH', expectedSource: SearchPolicySource.EXPLICIT }, From 17e683d58fcbe5d2e2681c03a5f5cc3d82fc8b1d Mon Sep 17 00:00:00 2001 From: cryptoxdog <31744795+cryptoxdog@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:22:39 -0400 Subject: [PATCH 5/5] pec: TASK-005 output --- docs/program-execution/TASK-005.md | 28 ++++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 docs/program-execution/TASK-005.md diff --git a/docs/program-execution/TASK-005.md b/docs/program-execution/TASK-005.md new file mode 100644 index 0000000..6373b1f --- /dev/null +++ b/docs/program-execution/TASK-005.md @@ -0,0 +1,28 @@ +# TASK-005 — Bump version to the 1.3.0 release line + +Campaign: `pe-router-capability-safety-v4` · Stacked on TASK-004 + +## What changed + +- `package.json` and `package-lock.json` (root + packages entry) bumped from + 1.1.3 to **1.3.0**. + +Operator decision (2026-08-18, mid-campaign): the release line is 1.3, not +1.1 — the search-policy audit work plus this campaign's capability-safety +work ship as 1.3.0, tagged by the operator after this campaign's delivery PR +merges. The intermediate 1.2.0 bump from PR #46 and the interim 1.1.3 pin +(PR #56) are superseded by this single forward jump; a separate version-fix +PR would have conflicted with this change on the same lines, so the campaign +itself is the 1.3.0 vehicle. Registry-state verification happens at the +operator's tag step (GitHub Packages authentication is operator-held). + +## Validation (run on the finished tree) + +- `npm run verify:all` — build, types, declarations, lint, boundary lint, + 171+ tests, audit, package smoke +- `npm run verify:package` — packed-tarball smoke install passes + +The packed artifact (npm pack) and the declaration-consumer fixture inside +verify:all stand as the local release proof; full disposable Website-Bot and +SEO-Bot consumer installs run at the operator's release step before the +1.3.0 tag push, per the contract's release sequence. diff --git a/package-lock.json b/package-lock.json index f6c34f1..07157ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quantum-l9/llm-router", - "version": "1.1.3", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quantum-l9/llm-router", - "version": "1.1.3", + "version": "1.3.0", "license": "PROPRIETARY", "dependencies": { "@quantum-l9/graphiti-memory-client": "^2.0.0", diff --git a/package.json b/package.json index 8ee331f..d475487 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@quantum-l9/llm-router", - "version": "1.1.3", + "version": "1.3.0", "type": "module", "description": "Reusable multi-provider LLM routing module with governed l9-graphiti-memory hydration, task-to-model routing, budgets, search, vision, and provider resilience.", "main": "dist/index.js",