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:

  1. The function name stays scoreIntent.
  2. The return type stays IntentScore.
  3. ModelId widens additively (never removes 'claude').
  4. ScoreContext keeps its index signature (never narrows to a closed shape).
  5. The file location stays src/domains/router/scoring.ts.
  6. 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_score tool 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); scoreIntent never imports it.
  • Any fallback logic. src/domains/router/fallback.ts is 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.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.