| import { nanoid } from "nanoid"; |
| import { |
| StrategyAssignment, |
| StrategyGenome, |
| StrategyPortfolio, |
| RoleType, |
| AssignmentReason, |
| StrategyClass, |
| StrategyContextCondition, |
| } from "@/types"; |
| import { loadStrategyAssignments, saveStrategyAssignments, loadStrategies } from "@/lib/config"; |
| import { ensureStrategiesSeeded, listStrategiesByRole } from "@/lib/strategy/library"; |
|
|
| const now = () => new Date().toISOString(); |
|
|
| const MAX_PROVEN = 3; |
| const MAX_PERSONALIZED = 2; |
| const MAX_EXPERIMENTAL = 1; |
| const TARGET_EXPLORE_RATIO = 0.3; |
|
|
| |
|
|
| export interface EmployeeContext { |
| employeeId: string; |
| role: RoleType; |
| territoryType?: string; |
| workloadLevel?: "low" | "medium" | "high"; |
| stakeholderSegment?: string; |
| productPortfolio?: string[]; |
| } |
|
|
| function contextMatches( |
| conditions: StrategyContextCondition[], |
| ctx: EmployeeContext |
| ): boolean { |
| if (conditions.length === 0) return true; |
| return conditions.every((c) => { |
| let actualValue: unknown; |
| switch (c.field) { |
| case "role": |
| actualValue = ctx.role; |
| break; |
| case "territory_type": |
| actualValue = ctx.territoryType || "geographic"; |
| break; |
| case "workload_level": |
| actualValue = ctx.workloadLevel || "medium"; |
| break; |
| case "stakeholder_segment": |
| actualValue = ctx.stakeholderSegment || "mixed"; |
| break; |
| case "product_portfolio": |
| actualValue = ctx.productPortfolio || []; |
| break; |
| default: |
| return true; |
| } |
|
|
| switch (c.operator) { |
| case "equals": |
| return actualValue === c.value; |
| case "contains": |
| if (typeof actualValue === "string" && typeof c.value === "string") { |
| return actualValue.includes(c.value); |
| } |
| if (Array.isArray(actualValue) && typeof c.value === "string") { |
| return actualValue.includes(c.value); |
| } |
| return false; |
| case "in_range": |
| if (Array.isArray(c.value) && typeof actualValue === "string") { |
| return c.value.includes(actualValue); |
| } |
| return false; |
| case "greater_than": |
| return typeof actualValue === "number" && typeof c.value === "number" && actualValue > c.value; |
| case "less_than": |
| return typeof actualValue === "number" && typeof c.value === "number" && actualValue < c.value; |
| default: |
| return true; |
| } |
| }); |
| } |
|
|
| |
|
|
| export function listAssignments(): StrategyAssignment[] { |
| return loadStrategyAssignments(); |
| } |
|
|
| export function getAssignmentsForEmployee(employeeId: string): StrategyAssignment[] { |
| return loadStrategyAssignments().filter((a) => a.employeeId === employeeId && a.active); |
| } |
|
|
| export function getAssignmentById(id: string): StrategyAssignment | undefined { |
| return loadStrategyAssignments().find((a) => a.id === id); |
| } |
|
|
| export function upsertAssignment(assignment: StrategyAssignment): void { |
| const all = loadStrategyAssignments(); |
| const idx = all.findIndex((a) => a.id === assignment.id); |
| if (idx >= 0) { |
| all[idx] = assignment; |
| } else { |
| all.push(assignment); |
| } |
| saveStrategyAssignments(all); |
| } |
|
|
| export function deactivateAssignment(id: string): void { |
| const all = loadStrategyAssignments(); |
| const idx = all.findIndex((a) => a.id === id); |
| if (idx >= 0) { |
| all[idx].active = false; |
| all[idx].deactivatedAt = now(); |
| saveStrategyAssignments(all); |
| } |
| } |
|
|
| |
|
|
| export function computeExploreRatio(assignments: StrategyAssignment[]): number { |
| if (assignments.length === 0) return 0; |
| const exploreCount = assignments.filter( |
| (a) => a.assignmentReason === "explore" || a.strategyClass === "experimental" |
| ).length; |
| return exploreCount / assignments.length; |
| } |
|
|
| export function shouldExplore( |
| currentAssignments: StrategyAssignment[], |
| candidateClass: StrategyClass |
| ): boolean { |
| const exploreRatio = computeExploreRatio(currentAssignments); |
| if (exploreRatio >= TARGET_EXPLORE_RATIO) return false; |
| return candidateClass === "experimental" || candidateClass === "personalized"; |
| } |
|
|
| |
|
|
| export function assignStrategies(ctx: EmployeeContext): StrategyAssignment[] { |
| ensureStrategiesSeeded(); |
|
|
| const existing = getAssignmentsForEmployee(ctx.employeeId); |
| const newAssignments: StrategyAssignment[] = []; |
|
|
| const candidates = listStrategiesByRole(ctx.role).filter( |
| (s) => !existing.some((a) => a.strategyId === s.id) |
| ); |
|
|
| const contextMatched = candidates.filter((s) => contextMatches(s.applicableContext, ctx)); |
| const pool = contextMatched.length > 0 ? contextMatched : candidates; |
|
|
| const byClass: Record<StrategyClass, StrategyGenome[]> = { |
| proven: pool.filter((s) => s.strategyClass === "proven"), |
| personalized: pool.filter((s) => s.strategyClass === "personalized"), |
| experimental: pool.filter((s) => s.strategyClass === "experimental"), |
| }; |
|
|
| |
| const provenToAssign = Math.min( |
| MAX_PROVEN - existing.filter((a) => a.strategyClass === "proven").length, |
| byClass.proven.length |
| ); |
| for (let i = 0; i < Math.max(0, provenToAssign); i++) { |
| const strategy = byClass.proven[i]; |
| const assignment = createAssignment(strategy, ctx, "exploit", existing.length + newAssignments.length + 1); |
| newAssignments.push(assignment); |
| upsertAssignment(assignment); |
| } |
|
|
| |
| const personalizedToAssign = Math.min( |
| MAX_PERSONALIZED - existing.filter((a) => a.strategyClass === "personalized").length, |
| byClass.personalized.length |
| ); |
| for (let i = 0; i < Math.max(0, personalizedToAssign); i++) { |
| const strategy = byClass.personalized[i]; |
| const reason: AssignmentReason = shouldExplore([...existing, ...newAssignments], "personalized") |
| ? "personalized_fit" |
| : "exploit"; |
| const assignment = createAssignment(strategy, ctx, reason, existing.length + newAssignments.length + 1); |
| newAssignments.push(assignment); |
| upsertAssignment(assignment); |
| } |
|
|
| |
| const updatedAll = [...existing, ...newAssignments]; |
| if (shouldExplore(updatedAll, "experimental")) { |
| const experimentalToAssign = Math.min( |
| MAX_EXPERIMENTAL - existing.filter((a) => a.strategyClass === "experimental").length, |
| byClass.experimental.length |
| ); |
| for (let i = 0; i < Math.max(0, experimentalToAssign); i++) { |
| const strategy = byClass.experimental[i]; |
| const assignment = createAssignment(strategy, ctx, "explore", existing.length + newAssignments.length + 1); |
| newAssignments.push(assignment); |
| upsertAssignment(assignment); |
| } |
| } |
|
|
| return newAssignments; |
| } |
|
|
| function createAssignment( |
| strategy: StrategyGenome, |
| ctx: EmployeeContext, |
| reason: AssignmentReason, |
| trialNumber: number |
| ): StrategyAssignment { |
| return { |
| id: nanoid(12), |
| strategyId: strategy.id, |
| employeeId: ctx.employeeId, |
| employeeRole: ctx.role, |
| strategyClass: strategy.strategyClass, |
| assignmentReason: reason, |
| assignedAt: now(), |
| active: true, |
| employeeAccepted: false, |
| employeeModified: false, |
| expectedOutcomeMetrics: strategy.expectedOutcomes.map((m) => ({ ...m })), |
| contextSnapshot: { |
| territoryType: ctx.territoryType, |
| workloadLevel: ctx.workloadLevel, |
| stakeholderSegment: ctx.stakeholderSegment, |
| productPortfolio: ctx.productPortfolio, |
| }, |
| trialNumber, |
| confidenceAtAssignment: strategy.evidenceLevel === "experimentally_supported" |
| ? 0.85 |
| : strategy.evidenceLevel === "probable_contribution" |
| ? 0.65 |
| : strategy.evidenceLevel === "observed_association" |
| ? 0.45 |
| : 0.25, |
| }; |
| } |
|
|
| |
|
|
| export function computeDiversityScore(assignments: StrategyAssignment[], strategies: StrategyGenome[]): number { |
| if (assignments.length === 0) return 0; |
| const domains = new Set<string>(); |
| for (const a of assignments) { |
| const s = strategies.find((s) => s.id === a.strategyId); |
| if (s) domains.add(s.domain); |
| } |
| return Math.round((domains.size / 8) * 100) / 100; |
| } |
|
|
| export function getPortfolio(employeeId: string, role: RoleType): StrategyPortfolio { |
| ensureStrategiesSeeded(); |
| const allStrategies = loadStrategies(); |
| const active = getAssignmentsForEmployee(employeeId); |
|
|
| const proven = active.filter((a) => a.strategyClass === "proven"); |
| const personalized = active.filter((a) => a.strategyClass === "personalized"); |
| const experimental = active.filter((a) => a.strategyClass === "experimental"); |
|
|
| const exploreRatio = computeExploreRatio(active); |
| const exploitRatio = 1 - exploreRatio; |
| const diversity = computeDiversityScore(active, allStrategies); |
|
|
| return { |
| employeeId, |
| employeeRole: role, |
| activeAssignments: active, |
| provenStrategies: proven, |
| personalizedStrategies: personalized, |
| experimentalStrategies: experimental, |
| portfolioDiversityScore: diversity, |
| exploreRatio, |
| exploitRatio, |
| lastRebalancedAt: now(), |
| }; |
| } |
|
|
| |
|
|
| export function acceptAssignment(id: string): StrategyAssignment | undefined { |
| const all = loadStrategyAssignments(); |
| const idx = all.findIndex((a) => a.id === id); |
| if (idx < 0) return undefined; |
| all[idx].employeeAccepted = true; |
| saveStrategyAssignments(all); |
| return all[idx]; |
| } |
|
|
| export function modifyAssignment(id: string, notes: string): StrategyAssignment | undefined { |
| const all = loadStrategyAssignments(); |
| const idx = all.findIndex((a) => a.id === id); |
| if (idx < 0) return undefined; |
| all[idx].employeeModified = true; |
| all[idx].modificationNotes = notes; |
| saveStrategyAssignments(all); |
| return all[idx]; |
| } |
|
|
| export function rejectAssignment(id: string): StrategyAssignment | undefined { |
| deactivateAssignment(id); |
| const all = loadStrategyAssignments(); |
| return all.find((a) => a.id === id); |
| } |
|
|