This commit is contained in:
490
packages/agent/src/canary/phase-5-monitoring.ts
Normal file
490
packages/agent/src/canary/phase-5-monitoring.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* Phase 5 monitoring — emitters + threshold detector + alert routing.
|
||||
*
|
||||
* Writes JSONL per-variant per-day to `gepa-phase-5/monitoring/<ISO_date>/<variant>.jsonl`
|
||||
* and threshold breaches to `gepa-phase-5/phase-5-alerts/<ISO_date>.jsonl`.
|
||||
*
|
||||
* Five required metrics (manifest gepa-phase-5/manifest.yaml § promotion_criteria + § rollback_triggers):
|
||||
* 1. pass_ii_rate — Pass II rate moving 10-sample window per variant
|
||||
* 2. retrieval_engagement — per-request retrieval call count
|
||||
* 3. latency_ms — per-request wall-clock latency
|
||||
* 4. cost_usd — per-request USD cost
|
||||
* 5. error — per-variant agent_error_rate by type (loop_exhausted, timeout, parse_fail, other)
|
||||
*
|
||||
* Stage 1 deliverable per brief §3.4: JSONL files + daily markdown summary (no UI).
|
||||
*
|
||||
* BIND: thresholds are pre-registered in manifest § promotion_criteria + § rollback_triggers.
|
||||
* Mid-flight changes require amendment + Marko ratifikacija (no-revisit-without-amendment).
|
||||
*
|
||||
* AUDIT: gepa-phase-5/manifest.yaml § promotion_criteria, § rollback_triggers, § halt_and_pm_triggers.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
// packages/agent/src/canary/phase-5-monitoring.ts → repo root
|
||||
const PROJECT_ROOT = path.resolve(path.dirname(__filename), '../../../..');
|
||||
const DEFAULT_MONITORING_BASE_DIR = path.join(PROJECT_ROOT, 'gepa-phase-5', 'monitoring');
|
||||
const DEFAULT_ALERTS_BASE_DIR = path.join(PROJECT_ROOT, 'gepa-phase-5', 'phase-5-alerts');
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type MetricName =
|
||||
| 'pass_ii_rate'
|
||||
| 'retrieval_engagement'
|
||||
| 'latency_ms'
|
||||
| 'cost_usd'
|
||||
| 'error';
|
||||
|
||||
export type ErrorType = 'loop_exhausted' | 'timeout' | 'parse_fail' | 'other';
|
||||
|
||||
export interface MetricEntry {
|
||||
ts: string; // ISO 8601 timestamp
|
||||
variant: string; // canary variant REGISTRY key OR baseline shape name
|
||||
request_id: string;
|
||||
metric_name: MetricName;
|
||||
metric_value: number;
|
||||
baseline_comparison?: BaselineComparison;
|
||||
error_type?: ErrorType; // populated when metric_name === 'error'
|
||||
}
|
||||
|
||||
export interface BaselineComparison {
|
||||
baseline_value: number;
|
||||
delta?: number;
|
||||
}
|
||||
|
||||
export interface AlertEntry {
|
||||
ts: string;
|
||||
trigger_id: string;
|
||||
variant: string;
|
||||
metric_name: MetricName | string;
|
||||
observed_value: number;
|
||||
threshold: number;
|
||||
is_rollback_trigger: boolean;
|
||||
diagnostic?: string;
|
||||
}
|
||||
|
||||
export interface MonitoringPaths {
|
||||
monitoringBaseDir: string;
|
||||
alertsBaseDir: string;
|
||||
}
|
||||
|
||||
export interface MonitoringContext {
|
||||
paths: MonitoringPaths;
|
||||
/** Override clock for tests. */
|
||||
now?: () => Date;
|
||||
/** Inject a writer (default: fs.appendFileSync). Tests use in-memory writer. */
|
||||
appendLine?: (filePath: string, line: string) => void;
|
||||
}
|
||||
|
||||
// ── Threshold registry (manifest-bound, LOCKED) ──────────────────────────
|
||||
|
||||
/**
|
||||
* Rollback trigger thresholds (immediate action). Mirror manifest § rollback_triggers.
|
||||
* Any breach → emit AlertEntry with is_rollback_trigger=true.
|
||||
*/
|
||||
export const ROLLBACK_THRESHOLDS = {
|
||||
pass_ii_collapse_pp: -10, // variant pass_ii < baseline pass_ii − 10pp
|
||||
pass_ii_consecutive_windows: 2,
|
||||
pass_ii_window_size: 10,
|
||||
error_rate_spike_pp: 5, // variant error > baseline error + 5pp
|
||||
error_consecutive_window_hours: 24,
|
||||
cost_per_request_multiplier: 2.0, // variant_cost > baseline_cost × 2.0
|
||||
latency_p95_multiplier: 3.0, // variant_p95 > baseline_p95 × 3.0
|
||||
opcija_c_loop_exhausted_rate_pct: 5, // > 5% baseline → halt with "long-task fixes potrebni"
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Promotion criteria thresholds (canary → full enable). Mirror manifest § promotion_criteria.
|
||||
* ε = 1e-9 inclusive boundary per feedback_epsilon_inclusive_boundary.
|
||||
*/
|
||||
export const PROMOTION_THRESHOLDS = {
|
||||
inclusive_boundary_epsilon: 1e-9,
|
||||
pass_ii_delta_pp: 0, // variant_pass_ii ≥ baseline + 0pp − ε
|
||||
retrieval_qwen_thinking_multiplier: 0.80, // qwen-thinking variant ≥ baseline × 0.80
|
||||
retrieval_claude_multiplier: 1.0, // claude variant ≥ baseline
|
||||
latency_p95_multiplier: 1.20, // variant_p95 ≤ baseline × 1.20
|
||||
cost_per_request_multiplier: 1.15, // variant_cost ≤ baseline × 1.15
|
||||
error_rate_delta_pp: 1, // variant_error ≤ baseline + 1pp
|
||||
sample_floor_per_metric: 30,
|
||||
days_min: 7,
|
||||
} as const;
|
||||
|
||||
// ── Filesystem helpers ──────────────────────────────────────────────────
|
||||
|
||||
function defaultClock(): Date {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
function defaultAppendLine(filePath: string, line: string): void {
|
||||
ensureDir(path.dirname(filePath));
|
||||
fs.appendFileSync(filePath, line, 'utf-8');
|
||||
}
|
||||
|
||||
function ensureDir(dirPath: string): void {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function isoDateUtc(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a variant identifier for use as a filename component. Replaces
|
||||
* `::` (REGISTRY key separator) with `__` and strips other unsafe chars.
|
||||
*/
|
||||
export function sanitizeVariantForFilename(variant: string): string {
|
||||
if (!variant) return 'unknown';
|
||||
return variant.replace(/::/g, '__').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
function defaultContext(): Required<Omit<MonitoringContext, 'paths'>> & {
|
||||
paths: MonitoringPaths;
|
||||
} {
|
||||
return {
|
||||
paths: {
|
||||
monitoringBaseDir: DEFAULT_MONITORING_BASE_DIR,
|
||||
alertsBaseDir: DEFAULT_ALERTS_BASE_DIR,
|
||||
},
|
||||
now: defaultClock,
|
||||
appendLine: defaultAppendLine,
|
||||
};
|
||||
}
|
||||
|
||||
function withDefaults(ctx?: MonitoringContext): Required<Omit<MonitoringContext, 'paths'>> & {
|
||||
paths: MonitoringPaths;
|
||||
} {
|
||||
const d = defaultContext();
|
||||
return {
|
||||
paths: ctx?.paths ?? d.paths,
|
||||
now: ctx?.now ?? d.now,
|
||||
appendLine: ctx?.appendLine ?? d.appendLine,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Emitters ────────────────────────────────────────────────────────────
|
||||
|
||||
function emitMetric(entry: MetricEntry, ctx?: MonitoringContext): void {
|
||||
const c = withDefaults(ctx);
|
||||
const date = isoDateUtc(c.now());
|
||||
const dir = path.join(c.paths.monitoringBaseDir, date);
|
||||
const file = path.join(dir, `${sanitizeVariantForFilename(entry.variant)}.jsonl`);
|
||||
c.appendLine(file, JSON.stringify(entry) + '\n');
|
||||
}
|
||||
|
||||
export interface EmitOptions {
|
||||
baselineComparison?: BaselineComparison;
|
||||
ctx?: MonitoringContext;
|
||||
}
|
||||
|
||||
export function emitPassIIRate(
|
||||
variant: string,
|
||||
requestId: string,
|
||||
passIiRate: number,
|
||||
options: EmitOptions = {},
|
||||
): void {
|
||||
const c = withDefaults(options.ctx);
|
||||
emitMetric(
|
||||
{
|
||||
ts: c.now().toISOString(),
|
||||
variant,
|
||||
request_id: requestId,
|
||||
metric_name: 'pass_ii_rate',
|
||||
metric_value: passIiRate,
|
||||
baseline_comparison: options.baselineComparison,
|
||||
},
|
||||
options.ctx,
|
||||
);
|
||||
}
|
||||
|
||||
export function emitRetrievalEngagement(
|
||||
variant: string,
|
||||
requestId: string,
|
||||
retrievalCallCount: number,
|
||||
options: EmitOptions = {},
|
||||
): void {
|
||||
const c = withDefaults(options.ctx);
|
||||
emitMetric(
|
||||
{
|
||||
ts: c.now().toISOString(),
|
||||
variant,
|
||||
request_id: requestId,
|
||||
metric_name: 'retrieval_engagement',
|
||||
metric_value: retrievalCallCount,
|
||||
baseline_comparison: options.baselineComparison,
|
||||
},
|
||||
options.ctx,
|
||||
);
|
||||
}
|
||||
|
||||
export function emitLatency(
|
||||
variant: string,
|
||||
requestId: string,
|
||||
latencyMs: number,
|
||||
options: EmitOptions = {},
|
||||
): void {
|
||||
const c = withDefaults(options.ctx);
|
||||
emitMetric(
|
||||
{
|
||||
ts: c.now().toISOString(),
|
||||
variant,
|
||||
request_id: requestId,
|
||||
metric_name: 'latency_ms',
|
||||
metric_value: latencyMs,
|
||||
baseline_comparison: options.baselineComparison,
|
||||
},
|
||||
options.ctx,
|
||||
);
|
||||
}
|
||||
|
||||
export function emitCost(
|
||||
variant: string,
|
||||
requestId: string,
|
||||
costUsd: number,
|
||||
options: EmitOptions = {},
|
||||
): void {
|
||||
const c = withDefaults(options.ctx);
|
||||
emitMetric(
|
||||
{
|
||||
ts: c.now().toISOString(),
|
||||
variant,
|
||||
request_id: requestId,
|
||||
metric_name: 'cost_usd',
|
||||
metric_value: costUsd,
|
||||
baseline_comparison: options.baselineComparison,
|
||||
},
|
||||
options.ctx,
|
||||
);
|
||||
}
|
||||
|
||||
export function emitError(
|
||||
variant: string,
|
||||
requestId: string,
|
||||
errorType: ErrorType,
|
||||
options: EmitOptions = {},
|
||||
): void {
|
||||
const c = withDefaults(options.ctx);
|
||||
emitMetric(
|
||||
{
|
||||
ts: c.now().toISOString(),
|
||||
variant,
|
||||
request_id: requestId,
|
||||
metric_name: 'error',
|
||||
metric_value: 1,
|
||||
error_type: errorType,
|
||||
baseline_comparison: options.baselineComparison,
|
||||
},
|
||||
options.ctx,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Threshold detection (single-event evaluation) ────────────────────────
|
||||
|
||||
export interface SingleEventCheck {
|
||||
variantValue: number;
|
||||
baselineValue: number;
|
||||
variant: string;
|
||||
metricName: MetricName | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-event rollback trigger detection. Returns an AlertEntry to emit if
|
||||
* any threshold is breached on this single observation; null if all clear.
|
||||
*
|
||||
* Multi-window rollback triggers (pass_ii_collapse over 2 consecutive windows;
|
||||
* error_rate spike over 24h consecutive) require the daily aggregator
|
||||
* (gepa-phase-5/scripts/phase-5-daily-summary.ts) — they cannot be detected
|
||||
* from a single observation.
|
||||
*
|
||||
* Single-event triggers handled here:
|
||||
* - cost_per_request_spike: variant > baseline × 2.0 (immediate single-window)
|
||||
* - latency_p95_spike: variant > baseline × 3.0 (immediate single-window)
|
||||
*/
|
||||
export function checkSingleEventRollback(
|
||||
check: SingleEventCheck,
|
||||
now: () => Date = defaultClock,
|
||||
): AlertEntry | null {
|
||||
const ts = now().toISOString();
|
||||
|
||||
if (check.metricName === 'cost_usd') {
|
||||
const threshold = check.baselineValue * ROLLBACK_THRESHOLDS.cost_per_request_multiplier;
|
||||
if (check.variantValue > threshold) {
|
||||
return {
|
||||
ts,
|
||||
trigger_id: 'cost_per_request_spike',
|
||||
variant: check.variant,
|
||||
metric_name: 'cost_usd',
|
||||
observed_value: check.variantValue,
|
||||
threshold,
|
||||
is_rollback_trigger: true,
|
||||
diagnostic: `Variant cost ${check.variantValue.toFixed(4)} > baseline ${check.baselineValue.toFixed(4)} × ${ROLLBACK_THRESHOLDS.cost_per_request_multiplier} = ${threshold.toFixed(4)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (check.metricName === 'latency_ms') {
|
||||
const threshold = check.baselineValue * ROLLBACK_THRESHOLDS.latency_p95_multiplier;
|
||||
if (check.variantValue > threshold) {
|
||||
return {
|
||||
ts,
|
||||
trigger_id: 'latency_p95_spike',
|
||||
variant: check.variant,
|
||||
metric_name: 'latency_ms',
|
||||
observed_value: check.variantValue,
|
||||
threshold,
|
||||
is_rollback_trigger: true,
|
||||
diagnostic: `Variant p95 ${check.variantValue.toFixed(0)}ms > baseline ${check.baselineValue.toFixed(0)}ms × ${ROLLBACK_THRESHOLDS.latency_p95_multiplier} = ${threshold.toFixed(0)}ms`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an alert entry to phase-5-alerts/<ISO_date>.jsonl.
|
||||
*
|
||||
* Halt-and-PM hook: when is_rollback_trigger=true, also emits a structured log
|
||||
* line via process.stderr. In production this triggers automation that can
|
||||
* invoke §2.3 rollback procedure (canary toggle to 0 + git revert ratification).
|
||||
*/
|
||||
export function emitAlert(alert: AlertEntry, ctx?: MonitoringContext): void {
|
||||
const c = withDefaults(ctx);
|
||||
const date = isoDateUtc(c.now());
|
||||
const file = path.join(c.paths.alertsBaseDir, `${date}.jsonl`);
|
||||
c.appendLine(file, JSON.stringify(alert) + '\n');
|
||||
|
||||
if (alert.is_rollback_trigger) {
|
||||
// Structured stderr line — automation hook for halt-and-PM cascade.
|
||||
// Format: PHASE5-ROLLBACK-TRIGGER <ts> <trigger_id> <variant> <metric_name>=<observed> threshold=<threshold>
|
||||
process.stderr.write(
|
||||
`PHASE5-ROLLBACK-TRIGGER ${alert.ts} ${alert.trigger_id} ${alert.variant} ${alert.metric_name}=${alert.observed_value} threshold=${alert.threshold}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aggregation primitives (multi-event analysis) ────────────────────────
|
||||
|
||||
export interface MovingWindowResult {
|
||||
windowSize: number;
|
||||
variantValues: number[];
|
||||
variantMean: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute mean of last N observations. Returns null if fewer than N values
|
||||
* available (caller is responsible for sample-floor compliance).
|
||||
*/
|
||||
export function computeMovingWindowMean(values: readonly number[], windowSize: number): MovingWindowResult | null {
|
||||
if (values.length < windowSize) return null;
|
||||
const tail = values.slice(values.length - windowSize);
|
||||
const sum = tail.reduce((a, b) => a + b, 0);
|
||||
return {
|
||||
windowSize,
|
||||
variantValues: tail,
|
||||
variantMean: sum / windowSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass II rate collapse: 2 consecutive 10-sample moving windows where variant
|
||||
* Pass II < baseline Pass II − 10pp. Returns AlertEntry if breach, null otherwise.
|
||||
*
|
||||
* Window 1 = oldest 10 samples; Window 2 = newest 10 samples. Caller passes
|
||||
* the full sequence of variant Pass II observations + the baseline mean.
|
||||
*
|
||||
* Returns null if fewer than 20 samples available (need 2 windows of 10 each).
|
||||
*/
|
||||
export function checkPassIIRateCollapse(
|
||||
variantPassIiSeries: readonly number[],
|
||||
baselinePassIi: number,
|
||||
variant: string,
|
||||
now: () => Date = defaultClock,
|
||||
): AlertEntry | null {
|
||||
const sampleSize = ROLLBACK_THRESHOLDS.pass_ii_window_size;
|
||||
const consecutive = ROLLBACK_THRESHOLDS.pass_ii_consecutive_windows;
|
||||
if (variantPassIiSeries.length < sampleSize * consecutive) return null;
|
||||
|
||||
const tail = variantPassIiSeries.slice(variantPassIiSeries.length - sampleSize * consecutive);
|
||||
// Last `consecutive` windows of `sampleSize` each; check ALL must breach.
|
||||
for (let i = 0; i < consecutive; i++) {
|
||||
const start = i * sampleSize;
|
||||
const window = tail.slice(start, start + sampleSize);
|
||||
const mean = window.reduce((a, b) => a + b, 0) / sampleSize;
|
||||
const collapseThreshold = baselinePassIi + ROLLBACK_THRESHOLDS.pass_ii_collapse_pp / 100;
|
||||
if (mean >= collapseThreshold) return null; // not collapsed in this window
|
||||
}
|
||||
// All consecutive windows collapsed.
|
||||
const lastWindow = tail.slice((consecutive - 1) * sampleSize);
|
||||
const lastMean = lastWindow.reduce((a, b) => a + b, 0) / sampleSize;
|
||||
return {
|
||||
ts: now().toISOString(),
|
||||
trigger_id: 'pass_ii_collapse',
|
||||
variant,
|
||||
metric_name: 'pass_ii_rate',
|
||||
observed_value: lastMean,
|
||||
threshold: baselinePassIi + ROLLBACK_THRESHOLDS.pass_ii_collapse_pp / 100,
|
||||
is_rollback_trigger: true,
|
||||
diagnostic: `Pass II collapse: ${consecutive} consecutive ${sampleSize}-sample windows below baseline ${baselinePassIi.toFixed(3)} − 10pp = ${(baselinePassIi - 0.1).toFixed(3)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Error-rate spike: variant error rate > baseline + 5pp over 24h consecutive.
|
||||
* Caller computes hourly error rate buckets from raw error events.
|
||||
*/
|
||||
export function checkErrorRateSpike(
|
||||
variantErrorRateHourly: readonly number[],
|
||||
baselineErrorRate: number,
|
||||
variant: string,
|
||||
now: () => Date = defaultClock,
|
||||
): AlertEntry | null {
|
||||
const hours = ROLLBACK_THRESHOLDS.error_consecutive_window_hours;
|
||||
if (variantErrorRateHourly.length < hours) return null;
|
||||
const tail = variantErrorRateHourly.slice(variantErrorRateHourly.length - hours);
|
||||
const threshold = baselineErrorRate + ROLLBACK_THRESHOLDS.error_rate_spike_pp / 100;
|
||||
if (tail.every((rate) => rate > threshold)) {
|
||||
const meanRate = tail.reduce((a, b) => a + b, 0) / hours;
|
||||
return {
|
||||
ts: now().toISOString(),
|
||||
trigger_id: 'error_rate_spike',
|
||||
variant,
|
||||
metric_name: 'error',
|
||||
observed_value: meanRate,
|
||||
threshold,
|
||||
is_rollback_trigger: true,
|
||||
diagnostic: `Error rate spike: variant ${(meanRate * 100).toFixed(2)}% > baseline ${(baselineErrorRate * 100).toFixed(2)}% + 5pp = ${(threshold * 100).toFixed(2)}% for ${hours} consecutive hours`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opcija C long-task trigger: loop_exhausted error rate > 5% baseline.
|
||||
* Phase 4 long-task fixes not inherited per Opcija C §3; halt diagnostic
|
||||
* "long-task fixes potrebni" + selective cherry-pick option flagged.
|
||||
*/
|
||||
export function checkLoopExhaustedRate(
|
||||
loopExhaustedRatePct: number,
|
||||
variant: string,
|
||||
now: () => Date = defaultClock,
|
||||
): AlertEntry | null {
|
||||
const threshold = ROLLBACK_THRESHOLDS.opcija_c_loop_exhausted_rate_pct;
|
||||
if (loopExhaustedRatePct > threshold) {
|
||||
return {
|
||||
ts: now().toISOString(),
|
||||
trigger_id: 'opcija_c_long_task_loop_exhausted',
|
||||
variant,
|
||||
metric_name: 'error',
|
||||
observed_value: loopExhaustedRatePct,
|
||||
threshold,
|
||||
is_rollback_trigger: true,
|
||||
diagnostic: `loop_exhausted rate ${loopExhaustedRatePct.toFixed(2)}% > 5% baseline. Phase 4 long-task fixes not inherited per Opcija C §3 — halt with "long-task fixes potrebni" rationale + selective cherry-pick option from feature/c3-v3-wrapper (commits c9bda3d, be8f702, e906114, 4d0542f, 8b8a940).`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
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