/** * V7 §2.5.4 — Skill lifecycle. * * A skill moves through three visible states: `candidate`, `active`, * `archived`. Transitions are driven by: * * - **Trials**: `trialsAttempted` grows on every `trial.pass` / `trial.fail` * feedback signal. Once `trialsAttempted >= candidateTrials` we check * the success ratio + η and promote to `active` or bounce back. * - **Reward**: `reward.updated` on the source policy bubbles into * `recomputeEta`, which re-seeds η from the policy's updated gain. * - **User thumbs**: direct `user.positive` / `user.negative` signals * adjust η by `etaDelta`. * * Everything here is pure over a `SkillRow`. The orchestrator calls these * helpers inside a `tx` so updates are atomic and auditable. */ import type { PolicyRow, SkillRow } from "../types.js"; import type { SkillConfig, SkillFeedbackKind, SkillLifecycleTransition, } from "./types.js"; export interface LifecycleUpdate { status: SkillRow["status"]; eta: number; trialsAttempted: number; trialsPassed: number; transition?: SkillLifecycleTransition; } /** * Apply one feedback signal to a skill. Returns the post-update state. */ export function applyFeedback( skill: SkillRow, kind: SkillFeedbackKind, cfg: SkillConfig, magnitude?: number, ): LifecycleUpdate { switch (kind) { case "trial.pass": return applyTrial(skill, true, cfg); case "trial.fail": return applyTrial(skill, false, cfg); case "user.positive": return applyThumbs(skill, +1, cfg, magnitude); case "user.negative": return applyThumbs(skill, -1, cfg, magnitude); case "reward.updated": return applyRewardDrift(skill, cfg, magnitude ?? 0); default: return snapshot(skill); } } function applyTrial( skill: SkillRow, passed: boolean, cfg: SkillConfig, ): LifecycleUpdate { const trialsAttempted = skill.trialsAttempted + 1; const trialsPassed = skill.trialsPassed + (passed ? 1 : 0); const eta = trialEtaWithPrior( skill.eta, skill.trialsAttempted, skill.trialsPassed, trialsAttempted, trialsPassed, ); let status: SkillRow["status"] = skill.status; let transition: SkillLifecycleTransition | undefined; if (status === "candidate" && trialsAttempted >= cfg.candidateTrials) { if (eta >= cfg.minEtaForRetrieval) { status = "active"; transition = "promoted"; } else { status = "archived"; transition = "archived"; } } if (status === "active" && eta < cfg.archiveEta) { status = "archived"; transition = "archived"; } return { status, eta, trialsAttempted, trialsPassed, transition }; } function trialEtaWithPrior( currentEta: number, previousAttempts: number, previousPasses: number, nextAttempts: number, nextPasses: number, ): number { // Treat the pre-trial η as one pseudo-observation, then fold in the // accumulated trial record. This preserves the skill's policy-derived // prior while still letting repeated real outcomes dominate. const priorStrength = 1; const priorEta = clamp01( currentEta * (priorStrength + previousAttempts) - previousPasses, ); return clamp01((priorEta * priorStrength + nextPasses) / (priorStrength + nextAttempts)); } function applyThumbs( skill: SkillRow, sign: 1 | -1, cfg: SkillConfig, magnitudeOverride: number | undefined, ): LifecycleUpdate { const delta = (magnitudeOverride ?? cfg.etaDelta) * sign; const eta = clamp01(skill.eta + delta); let status = skill.status; let transition: SkillLifecycleTransition | undefined; if (skill.status === "archived" && eta >= cfg.minEtaForRetrieval) { status = "candidate"; transition = "promoted"; } else if (skill.status !== "archived" && eta < cfg.archiveEta) { status = "archived"; transition = "archived"; } return { status, eta, trialsAttempted: skill.trialsAttempted, trialsPassed: skill.trialsPassed, transition, }; } function applyRewardDrift( skill: SkillRow, cfg: SkillConfig, magnitude: number, ): LifecycleUpdate { const bounded = clamp01(magnitude); // Blend eta with the new policy gain (70% old, 30% new). This avoids // whiplash on a single noisy reward update. const eta = clamp01(0.7 * skill.eta + 0.3 * bounded); let status = skill.status; let transition: SkillLifecycleTransition | undefined; if (skill.status !== "archived" && eta < cfg.archiveEta) { status = "archived"; transition = "demoted"; } return { status, eta, trialsAttempted: skill.trialsAttempted, trialsPassed: skill.trialsPassed, transition, }; } function snapshot(skill: SkillRow): LifecycleUpdate { return { status: skill.status, eta: skill.eta, trialsAttempted: skill.trialsAttempted, trialsPassed: skill.trialsPassed, }; } /** * Recompute the η a freshly-built skill should carry given its source * policy's latest gain + support. Used when we detect policy drift large * enough to rebuild a skill. */ export function recomputeEta( skill: SkillRow, policy: PolicyRow, cfg: SkillConfig, ): number { if (skill.trialsAttempted > 0) return clamp01(skill.eta); const baseline = clamp01(policy.gain); return clamp01(Math.max(cfg.minEtaForRetrieval, baseline)); } /** * Decide if a skill has decayed enough to archive without any new * evidence (e.g. it has been inactive with a low-eta source policy). * Used by the orchestrator's periodic `lifecycle.tick`. */ export function shouldArchiveIdle( skill: SkillRow, idleMs: number, cfg: SkillConfig, now: number, ): boolean { if (skill.status !== "active") return false; const idleSince = skill.lastUsedAt ?? skill.createdAt; const age = now - idleSince; if (age < idleMs) return false; return skill.eta < cfg.minEtaForRetrieval; } /** * Decide whether a non-repair candidate skill should skip trial * and become active immediately. `eta` already encodes the * gain / support of the source policies (initialised at * crystallisation and kept current by reward drift), so we * don't need a separate gain / support gate. */ export function shouldPromoteCandidate( skill: SkillRow, cfg: SkillConfig, ): boolean { if (skill.status !== "candidate") return false; if (hasDecisionGuidance(skill)) return false; return skill.eta >= cfg.minEtaForRetrieval; } function hasDecisionGuidance(skill: SkillRow): boolean { const procedure = skill.procedureJson; if (!procedure || typeof procedure !== "object") return false; const guidance = (procedure as { decisionGuidance?: { preference?: unknown[]; antiPattern?: unknown[]; }; }).decisionGuidance; return Boolean( (Array.isArray(guidance?.preference) && guidance.preference.length > 0) || (Array.isArray(guidance?.antiPattern) && guidance.antiPattern.length > 0), ); } function clamp01(n: number): number { if (!Number.isFinite(n)) return 0; if (n < 0) return 0; if (n > 1) return 1; return n; }