P0.5.1 — δ Intent Scoring — Contract
Step 2 of the 5-step chain. Behavioral contract, type shapes, invariants, and acceptance-criteria → test mapping. This file gates Step 3 (packet) and, transitively, Step 4 (implementation).
1. Types
The scorer exposes three types. Two are structural (context, score); one is a string-literal union that widens additively in Phase 1.5.
export type ModelId = 'claude';
export interface ScoreContext {
readonly toolCount?: number;
readonly complexity?: string;
readonly [key: string]: unknown;
}
export interface IntentScore {
readonly scores: Readonly<Record<ModelId, number>>;
readonly winner: ModelId;
}
Why ModelId = 'claude' (literal union, not string). Phase 1.5 widens to 'claude' | 'kimi' | 'codex' | ...; exhaustive checks in downstream code (e.g. switch (winner)) continue to warn on unhandled cases after the widening. string loses exhaustiveness; a const-enum loses structural compatibility with the Phase 1.5 scorer shape.
Why ScoreContext is an index signature. The donor extraction lists dozens of candidate factors (prompt length, complexity keywords, context size, tool requirements, latency tolerance, cost ceiling, secrecy, …). Phase 0 reads none of them, but Phase 1.5 will read many. Freezing the shape now would force a breaking change at Phase 1.5; leaving it open ([key: string]: unknown) means callers can pre-populate the context today and Phase 1.5 starts consuming it without a type change.
Why IntentScore is deep-readonly. The scorer returns a shared frozen object. Callers that mutate it crash under strict mode today (Phase 0) and in Phase 1.5 (where the output is a fresh object per call). The readonly types make the mutation impossible at compile time.
2. Function signature
/**
* Score a prompt across candidate models and pick a winner.
*
* Phase 0 stub: always returns Claude as the winner with score 1.0.
* Phase 1.5 will replace this with the real scoring matrix.
*/
export function scoreIntent(
_prompt: string,
_context?: ScoreContext,
): IntentScore;
The underscore-prefixed parameter names signal that the Phase 0 stub does not consume its inputs. ESLint’s no-unused-vars ignores underscore-prefixed params; the convention is consistent with the rest of the codebase (search: _options in src/domains/integrations/notifications.ts).
3. Invariants (ADR-005 §Decision)
| # | Invariant | Enforcement |
|---|---|---|
| I1 | scoreIntent(p, c).winner === 'claude' for all p, c |
Constant return. |
| I2 | scoreIntent(p, c).scores.claude === 1.0 for all p, c |
Constant return. |
| I3 | No other key exists in scores |
Return value is a frozen { claude: 1.0 }; Object.keys(scores).length === 1. |
| I4 | Purity — no I/O, no randomness, no time-dependent state | Function body contains only Object.freeze of a literal. |
| I5 | Determinism — same input → structurally equal output | Constant output trivially satisfies this; tests assert two calls with identical inputs return deep-equal objects. |
| I6 | Output is deeply immutable — Object.isFrozen(ret) && Object.isFrozen(ret.scores) |
Explicit Object.freeze on both levels. |
| I7 | Interface is forward-compatible with Phase 1.5 | ModelId is a string-literal union (additive widening); scores is Record<ModelId, number> (adds keys without breaking existing readers); ScoreContext has an index signature (additive fields). |
4. Failure modes (all absent in Phase 0)
Phase 0’s stub cannot fail:
- It does no I/O, so network, timeout, disk, and API-key errors cannot occur.
- It performs no parsing, so malformed input cannot throw.
- It reads no env, so missing-env-var cannot throw.
- It does not allocate, so OOM is not practically possible (one shared frozen object).
Phase 1.5 will introduce failure modes (adapter errors, token-budget exhaustion, all-models-failed). The contract names them here so the forward-compatibility promise is explicit:
| Phase 1.5 failure mode | Phase 0 behavior | Phase 1.5 behavior |
|---|---|---|
| No models available | N/A (claude is hard-coded) | Throws NoModelsAvailableError |
| All scores are zero | N/A (constant 1.0) | Falls back to lowest-cost model; logs degradation |
| Scorer panic | N/A (no inner logic to panic) | Caught by router_call; falls back to default model |
These behaviors are NOT implemented in this task; they are documented so a Phase 1.5 executor can verify that the Phase 0 stub remains compatible with the richer contract.
5. Module exports
From src/domains/router/scoring.ts:
scoreIntent— the function (public, re-exported from the barrel).ModelId— type alias (public).ScoreContext— interface (public).IntentScore— interface (public).
From src/domains/router/index.ts:
export * from './scoring.js';
Nothing else. P0.5.2 will append export * from './fallback.js'; after this PR merges.
6. Acceptance-criteria → test mapping
| # | Criterion | Test name(s) |
|---|---|---|
| AC1 | Returns {scores: {claude: 1.0}, winner: 'claude'} for any string input |
returns constant winner and score for empty prompt, returns constant winner and score for short prompt, returns constant winner and score for long prompt, returns constant winner and score for unicode prompt, returns constant winner and score for whitespace-only prompt |
| AC2 | Same shape regardless of context | ignores empty context, ignores context with toolCount, ignores context with complexity, ignores context with arbitrary keys, ignores deeply nested context |
| AC3 | Determinism — same input twice → deep-equal output | is deterministic across repeated calls |
| AC4 | scores.claude is exactly 1.0 |
claude score is exactly 1.0 |
| AC5 | Only one model in scores | scores contains exactly one model |
| AC6 | Output is immutable (frozen) | returned object is frozen, scores object is frozen |
| AC7 | Mutation throws under strict mode | mutating winner throws in strict mode, mutating scores throws in strict mode |
| AC8 | Pure — no time-dependent drift | returns same value across time windows |
| AC9 | Public interface matches Phase 1.5 contract | ModelId is a string-literal type with claude, IntentScore shape matches the forward-compatible contract (compile-time via satisfies; runtime via structural assertions) |
| AC10 | Zero MCP tools registered, zero env vars | module has no side effects on import (test imports the module and asserts no McpServer.registerTool-like call or process.env read occurs — verified structurally via source grep, not at runtime) |
Target: ≥10 distinct test(...) cases. Planned count: 16 (one per row above + extras for completeness).
7. Forward-compatibility promise
Phase 1.5 must drop in behind the same scoreIntent signature without breaking callers. That means:
- The function name stays
scoreIntent. - The return type stays
IntentScore. ModelIdwidens additively (never removes'claude').ScoreContextkeeps its index signature (never narrows to a closed shape).- The file location stays
src/domains/router/scoring.ts. - The barrel export stays
export * from './scoring.js'.
The P0.5 extraction doc (docs/reference/extractions/delta-model-router-extraction.md) describes the Phase 1.5 scoring algorithm. This task does NOT import from it — the heritage doc is a reference, not a source. Phase 1.5 will write the scorer from the contract above plus the algorithmic notes in the extraction.
8. Out-of-scope (explicit)
- Any MCP tool registration. The
router_scoretool is Phase 1.5. - Any env var added to
src/config.ts. None needed. - Any adapter wiring. Claude adapter lives in
src/domains/integrations/claude.ts(P0.9.2);scoreIntentnever imports it. - Any fallback logic.
src/domains/router/fallback.tsis P0.5.2 scope. - Any database schema change. δ does not touch SQLite in Phase 0.
- Any reconciliation of task-breakdown.md §P0.5.1’s “deferred” wording — the doc-fix is a Wave I close-commit task.
9. Contract approval
Gates Step 3 (packet). Packet must cite exactly these invariants, exactly this signature, exactly these ten ACs. Any deviation requires re-issuing this contract.