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:
2026-04-25 10:00:38 +00:00
parent b8113d4bda
commit 2d7cf217a9
6 changed files with 629 additions and 20 deletions

View File

@@ -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
}
})();
}
}