feat(ml): egreedy-v2 shadow policy — D=12 with profile features (#99)
Ship the scaffolding for #99 (phase B.3 of #81): - ml/serving: add /score/egreedy/v2, /reward/egreedy/v2, /stats/egreedy/v2 endpoints (D=12). New feature dims: completion/dismiss rates, mean dwell (clipped 10min), preferred-hour alignment (cosine, 1-dim), tip volume (log). Separate state file per user (_egreedy_v2.json). /reset clears v2 state too. - ADR-0012: documents D=7→12 dimension change, normalization choices, shadow rollout protocol, and promotion gate (offline sim win per ADR-0002). - recommender.ts: register egreedy-v2-shadow in shadow-policy map (disabled by default). When enabled, calls /score/egreedy/v2 fire-and-forget and publishes shadow:egreedy-v2-shadow serve signal. No reward to shadow — sim is the gate. - sim runner/personas: personas carry synthetic profile_features per persona; _call_score/_call_reward thread profile_features through (None-safe for v1/linucb). - 18 new Python tests; all 56 Python + 170 TS tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -47,8 +47,8 @@ export const _clearCandidateCacheForTests = () => {
|
||||
// Shadow-policy registry
|
||||
// ---------------------------------------------------------------------------
|
||||
const shadowPolicies = new Map<string, { active: boolean }>([
|
||||
// Example: enable random as a shadow baseline
|
||||
// ('random-shadow', { active: true }),
|
||||
// egreedy-v2 (D=12, profile features) — disabled until sim gate per ADR-0012
|
||||
['egreedy-v2-shadow', { active: false }],
|
||||
]);
|
||||
|
||||
export function getShadowPolicies() {
|
||||
@@ -296,6 +296,42 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re
|
||||
policy: `shadow:${name}`,
|
||||
servedAt,
|
||||
});
|
||||
} else if (name === 'egreedy-v2-shadow') {
|
||||
// Call v2 endpoint with the same payload used for the active policy.
|
||||
// No reward is delivered — offline sim is the reward measurement for shadow.
|
||||
void (async () => {
|
||||
try {
|
||||
const body = {
|
||||
user_id: req.userId!,
|
||||
candidates: allCandidates.map((t) => ({
|
||||
id: t.id,
|
||||
content: t.content,
|
||||
source: t.source,
|
||||
source_id: t.sourceId ?? null,
|
||||
features: t.features,
|
||||
})),
|
||||
context: { hour_of_day: hour, day_of_week: dayOfWeek },
|
||||
profile_features: profile,
|
||||
};
|
||||
const res = await fetch(`${config.ML_SERVING_URL}/score/egreedy/v2`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { tip_id: string };
|
||||
bus.publish('signals.tip.served', {
|
||||
userId: req.userId!,
|
||||
tipId: data.tip_id,
|
||||
policy: `shadow:${name}`,
|
||||
servedAt,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// shadow is best-effort
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user