import { clamp01, jaccard, tokenize, type ActionScore, type AgentMode, type ObservedEntity, type PageState, type SemanticAction } from "@src/shared"; import type { WorldModel } from "./WorldModel.ts"; /** * Information Gain Engine (§26–§27): * gain = novelty × relevance × expected_entity_yield × confidence × source_quality ÷ cost * with penalties for already-seen / duplicate / loop / low-quality / low-relevance. */ export interface GainContext { goal: string; mode: AgentMode; world: WorldModel; state: PageState; recentActionTypes: string[]; // last N action types recentUrls: string[]; stepsWithoutNewEntities: number; /** Learned expectations: action type → average new entities produced (from the platform model). */ learnedYield?: Record; /** Labels (type + target) of actions that failed in the last few steps — retrying them blindly is a loop in disguise. */ recentFailures?: Set; } export function actionKey(a: SemanticAction): string { return `${a.type}:${a.target_url ?? a.query ?? a.label}`; } const TYPE_YIELD: Record = { OPEN_CHANNEL: 0.8, OPEN_PROFILE: 0.8, OPEN_PAGE: 0.75, OPEN_VIDEO: 0.65, OPEN_POST: 0.65, OPEN_COMMENTS: 0.55, OPEN_ENTITY: 0.6, SCROLL_DOWN: 0.6, SEARCH: 0.85, EXPAND: 0.4, PLAY_VIDEO: 0.3, SCROLL_UP: 0.05, BACK: 0.2, RETURN_TO_FEED: 0.3, FILTER: 0.4, WAIT_FOR_CONTENT: 0.1, END_SESSION: 0.0, FORWARD: 0.1, PAUSE_VIDEO: 0.0, COLLAPSE: 0.0, }; export function relevanceOf(text: string | undefined, goal: string): number { if (!goal.trim()) return 0.6; if (!text) return 0.35; const g = tokenize(goal); const t = tokenize(text); if (g.size === 0 || t.size === 0) return 0.4; let hits = 0; for (const tok of g) if (t.has(tok) || [...t].some((x) => x.startsWith(tok.slice(0, 5)) && tok.length > 4)) hits++; const coverage = hits / g.size; return clamp01(0.3 + 0.7 * coverage + 0.2 * jaccard(g, t)); } function entityOf(ctx: GainContext, a: SemanticAction): ObservedEntity | undefined { return a.target_ref ? ctx.state.entities.find((e) => e.ref === a.target_ref) : undefined; } export function scoreActions(ctx: GainContext, actions: SemanticAction[]): ActionScore[] { const scores: ActionScore[] = []; const currentRel = relevanceOf(ctx.state.summary_text.slice(0, 1500), ctx.goal); for (const a of actions) { const penalties: string[] = []; const e = entityOf(ctx, a); let novelty = 0.5; let relevance = 0.5; let confidence = 0.7; let source_quality = 0.7; let yieldExp = ctx.learnedYield?.[a.type] !== undefined ? clamp01(ctx.learnedYield[a.type]! / 10) : TYPE_YIELD[a.type] ?? 0.3; if (e) { novelty = ctx.world.novelty(e); relevance = relevanceOf(`${e.name ?? ""} ${e.text ?? ""} ${e.author ?? ""}`, ctx.goal); confidence = Math.max(0, ...e.provenance.map((p) => p.confidence)); source_quality = e.provenance.some((p) => p.surface === "network") && e.provenance.some((p) => p.surface === "dom") ? 0.95 : 0.75; if (ctx.world.isVisited(e)) penalties.push("already_seen"); if (/^(navigation|header|sidebar)/.test(e.context ?? "") && ctx.mode !== "learn") penalties.push("low_quality_source"); if (!e.name && !e.text) penalties.push("low_confidence_entity"); // Profiles/channels are hubs: raise yield when the goal is about people/orgs. if ((e.type === "channel" || e.type === "profile") && /personalit|people|person|creator|influenc|chaîne|channel|profil/i.test(ctx.goal)) yieldExp = Math.min(1, yieldExp + 0.15); } else { switch (a.type) { case "SCROLL_DOWN": relevance = currentRel; novelty = ctx.stepsWithoutNewEntities > 2 ? 0.2 : 0.6; if (ctx.recentActionTypes.slice(-4).every((t) => t === "SCROLL_DOWN") && ctx.recentActionTypes.length >= 4) penalties.push("navigation_loop"); break; case "SEARCH": relevance = 0.9; novelty = ctx.recentActionTypes.includes("SEARCH") ? 0.15 : 0.9; if (ctx.state.classification.page_type === "SEARCH_RESULTS") penalties.push("already_seen"); break; case "EXPAND": relevance = currentRel; novelty = 0.5; break; case "PLAY_VIDEO": relevance = currentRel; novelty = ctx.state.classification.page_type === "VIDEO_DETAIL" ? 0.6 : 0.3; break; case "BACK": case "RETURN_TO_FEED": relevance = 0.4; novelty = ctx.stepsWithoutNewEntities > 1 ? 0.6 : 0.2; if (ctx.recentActionTypes.slice(-2).includes(a.type)) penalties.push("navigation_loop"); break; case "END_SESSION": relevance = 0.1; novelty = ctx.stepsWithoutNewEntities > 6 ? 0.9 : 0.05; break; default: break; } } if (ctx.mode === "observe" && a.type !== "SCROLL_DOWN" && a.type !== "WAIT_FOR_CONTENT" && a.type !== "END_SESSION") penalties.push("mode_observe_no_navigation"); if (relevance < 0.35 && e) penalties.push("low_relevance"); if (ctx.recentFailures?.has(actionKey(a))) penalties.push("recently_failed"); let gain = (novelty * relevance * yieldExp * confidence * source_quality) / Math.max(0.25, a.cost); for (const p of penalties) gain *= p === "already_seen" ? 0.05 : p === "navigation_loop" ? 0.15 : p === "mode_observe_no_navigation" ? 0.02 : p === "recently_failed" ? 0.1 : 0.5; scores.push({ action_id: a.id, novelty, relevance, expected_entity_yield: yieldExp, confidence, source_quality, cost: a.cost, penalties, information_gain: gain }); } return scores.sort((x, y) => y.information_gain - x.information_gain); }