This commit is contained in:
180
packages/agent/src/canary/phase-5-router.ts
Normal file
180
packages/agent/src/canary/phase-5-router.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Phase 5 canary router — deterministic per-request routing between
|
||||
* pre-Phase-5 baseline shapes and GEPA-evolved variants.
|
||||
*
|
||||
* Reads WAGGLE_PHASE5_CANARY_PCT (0-100, default 0) via FEATURE_FLAGS, hashes
|
||||
* the request_id into a stable 0-99 bucket, and routes to the canary variant
|
||||
* iff bucket < canary_pct AND the base shape has a canary mapping AND the
|
||||
* canary shape is registered.
|
||||
*
|
||||
* BIND (manifest gepa-phase-5/manifest.yaml § canary_toggle):
|
||||
* - Deterministic per-request_id routing preserves A/B paired-comparison
|
||||
* validity for §3 monitoring (same request_id always routes the same way).
|
||||
* - Default canary_pct = 0 until PM canary kick-off ratification (§7.3).
|
||||
* - Hot reconfig via process restart; no code redeploy required.
|
||||
* - LOCKED scope (manifest § scope_LOCKED): claude::gen1-v1 +
|
||||
* qwen-thinking::gen1-v1. Mid-flight scope changes require new LOCKED
|
||||
* decision memo + Marko ratifikacija.
|
||||
*
|
||||
* AUDIT: gepa-phase-5/manifest.yaml § canary_toggle, § scope_LOCKED.
|
||||
*/
|
||||
|
||||
import { FEATURE_FLAGS } from '../feature-flags.js';
|
||||
import { selectShape, REGISTRY, type SelectShapeOptions } from '../prompt-shapes/selector.js';
|
||||
import type { PromptShape } from '../prompt-shapes/types.js';
|
||||
|
||||
/**
|
||||
* Phase 5 LOCKED variant scope. Maps a base shape `name` to its evolved
|
||||
* variant's REGISTRY key. Both directions of the mapping are pinned by the
|
||||
* scope LOCK (decisions/2026-04-29-phase-5-scope-LOCKED.md).
|
||||
*
|
||||
* NOT mapped (intentional, per scope LOCK): qwen-non-thinking, gpt,
|
||||
* generic-simple. These remain on baseline shapes.
|
||||
*/
|
||||
export const BASE_TO_CANARY_VARIANT_MAP: Readonly<Record<string, string>> = Object.freeze({
|
||||
claude: 'claude::gen1-v1',
|
||||
'qwen-thinking': 'qwen-thinking::gen1-v1',
|
||||
});
|
||||
|
||||
export interface RouteResult {
|
||||
/** The PromptShape selected (canary variant or baseline). */
|
||||
shape: PromptShape;
|
||||
/** True iff routed to a Phase 5 canary variant. */
|
||||
isCanary: boolean;
|
||||
/** Base shape name resolved by selectShape() before canary consideration. */
|
||||
baseShapeName: string;
|
||||
/** Canary variant REGISTRY key (only when isCanary === true). */
|
||||
canaryShapeName?: string;
|
||||
/** Bucket [0, 99] computed from requestId. Useful for monitoring telemetry. */
|
||||
bucket: number;
|
||||
/** Canary percentage at routing time (snapshotted from FEATURE_FLAGS). */
|
||||
canaryPct: number;
|
||||
}
|
||||
|
||||
export interface RouteOptions extends SelectShapeOptions {
|
||||
/**
|
||||
* Override canary_pct for this single call. Useful for tests + replay.
|
||||
* Production callers should rely on FEATURE_FLAGS.PHASE_5_CANARY_PCT.
|
||||
* Invalid values fall back to 0 (canary OFF).
|
||||
*/
|
||||
canaryPctOverride?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a request id into a stable 0-99 bucket using FNV-1a.
|
||||
*
|
||||
* Properties:
|
||||
* - Deterministic: same input always returns same bucket.
|
||||
* - Reasonable distribution across short alphanumeric request ids.
|
||||
* - Fast (no crypto, no allocations beyond input traversal).
|
||||
*
|
||||
* Not cryptographically secure — strictly for canary bucketing.
|
||||
*/
|
||||
export function hashRequestIdToBucket(requestId: string): number {
|
||||
if (typeof requestId !== 'string' || requestId.length === 0) {
|
||||
return 0; // fail-safe: non-strings + empty strings → bucket 0
|
||||
}
|
||||
let hash = 0x811c9dc5; // FNV offset basis
|
||||
for (let i = 0; i < requestId.length; i++) {
|
||||
hash ^= requestId.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193); // FNV prime, with 32-bit truncation via Math.imul
|
||||
hash >>>= 0; // unsigned 32-bit
|
||||
}
|
||||
return hash % 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a canary_pct override value. Mirrors feature-flags.ts parser
|
||||
* semantics (fail-safe to 0 on malformed input).
|
||||
*/
|
||||
function clampCanaryPct(raw: number | undefined): number {
|
||||
if (raw === undefined) return FEATURE_FLAGS.PHASE_5_CANARY_PCT;
|
||||
if (!Number.isFinite(raw)) return 0;
|
||||
if (!Number.isInteger(raw)) return 0;
|
||||
if (raw < 0 || raw > 100) return 0;
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the base shape NAME for a model alias by inspecting the result of
|
||||
* selectShape(). Mirrors selectShape's resolution order; returns the
|
||||
* `shape.name` string field (which is stable on every PromptShape).
|
||||
*
|
||||
* If override is provided, returns the override directly (without resolution).
|
||||
*/
|
||||
function resolveBaseShapeName(modelAlias: string, options: SelectShapeOptions): string {
|
||||
if (options.override) return options.override;
|
||||
const shape = selectShape(modelAlias, options);
|
||||
return shape.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a request to a Phase 5 canary variant or to the baseline shape.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Resolve baseline shape name via selectShape() (or options.override).
|
||||
* 2. Look up canary variant in BASE_TO_CANARY_VARIANT_MAP.
|
||||
* 3. If no canary mapping: return baseline.
|
||||
* 4. If canary variant not registered (e.g. shape file missing): return
|
||||
* baseline (fail-safe — never crash a request because evolved variant
|
||||
* isn't loaded yet).
|
||||
* 5. Hash requestId into 0-99 bucket; if bucket < canary_pct: route to
|
||||
* canary variant; else: return baseline.
|
||||
*
|
||||
* Audit: returns full provenance (baseShapeName, canaryShapeName, bucket,
|
||||
* canaryPct) so §3 monitoring can record per-request routing decisions.
|
||||
*/
|
||||
export function routeRequestToVariant(
|
||||
modelAlias: string,
|
||||
requestId: string,
|
||||
options: RouteOptions = {},
|
||||
): RouteResult {
|
||||
const canaryPct = clampCanaryPct(options.canaryPctOverride);
|
||||
const bucket = hashRequestIdToBucket(requestId);
|
||||
const baseShapeName = resolveBaseShapeName(modelAlias, options);
|
||||
const baseShape = selectShape(modelAlias, options);
|
||||
|
||||
// Canary OFF or no mapping or variant not loaded → return baseline.
|
||||
if (canaryPct <= 0) {
|
||||
return { shape: baseShape, isCanary: false, baseShapeName, bucket, canaryPct };
|
||||
}
|
||||
const canaryShapeName = BASE_TO_CANARY_VARIANT_MAP[baseShapeName];
|
||||
if (!canaryShapeName) {
|
||||
return { shape: baseShape, isCanary: false, baseShapeName, bucket, canaryPct };
|
||||
}
|
||||
const canaryShape = REGISTRY[canaryShapeName];
|
||||
if (!canaryShape) {
|
||||
// Variant declared in scope but not registered (e.g. shape file deleted or
|
||||
// not yet loaded). Fail-safe to baseline; surface via return value rather
|
||||
// than throw so the request still serves.
|
||||
return { shape: baseShape, isCanary: false, baseShapeName, bucket, canaryPct };
|
||||
}
|
||||
|
||||
// Bucket < canaryPct → route to canary.
|
||||
if (bucket < canaryPct) {
|
||||
return {
|
||||
shape: canaryShape,
|
||||
isCanary: true,
|
||||
baseShapeName,
|
||||
canaryShapeName,
|
||||
bucket,
|
||||
canaryPct,
|
||||
};
|
||||
}
|
||||
return { shape: baseShape, isCanary: false, baseShapeName, bucket, canaryPct };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspector — list shape NAMES that have an active canary mapping.
|
||||
* Useful for §3 monitoring + manifest cross-checks.
|
||||
*/
|
||||
export function listCanaryEligibleShapes(): string[] {
|
||||
return Object.keys(BASE_TO_CANARY_VARIANT_MAP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspector — list canary variant REGISTRY keys (in-scope per LOCK).
|
||||
*/
|
||||
export function listCanaryVariants(): string[] {
|
||||
return Object.values(BASE_TO_CANARY_VARIANT_MAP);
|
||||
}
|
||||
Reference in New Issue
Block a user