diff --git a/apps/memos-local-plugin/agent-contract/jsonrpc.ts b/apps/memos-local-plugin/agent-contract/jsonrpc.ts index 36e5cc58b..58b435f43 100644 --- a/apps/memos-local-plugin/agent-contract/jsonrpc.ts +++ b/apps/memos-local-plugin/agent-contract/jsonrpc.ts @@ -106,6 +106,10 @@ export const RPC_METHODS = { HUB_PUBLISH: "hub.publish", HUB_PULL: "hub.pull", + // ── policies (gain maintenance) ── + POLICIES_GAIN_PREVIEW: "policies.gainPreview", + POLICIES_GAIN_ROLLBACK: "policies.gainRollback", + // ── logs ── LOGS_TAIL: "logs.tail", /** Notification: forward a log line from a non-TS adapter back into our sinks. */ diff --git a/apps/memos-local-plugin/agent-contract/memory-core.ts b/apps/memos-local-plugin/agent-contract/memory-core.ts index be0e8563d..d595853f8 100644 --- a/apps/memos-local-plugin/agent-contract/memory-core.ts +++ b/apps/memos-local-plugin/agent-contract/memory-core.ts @@ -154,6 +154,97 @@ export interface EmbeddingMaintenanceRunResult { error?: string; } +// ─── Gain repair maintenance ───────────────────────────────────────────────── + +export type GainPreviewProposedTransition = + | "promote_to_active" + | "retain_candidate" + | "retain_active" + | "none"; + +export type GainPreviewSkipReason = "no_resolved_with" | "unknown_owner"; + +export interface GainPreviewQueueState { + state: "pending" | "blocked" | "claimed"; + reason: string | null; + blockedReason: string | null; + attemptCount: number; + inferenceVersion: number; +} + +export interface GainPreviewPolicyEntry { + policyId: string; + title: string; + status: "candidate" | "active"; + support: number; + /** Currently stored gain / version (the "old" side of a repair). */ + oldGain: number; + oldGainVersion: number; + /** RAW (un-smoothed) recomputation — preview cannot rebuild it from a scalar. */ + rawGain: number; + /** EMA-smoothed gain a repair would persist. */ + newGain: number; + newGainVersion: 1 | 2; + resolvedWith: number; + resolvedWithout: number; + provenance: { liveNormalized: number; inferredNormalized: number; legacyUnscaled: number }; + excluded: { + unresolvedWith: number; + unresolvedWithout: number; + withBeyondLimit: number; + withoutBeyondLimit: number; + }; + reported: { danglingIds: number; invalidScores: number; outOfNamespace: number }; + proposedTransition: GainPreviewProposedTransition; + skipReason: GainPreviewSkipReason | null; + unknownOwner: boolean; + queue: GainPreviewQueueState | null; +} + +export interface GainPreviewResult { + policies: GainPreviewPolicyEntry[]; + total: number; + limit: number; + offset: number; + queue: { pending: number; blocked: number; claimed: number }; + /** Attempted/limit/remaining via the existing durable-budget readback. */ + budget: { attempted: number; limit: number | null; remaining: number | null; initialized: boolean }; + inferenceVersion: number; + legacy: { + groups: number; + traces: number; + postCutoverGroups: number; + postCutoverTraces: number; + unknownChronologyGroups: number; + unknownChronologyTraces: number; + }; +} + +export interface GainRollbackConflict { + journalId: string; + /** + * Null when the journal row is missing or belongs to another namespace — + * the caller learns nothing about foreign rows. + */ + policyId: string | null; + reason: + | "not_found_or_forbidden" + | "not_rollback_eligible" + | "policy_missing" + | "policy_changed" + | "duplicate_policy_entries"; + field?: "status" | "support" | "gain" | "gain_version" | "updated_at"; +} + +export type GainRollbackResult = + | { + ok: true; + batchId: string | null; + rolledBack: Array<{ journalId: string; policyId: string }>; + rolledBackAt: number; + } + | { ok: false; batchId: string | null; conflicts: GainRollbackConflict[] }; + // ─── Subscriptions ──────────────────────────────────────────────────────────── export type Unsubscribe = () => void; @@ -337,6 +428,47 @@ export interface MemoryCore { id: string, patch: { preference?: string[]; antiPattern?: string[] }, ): Promise; + // ── gain repair maintenance ── + /** + * Read-only repair preview for an EXACT namespace, paginated. Returns the + * current stored gain/version alongside the freshly recomputed raw/new + * gain, provenance/exclusion counts, the proposed transition (or skip + * reason), per-policy queue state, queue totals, the durable budget + * readback, the inference version and post-cutover/unknown-chronology + * legacy summaries. + * + * This is a sanity check, NOT a frozen approval artifact: the timer + * recomputes from fresh evidence on every tick, so a preview never + * authorizes or locks in a future repair. It performs ZERO writes. + */ + previewGainRepair(input: { + namespace: RuntimeNamespace; + limit?: number; + offset?: number; + }): Promise; + /** + * Policy-field CAS rollback of explicit journal rows (one batch or an + * explicit ID list) within an EXACT namespace. Compares ALL requested rows + * against the recorded post-write fields (status, support, gain, + * gain_version, updated_at) BEFORE any write and rejects the entire batch + * on any mismatch. On match it restores the repair-owned gain/version/ + * status, stamps a fresh updated_at, PRESERVES support and all trace/link + * data, and marks the journal rows `rolled_back` + the queue entries + * `blocked` atomically. Never restores historical timestamps, never + * refunds the budget, never touches evidence. + * + * Operational preconditions (NOT enforced in code — they are operator + * steps): pause repair first (`gainRepairBatchSize: 0`); rolled-back + * entries resume through the config re-screen generation (bump + * `gainRepairRescreenGeneration`), not a separate approval flow; keep a + * WAL-consistent SQLite backup with `quick_check` before first enable as + * disaster recovery. + */ + rollbackGainRepair(input: { + namespace: RuntimeNamespace; + batchId?: string; + journalIds?: readonly string[]; + }): Promise; /** Hard-delete a world-model row. */ deleteWorldModel(id: string): Promise<{ deleted: boolean }>; /** @@ -588,6 +720,15 @@ export interface MemoryCore { worldModels: WorldModelDTO[]; skills: SkillDTO[]; }>; + /** + * Restore a bundle-1 export. Imports never trust supplied policy + * certification or manufacture a complete reward-pass set from a partial + * bundle — imported traces keep unresolved gain scores and imported + * policies stay UNCERTIFIED (`gain_version` 1). Eligible (candidate/active) + * imported policies are queued for gain repair without changing their + * status/support; the next startup's union reconcile resolves pending vs + * blocked from real evidence. + */ importBundle(bundle: { version?: number; traces?: unknown[]; diff --git a/apps/memos-local-plugin/bridge/methods.ts b/apps/memos-local-plugin/bridge/methods.ts index 1e6cc27a0..f18f78fda 100644 --- a/apps/memos-local-plugin/bridge/methods.ts +++ b/apps/memos-local-plugin/bridge/methods.ts @@ -348,6 +348,58 @@ export function makeDispatcher( // but we route it via a notification on the events stream instead. // Leaving a branch here would be dead code; we intentionally drop. + // ── policies (gain maintenance) ── + case RPC_METHODS.POLICIES_GAIN_PREVIEW: { + const p = asRecord(params, method); + const ns = namespaceParam(p); + // Exact namespace is mandatory: preview must never guess a namespace + // and leak another owner's rows. + if (!ns) { + throw new MemosError( + "invalid_argument", + `${method}: 'namespace' is required (exact namespace)`, + ); + } + return await core.previewGainRepair({ + namespace: ns, + limit: typeof p.limit === "number" ? p.limit : undefined, + offset: typeof p.offset === "number" ? p.offset : undefined, + }); + } + case RPC_METHODS.POLICIES_GAIN_ROLLBACK: { + const p = asRecord(params, method); + const ns = namespaceParam(p); + if (!ns) { + throw new MemosError( + "invalid_argument", + `${method}: 'namespace' is required (exact namespace)`, + ); + } + const batchId = + typeof p.batchId === "string" && p.batchId.length > 0 ? p.batchId : undefined; + const journalIds = Array.isArray(p.journalIds) + ? (p.journalIds as unknown[]).filter( + (id): id is string => typeof id === "string" && id.length > 0, + ) + : undefined; + if (batchId !== undefined && journalIds !== undefined) { + throw new MemosError( + "invalid_argument", + `${method}: pass either 'batchId' or 'journalIds', not both`, + ); + } + if (batchId !== undefined) { + return await core.rollbackGainRepair({ namespace: ns, batchId }); + } + if (journalIds !== undefined && journalIds.length > 0) { + return await core.rollbackGainRepair({ namespace: ns, journalIds }); + } + throw new MemosError( + "invalid_argument", + `${method}: exactly one of 'batchId' / non-empty 'journalIds' is required`, + ); + } + // ── config / hub ── case RPC_METHODS.CONFIG_GET: case RPC_METHODS.CONFIG_PATCH: diff --git a/apps/memos-local-plugin/core/config/defaults.ts b/apps/memos-local-plugin/core/config/defaults.ts index 2575a8405..a0a893f33 100644 --- a/apps/memos-local-plugin/core/config/defaults.ts +++ b/apps/memos-local-plugin/core/config/defaults.ts @@ -203,6 +203,16 @@ export const DEFAULT_CONFIG: ResolvedConfig = { traceCharCap: 3_000, gainEmaAlpha: 0.4, archiveGain: -0.05, + // v2 gain scoring is OFF by default; the operator flips it on + // after a schema-only migration + inference pass + preview inspection. + gainV2Enabled: false, + minGainValue: 0.02, + gainRepairBatchSize: 0, + gainRepairIntervalMs: 900_000, + gainRepairMaxTotal: null, + gainRepairRescreenGeneration: 0, + gainInferenceBootMaxGroups: 2_000, + gainInferenceBootTimeBudgetMs: 30_000, }, l3Abstraction: { // Lowered from 3 → 2. The original threshold required THREE diff --git a/apps/memos-local-plugin/core/config/schema.ts b/apps/memos-local-plugin/core/config/schema.ts index 9cd79b612..8b2fe738d 100644 --- a/apps/memos-local-plugin/core/config/schema.ts +++ b/apps/memos-local-plugin/core/config/schema.ts @@ -313,6 +313,56 @@ const AlgorithmSchema = Type.Object({ gainEmaAlpha: NumberInRange(0.4, 0, 1), /** Archive active policies whose gain dips below this value. */ archiveGain: NumberInRange(-0.05, -1, 1), + /** + * use `gainValue` (clamp(N·V, -1, 1)) for L2 gain/induction and + * permit configured repair. Disabled keeps legacy `minTraceValue` + * semantics on V; disabling v2 after v2 gains are written is not a clean + * semantic rollback. + */ + gainV2Enabled: Bool(false), + /** resolved `gainValue` induction floor for enabled mode. */ + minGainValue: NumberInRange(0.02, -1, 1), + /** + * repair attempts per timer tick (integer 0..25); 0 pauses + * repair while retaining v2 scoring. + */ + gainRepairBatchSize: Type.Integer({ default: 0, minimum: 0, maximum: 25 }), + /** + * timer cadence (integer ms, 60000..86399999); default 15 + * minutes, always faster than daily. + */ + gainRepairIntervalMs: Type.Integer({ default: 900_000, minimum: 60_000, maximum: 86_399_999 }), + /** + * durable absolute total-attempt ceiling since initial enable. + * `null`/omitted = unlimited; otherwise a nonnegative integer. Raising + * it permits more attempts; lowering it below attempted pauses new + * attempts; clearing to null removes the ceiling without resetting the + * counter. Restarts/pause/resume/enable toggles never reset/refund it. + */ + gainRepairMaxTotal: Type.Union( + [Type.Integer({ minimum: 0 }), Type.Null()], + { default: null }, + ), + /** + * config-driven re-screen generation. A nonnegative integer; + * increasing it requests one re-screen of blocked evidence at the current + * inference version (consumed once, cannot bypass the attempt budget). + */ + gainRepairRescreenGeneration: Type.Integer({ default: 0, minimum: 0 }), + /** + * override for the per-boot historical inference pass's group + * cap (`GAIN_INFERENCE_BOOT_MAX_GROUPS` default). Raise on hosts whose + * backlog doesn't converge in one restart under the time budget; a large + * corpus can need several thousand groups per pass. + */ + gainInferenceBootMaxGroups: Type.Integer({ default: 2_000, minimum: 1 }), + /** + * override for the per-boot historical inference pass's time + * budget in ms (`GAIN_INFERENCE_BOOT_TIME_BUDGET_MS` default). Capped + * well under `bridge.initWatchdogMs` so a large backlog still can't stall + * startup past the watchdog. + */ + gainInferenceBootTimeBudgetMs: Type.Integer({ default: 30_000, minimum: 1_000, maximum: 300_000 }), }, { default: {} }), l3Abstraction: Type.Object({ /** Minimum number of compatible active L2 policies to trigger an L3 abstraction. */ diff --git a/apps/memos-local-plugin/core/experience/feedback-builder.ts b/apps/memos-local-plugin/core/experience/feedback-builder.ts index f5a948d40..9c1222bbc 100644 --- a/apps/memos-local-plugin/core/experience/feedback-builder.ts +++ b/apps/memos-local-plugin/core/experience/feedback-builder.ts @@ -118,6 +118,11 @@ export async function runFeedbackExperience( boundary: draft.boundary, support: 1, gain: Math.max(0.02, draft.salience), + // feedback-derived salience is NOT a shared v2 gainValue + // calculation: the row stays uncertified until a real v2 pass certifies + // it. Explicit feedback activation (status above) is distinct from the + // L2 evidence gate and is preserved as-is. + gainVersion: 1, status: draft.salience >= 0.5 ? "active" : "candidate", experienceType: draft.type, evidencePolarity: draft.polarity, @@ -400,6 +405,10 @@ function mergePolicy( ...existing, support: Math.max(1, existing.support) + 1, gain: Math.max(existing.gain, draft.salience, 0.02), + // a feedback/salience-derived gain overwrite invalidates v2 + // certification: only an actual shared gainValue calculation certifies v2, + // never a blanket merge. + gainVersion: 1, status: existing.status === "archived" ? existing.status : "active", experienceType: skillEligible && polarity === "mixed" ? "repair_validated" diff --git a/apps/memos-local-plugin/core/memory/l2/gain-maintenance.ts b/apps/memos-local-plugin/core/memory/l2/gain-maintenance.ts new file mode 100644 index 000000000..9eee25a80 --- /dev/null +++ b/apps/memos-local-plugin/core/memory/l2/gain-maintenance.ts @@ -0,0 +1,544 @@ +/** + * `gain-maintenance.ts` — maintenance back-end: the + * read-only `policies.gainPreview` and the policy-field CAS + * `policies.gainRollback`. Called ONLY from the two `MemoryCore` maintenance + * methods (which resolve the exact namespace + live config slice); the timer + * engine (`gain-repair.ts`) never calls this module and this module never + * calls the engine. + * + * Preview is a sanity check, not a frozen approval artifact: it recomputes + * through the shared helper in `preview` mode (which forces v2 scoring + * regardless of the enable flag) and performs ZERO writes — no queue/journal/ + * policy/trace/kv mutation of any kind. The timer recomputes from fresh + * evidence on every tick, so a preview neither authorizes nor locks in a + * future repair. + * + * Rollback restores ONLY repair-owned policy fields (gain/version/status) to + * their journaled pre-repair values after a five-field CAS + * (status/support/gain/gain_version/updated_at) against the recorded + * post-write state. It preserves support and every trace/link row, stamps a + * FRESH updated_at (historical timestamps are never restored), marks the + * journal rows `rolled_back` and parks the queue entries `blocked` + * atomically, and never refunds the budget counter. + * + * Operational notes (operator steps, NOT code enforcement): + * + * • Pause repair BEFORE assessing a rollback (`gainRepairBatchSize: 0` + * while retaining v2 scoring); otherwise the timer can re-repair a + * rolled-back entry on the next tick and the CAS will rightly refuse a + * second rollback of the same batch. + * • Resumption of rolled-back entries uses the config re-screen generation + * (bump `gainRepairRescreenGeneration`): the re-screen un-stamps their + * evidence, re-screens it at the current inference version and requeues + * the entries when resolved. There is no separate approval or requeue + * flow, and preview stays read-only. + * • Journal history stays available through the existing journal reads + * (`getJournalById` / `listJournalByBatch`); no dedicated journal-listing + * RPC was added. + * • Before first enable, take a WAL-consistent SQLite backup and verify it + * with `quick_check` as disaster recovery. That snapshot is the restore + * path for operational mistakes; `gainRollback` is a policy-field repair + * tool, not a substitute for full-database restore. + */ + +import { MemosError } from "../../../agent-contract/errors.js"; +import type { + GainPreviewPolicyEntry, + GainPreviewResult, + GainRollbackConflict, + GainRollbackResult, +} from "../../../agent-contract/memory-core.js"; +import { + GAIN_INFERENCE_VERSION, + GAIN_POST_CUTOVER_BOUNDARY_MS, +} from "../../reward/gain-inference.js"; +import type { Repos } from "../../storage/repos/index.js"; +import type { GainRepairJournalRow } from "../../storage/repos/gain-repair.js"; +import type { StorageDb } from "../../storage/types.js"; +import type { PolicyId, PolicyRow } from "../../types.js"; +import { + namespaceFromOwner, + readGainRepairBudget, + type GainRepairOwner, +} from "./gain-repair.js"; +import { isExactOwner } from "../../storage/repos/_helpers.js"; +import { recomputePolicyGain } from "./recompute-gain.js"; +import type { L2Config } from "./types.js"; + +export interface GainMaintenanceDeps { + db: StorageDb; + repos: Pick< + Repos, + "episodes" | "gainRepair" | "kv" | "policies" | "tracePolicyLinks" | "traces" + >; + /** Live L2 config slice (same shape the timer builds per tick). */ + config: L2Config; + /** Exact namespace of the caller — every read and write is scoped to it. */ + owner: GainRepairOwner; + /** Live promotion thresholds (repair never archives, so no archive gate). */ + thresholds: { minSupport: number; minGain: number }; + inferenceVersion?: number; + now?: () => number; +} + +export interface GainPreviewOptions { + limit?: number; + offset?: number; +} + +export interface GainRollbackOptions { + batchId?: string; + journalIds?: readonly string[]; +} + +// ─── policies.gainPreview ──────────────────────────────────────────────────── + +export function previewGainRepair( + deps: GainMaintenanceDeps, + opts: GainPreviewOptions = {}, +): GainPreviewResult { + const limit = Math.max(1, Math.min(500, Math.floor(opts.limit ?? 50))); + const offset = Math.max(0, Math.floor(opts.offset ?? 0)); + const { owner } = deps; + const version = deps.inferenceVersion ?? GAIN_INFERENCE_VERSION; + const namespace = namespaceFromOwner(owner); + + // Repair universe: candidate/active policies of the EXACT namespace, read + // in ONE owner-scoped query (statusIn) so the two statuses are atomic — + // a policy created between two separate status reads can no longer appear + // twice or be silently missed. Total comes from a matching COUNT; both + // stay SQL-side so no candidate/active universe is hydrated to page. + const ownerFilter = { + ownerAgentKind: owner.ownerAgentKind, + ownerProfileId: owner.ownerProfileId, + ownerWorkspaceId: owner.ownerWorkspaceId ?? null, + } as const; + const total = deps.repos.policies.count({ + ...ownerFilter, + statusIn: ["candidate", "active"], + }); + const policies = deps.repos.policies + .list({ + ...ownerFilter, + statusIn: ["candidate", "active"], + limit: 100_000, + offset: 0, + }) + .filter((p) => isExactOwner(p, owner)); + + const queueByPolicy = new Map(); + let queuePending = 0; + let queueBlocked = 0; + let queueClaimed = 0; + for (const row of deps.repos.gainRepair.listByOwnerAndStates(owner, [ + "pending", + "blocked", + "claimed", + ])) { + queueByPolicy.set(String(row.policyId), { + state: row.state, + reason: row.reason, + blockedReason: row.blockedReason, + attemptCount: row.attemptCount, + inferenceVersion: row.inferenceVersion, + }); + if (row.state === "pending") queuePending += 1; + else if (row.state === "blocked") queueBlocked += 1; + else queueClaimed += 1; + } + + const entries: GainPreviewPolicyEntry[] = policies.map((policy) => { + const recomputed = recomputePolicyGain( + { policy, namespace, config: deps.config, mode: "preview" }, + { + episodes: deps.repos.episodes, + traces: deps.repos.traces, + tracePolicyLinks: deps.repos.tracePolicyLinks, + }, + ); + let proposedTransition: GainPreviewPolicyEntry["proposedTransition"]; + // Unknown-owner policies are reported with their computed values but + // never proposed for auto-mutation: the timer must not touch them, so + // the preview must not suggest that it will. + const skipReason = + recomputed.skipReason ?? (recomputed.unknownOwner ? "unknown_owner" : null); + if (skipReason !== null) { + proposedTransition = "none"; + } else if (policy.status === "active") { + // Timer repair refreshes active gains but never archives. + proposedTransition = "retain_active"; + } else if ( + policy.support >= deps.thresholds.minSupport && + recomputed.persistedGain >= deps.thresholds.minGain + ) { + proposedTransition = "promote_to_active"; + } else { + proposedTransition = "retain_candidate"; + } + return { + policyId: String(policy.id), + title: policy.title, + // The universe above selects candidate/active only; archived rows + // never reach this mapping. + status: policy.status as "candidate" | "active", + support: policy.support, + oldGain: policy.gain, + oldGainVersion: policy.gainVersion ?? 1, + rawGain: recomputed.raw.gain, + newGain: recomputed.persistedGain, + newGainVersion: recomputed.gainVersion, + resolvedWith: recomputed.selectedWithIds.length, + resolvedWithout: recomputed.selectedWithoutIds.length, + provenance: { ...recomputed.provenance }, + excluded: { ...recomputed.excluded }, + reported: { ...recomputed.reported }, + proposedTransition, + skipReason, + unknownOwner: recomputed.unknownOwner, + queue: queueByPolicy.get(String(policy.id)) ?? null, + }; + }); + + // Rank: candidates first (timer selection order), then proposed gain desc, + // support desc, policy-ID asc. Deterministic across calls. + entries.sort((a, b) => { + const statusRank = (s: string): number => (s === "candidate" ? 0 : 1); + const rank = statusRank(a.status) - statusRank(b.status); + if (rank !== 0) return rank; + if (a.newGain !== b.newGain) return b.newGain - a.newGain; + if (a.support !== b.support) return b.support - a.support; + return a.policyId < b.policyId ? -1 : a.policyId > b.policyId ? 1 : 0; + }); + + return { + policies: entries.slice(offset, offset + limit), + total, + limit, + offset, + queue: { + pending: queuePending, + blocked: queueBlocked, + claimed: queueClaimed, + }, + budget: readGainRepairBudget(deps.repos.kv, owner, deps.config.gainRepairMaxTotal), + inferenceVersion: version, + legacy: legacySummary(deps, owner), + }; +} + +/** + * Current-state legacy_unscaled summary for the exact namespace, grouped by + * stored episode: group/trace totals plus the post-cutover cohort (newest + * member on/after 2026-06-22T00:00:00Z) and the unknown-chronology cohort. + * Chronology follows the inference predicate (any member ts ≤ 0/non-finite + * taints the whole group); the date is an audit boundary, never a gate. + */ +function legacySummary( + deps: GainMaintenanceDeps, + owner: GainRepairOwner, +): GainPreviewResult["legacy"] { + const rows = deps.db.prepare< + { kind: string; profile: string; workspace_id: string | null }, + { episode_id: string; n: number; newest_ts: number | null; unknown_n: number } + >( + `SELECT episode_id AS episode_id, COUNT(*) AS n, MAX(ts) AS newest_ts, + SUM(CASE WHEN ts IS NULL OR ts <= 0 THEN 1 ELSE 0 END) AS unknown_n + FROM traces + WHERE owner_agent_kind = @kind + AND owner_profile_id = @profile + AND owner_workspace_id IS @workspace_id + AND gain_value_source = 'legacy_unscaled' + GROUP BY episode_id`, + ).all({ + kind: owner.ownerAgentKind, + profile: owner.ownerProfileId, + workspace_id: owner.ownerWorkspaceId ?? null, + }); + const out = { + groups: 0, + traces: 0, + postCutoverGroups: 0, + postCutoverTraces: 0, + unknownChronologyGroups: 0, + unknownChronologyTraces: 0, + }; + for (const r of rows) { + out.groups += 1; + out.traces += r.n; + const newest = r.newest_ts; + const unknown = r.unknown_n > 0 || newest == null || !Number.isFinite(newest); + if (unknown) { + out.unknownChronologyGroups += 1; + out.unknownChronologyTraces += r.n; + } else if (newest >= GAIN_POST_CUTOVER_BOUNDARY_MS) { + out.postCutoverGroups += 1; + out.postCutoverTraces += r.n; + } + } + return out; +} + +// ─── policies.gainRollback ─────────────────────────────────────────────────── + +interface RollbackCandidate { + journalId: string; + policyId: PolicyId; + policy: PolicyRow; + oldGain: number; + oldGainVersion: number; + oldStatus: "candidate" | "active"; + newGain: number; + newGainVersion: number; + newStatus: string; + newSupport: number; + newUpdatedAt: number; + inferenceVersion: number; +} + +/** + * Restore repair-owned policy fields after a five-field CAS. Compare phase + * loads and checks EVERY requested row before the single apply transaction; + * any mismatch rejects the whole batch with zero writes. Exact-namespace + * authorization runs BEFORE any field comparison, even when fields would + * match: foreign rows resolve to `not_found_or_forbidden` with a null + * policyId so nothing leaks. + */ +export function rollbackGainRepair( + deps: GainMaintenanceDeps, + opts: GainRollbackOptions, +): GainRollbackResult { + const batchId = + typeof opts.batchId === "string" && opts.batchId.length > 0 ? opts.batchId : null; + const journalIds = Array.isArray(opts.journalIds) + ? opts.journalIds.filter((id): id is string => typeof id === "string" && id.length > 0) + : []; + if (batchId === null && journalIds.length === 0) { + throw new MemosError( + "invalid_argument", + "policies.gainRollback: exactly one of 'batchId' / non-empty 'journalIds' is required", + ); + } + if (batchId !== null && journalIds.length > 0) { + throw new MemosError( + "invalid_argument", + "policies.gainRollback: pass either 'batchId' or 'journalIds', not both", + ); + } + const { owner } = deps; + + // ── Resolve the requested rows (reads only) ── + const requested: Array<{ journalId: string; row: GainRepairJournalRow | null }> = []; + if (batchId !== null) { + const rows = deps.repos.gainRepair.listJournalByBatch(batchId); + const ownRows = rows.filter((r) => isExactOwner(r, owner)); + if (ownRows.length === 0) { + // A batch with no rows in this namespace (foreign-only, or not at all) + // is simply unknown here — the error reveals nothing about foreign rows. + throw new MemosError( + "invalid_argument", + "policies.gainRollback: unknown batch in this namespace", + ); + } + for (const row of rows) { + // Foreign-namespace batch rows are included as conflicts (not silently + // dropped), matching the journalIds path behavior and preventing + // partial cross-namespace rollbacks. A batch with no own rows at all + // takes the unknown-batch path above, so its existence is not revealed. + if (!isExactOwner(row, owner)) { + requested.push({ journalId: row.id, row: null }); + } else { + requested.push({ journalId: row.id, row }); + } + } + } else { + for (const journalId of journalIds) { + const row = deps.repos.gainRepair.getJournalById(journalId); + requested.push({ journalId, row: row && isExactOwner(row, owner) ? row : null }); + } + } + + // ── Compare phase: every row must prove eligibility + CAS match ── + const conflicts: GainRollbackConflict[] = []; + let candidates: RollbackCandidate[] = []; + const seenPolicies = new Map(); + const originalRetracted = new Set(); + for (const { journalId, row } of requested) { + if (!row) { + conflicts.push({ journalId, policyId: null, reason: "not_found_or_forbidden" }); + continue; + } + const eligible = + row.result === "completed" && + row.policyId != null && + row.oldGain != null && + row.newGain != null && + row.oldGainVersion != null && + row.newGainVersion != null && + row.oldStatus != null && + row.newStatus != null && + row.oldSupport != null && + row.newSupport != null && + row.newUpdatedAt != null && + (row.oldStatus === "candidate" || row.oldStatus === "active"); + if (!eligible) { + // Pending/blocked/conflicted/failed/rolled-back rows and completed rows + // that never recorded the post-write timestamp can never prove + // post-write ownership. + conflicts.push({ + journalId, + policyId: row.policyId != null ? String(row.policyId) : null, + reason: "not_rollback_eligible", + }); + continue; + } + const policyKey = String(row.policyId); + const firstSeen = seenPolicies.get(policyKey); + if (firstSeen !== undefined) { + // A policy appears twice in the same request: retract the earlier + // candidate AND report the FULL duplicate pair as conflicts so the + // caller sees both journal rows, not just the second occurrence. + // Only the first retraction reports the original's journal ID; any + // further repeats of the same policy add only their own row. + if (!originalRetracted.has(policyKey)) { + candidates = candidates.filter((c) => String(c.policyId) !== policyKey); + conflicts.push({ journalId: firstSeen, policyId: policyKey, reason: "duplicate_policy_entries" }); + originalRetracted.add(policyKey); + } + conflicts.push({ journalId, policyId: policyKey, reason: "duplicate_policy_entries" }); + continue; + } + seenPolicies.set(policyKey, journalId); + const policy = deps.repos.policies.getById(row.policyId as PolicyId); + if (!policy) { + conflicts.push({ journalId, policyId: policyKey, reason: "policy_missing" }); + continue; + } + if (!isExactOwner(policy, owner)) { + conflicts.push({ journalId, policyId: null, reason: "not_found_or_forbidden" }); + continue; + } + const cas: Array<{ + field: NonNullable; + current: number | string; + recorded: number | string; + }> = [ + { field: "status", current: policy.status, recorded: row.newStatus as string }, + { field: "support", current: policy.support, recorded: row.newSupport as number }, + { field: "gain", current: policy.gain, recorded: row.newGain as number }, + { + field: "gain_version", + current: policy.gainVersion ?? 1, + recorded: row.newGainVersion as number, + }, + { field: "updated_at", current: policy.updatedAt, recorded: row.newUpdatedAt as number }, + ]; + const mismatch = cas.find((c) => c.current !== c.recorded); + if (mismatch) { + // A newer support/gain/status/timestamp write after the repair owns + // this policy now — never overwrite it. No evidence/link/config + // comparison is performed: unrelated evidence changes do not block + // rollback while the five policy fields still match. + conflicts.push({ + journalId, + policyId: policyKey, + reason: "policy_changed", + field: mismatch.field, + }); + continue; + } + candidates.push({ + journalId, + policyId: row.policyId as PolicyId, + policy, + oldGain: row.oldGain as number, + oldGainVersion: row.oldGainVersion as number, + oldStatus: row.oldStatus as "candidate" | "active", + newGain: row.newGain as number, + newGainVersion: row.newGainVersion as number, + newStatus: row.newStatus as string, + newSupport: row.newSupport as number, + newUpdatedAt: row.newUpdatedAt as number, + inferenceVersion: row.inferenceVersion, + }); + } + if (conflicts.length > 0) { + return { ok: false, batchId, conflicts }; + } + + // ── Apply phase: one atomic transaction for the whole batch ── + const now = deps.now?.() ?? Date.now(); + const result = deps.db.tx((): GainRollbackResult => { + // Re-read every candidate policy inside the write transaction and repeat + // the five-field comparisons: a concurrent write that landed between the + // compare phase and this transaction would otherwise be silently + // overwritten. Any drift aborts the whole batch with zero writes. + const driftConflicts: GainRollbackConflict[] = []; + for (const c of candidates) { + const current = deps.repos.policies.getById(c.policyId); + if (!current) { + driftConflicts.push({ + journalId: c.journalId, + policyId: String(c.policyId), + reason: "policy_missing", + }); + continue; + } + const cas: Array<{ + field: NonNullable; + current: number | string; + recorded: number | string; + }> = [ + { field: "status", current: current.status, recorded: c.newStatus }, + { field: "support", current: current.support, recorded: c.newSupport }, + { field: "gain", current: current.gain, recorded: c.newGain }, + { + field: "gain_version", + current: current.gainVersion ?? 1, + recorded: c.newGainVersion, + }, + { field: "updated_at", current: current.updatedAt, recorded: c.newUpdatedAt }, + ]; + const mismatch = cas.find((x) => x.current !== x.recorded); + if (mismatch) { + driftConflicts.push({ + journalId: c.journalId, + policyId: String(c.policyId), + reason: "policy_changed", + field: mismatch.field, + }); + } + } + if (driftConflicts.length > 0) { + return { ok: false, batchId, conflicts: driftConflicts }; + } + const out: Array<{ journalId: string; policyId: string }> = []; + for (const c of candidates) { + deps.repos.policies.updateStats(c.policyId, { + // Restore ONLY the repair-owned fields; support is preserved and + // updated_at is fresh — historical timestamps are never restored. + support: c.policy.support, + gain: c.oldGain, + gainVersion: c.oldGainVersion, + status: c.oldStatus, + updatedAt: now, + }); + deps.repos.gainRepair.setJournalResult(c.journalId, "rolled_back"); + // Park the queue entry blocked: resumption goes through the config + // re-screen generation (operator bumps `gainRepairRescreenGeneration`), + // never a separate approval flow. + deps.repos.gainRepair.upsertBlocked({ + policyId: c.policyId, + ownerAgentKind: owner.ownerAgentKind, + ownerProfileId: owner.ownerProfileId, + ownerWorkspaceId: owner.ownerWorkspaceId ?? null, + reason: "manual", + blockedReason: "rolled_back", + inferenceVersion: c.inferenceVersion, + now, + }); + out.push({ journalId: c.journalId, policyId: String(c.policyId) }); + } + return { ok: true, batchId, rolledBack: out, rolledBackAt: now }; + }); + return result; +} diff --git a/apps/memos-local-plugin/core/memory/l2/gain-repair.ts b/apps/memos-local-plugin/core/memory/l2/gain-repair.ts new file mode 100644 index 000000000..58fc51078 --- /dev/null +++ b/apps/memos-local-plugin/core/memory/l2/gain-repair.ts @@ -0,0 +1,828 @@ +/** + * `gain-repair.ts` — per-policy attempt engine. + * + * Used ONLY by the repair timer (core/pipeline/memory-core.ts); ordinary L2, + * preview and rollback never call it. The engine owns the durable total-attempt + * budget (kv, `pipeline..v1` — no separate budget table), the per-policy + * reservation + recompute transactions, the interrupted-claim reconcile and the + * config-generation re-screen. Policy field writes happen here (under a short + * per-policy transaction) — that is the timer exception to the + * "transaction layer owns policy writes" rule, because the timer has no other + * transaction layer of its own. + * + * Atomicity contract: + * + * • Reservation (one tx): read budget from kv, refuse when the absolute + * ceiling is exhausted, claim the queue entry, increment the budget and + * journal an attempt/claim (`result='pending'`) — ALL before any repair + * work. A crash after reservation consumes that attempt; the next tick's + * interrupted-claim reconcile resets the queue entry to `pending` and marks + * the orphaned journal row `failed` WITHOUT replaying a committed repair. + * Any retry is a new budgeted attempt. + * + * • Per-policy transaction (one tx): re-read the policy + evidence fresh + * (no review-token/fingerprint scheme), detect concurrent policy changes + * (journal `conflicted`, leave pending, never overwrite), recompute gain + * via the shared helper, then commit policy fields + final queue state + * + journal outcome together. `unknown_owner` / `no_resolved_with` become + * `blocked` (budget consumed, policy never mutated). Active policies never + * archive; support never increments; no LLM calls; no synthetic L2 events. + * + * Outcomes consume budget: blocked, conflicted and failed. Natural-touch + * reconciliation (already-v2 / already-repaired rows) and ordinary L2 + * updates/promotions do NOT consume budget. Re-screen never consumes budget + * and never writes policy fields. + */ + +import { ids } from "../../id.js"; +import type { Logger } from "../../logger/types.js"; +import type { Repos } from "../../storage/repos/index.js"; +import type { makeKvRepo } from "../../storage/repos/kv.js"; +import type { StorageDb } from "../../storage/types.js"; +import type { PolicyId, PolicyRow, RuntimeNamespace, TraceId } from "../../types.js"; +import { GAIN_INFERENCE_VERSION, runGainInference } from "../../reward/gain-inference.js"; +import type { GainRepairQueueRow } from "../../storage/repos/gain-repair.js"; +import { + isBorrowedEvidence, + isResolvedGainValue, + normalizeOwner, + recomputePolicyGain, + type RecomputeGainResult, +} from "./recompute-gain.js"; +import type { L2Config } from "./types.js"; + +// ─── Durable budget (kv, namespace-prefixed, pipeline..v1) ───────────── + +export const GAIN_REPAIR_BUDGET_KEY = "pipeline.gain_repair_budget.v1"; +export const GAIN_REPAIR_RESCREEN_KEY = "pipeline.gain_repair_rescreen.v1"; +export const GAIN_REPAIR_ALGORITHM_VERSION = "gain-repair.v1"; + +export interface GainRepairOwner { + ownerAgentKind: string; + ownerProfileId: string; + ownerWorkspaceId?: string | null; +} + +export interface GainRepairBudgetState { + /** Attempts consumed since the first enabled campaign. Never reset/refunded. */ + attempted: number; + /** Live absolute ceiling from config; null = unlimited. */ + limit: number | null; + /** null when unlimited; otherwise max(0, limit − attempted). */ + remaining: number | null; + /** False until the first enabled tick initializes the counter. */ + initialized: boolean; +} + +/** Namespace-prefixed budget key — one counter per exact owner/namespace. */ +export function gainRepairBudgetKey(owner: GainRepairOwner): string { + return `${GAIN_REPAIR_BUDGET_KEY}.${ownerSuffix(owner)}`; +} + +export function gainRepairRescreenKey(owner: GainRepairOwner): string { + return `${GAIN_REPAIR_RESCREEN_KEY}.${ownerSuffix(owner)}`; +} + +/** + * Bijective owner suffix for durable kv keys. A delimiter-joined string + * would collide across owners whose fields contain the delimiter (or where + * a null workspace is indistinguishable from a literal `"default"`); a + * compact JSON array keeps every exact owner distinct. + */ +function ownerSuffix(owner: GainRepairOwner): string { + return JSON.stringify([owner.ownerAgentKind, owner.ownerProfileId, owner.ownerWorkspaceId ?? null]); +} + +interface StoredBudget { + attempted: number; + initializedAt: number; +} + +/** + * Parse the persisted attempt counter. A present-but-malformed value (wrong + * JSON shape, non-finite number, negative) is treated as CORRUPT, not as + * zero: callers fail closed (no new attempts) so corrupt kv JSON can neither + * reset the counter nor bypass the ceiling. The corrupt value itself is never + * overwritten here — only an explicit operator clear removes it. + */ +function parseBudgetAttempted(stored: StoredBudget | null): number | null { + if (!stored) return 0; + const raw = (stored as { attempted?: unknown }).attempted; + if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) return null; + return Math.floor(raw); +} + +export function readGainRepairBudget( + kv: ReturnType, + owner: GainRepairOwner, + maxTotal: number | null, +): GainRepairBudgetState { + const stored = kv.get(gainRepairBudgetKey(owner), null); + const parsed = parseBudgetAttempted(stored); + const attempted = parsed ?? 0; + return { + attempted, + limit: maxTotal, + // Corrupt counters report zero remaining (fail closed) rather than a + // fresh allowance. + remaining: maxTotal === null ? null : parsed === null ? 0 : Math.max(0, maxTotal - attempted), + initialized: stored != null, + }; +} + +// ─── Engine deps + result ──────────────────────────────────────────────────── + +export interface GainRepairAttemptDeps { + db: StorageDb; + repos: Pick< + Repos, + | "episodes" + | "gainRepair" + | "kv" + | "policies" + | "tracePolicyLinks" + | "traces" + >; + /** Live config slice (extractAlgorithmConfig output) — re-read every tick. */ + config: L2Config; + /** Exact namespace of the timer owner. */ + owner: GainRepairOwner; + /** Live promotion thresholds (minSupport/minGain); archive is never used. */ + thresholds: { minSupport: number; minGain: number; archiveGain: number }; + log: Logger; + now?: () => number; + inferenceVersion?: number; + /** + * Test seam — the shared recompute core, injectable so unit tests can force + * an unexpected item failure. Never wired from the timer. + */ + recomputePolicyGainFn?: typeof recomputePolicyGain; +} + +export interface GainRepairTickCounts { + attempted: number; + rescored: number; + promoted: number; + blocked: number; + conflicted: number; + failed: number; + reconciled: number; +} + +export interface GainRepairTickResult extends GainRepairTickCounts { + batchId: string; + budget: { attempted: number; limit: number | null; remaining: number | null }; + inferenceVersion: number; + rescreenConsumed: boolean; + durationMs: number; +} + +export type GainRepairReservationOutcome = + | { kind: "reserved"; journalId: string; policy: PolicyRow } + | { kind: "budget_exhausted" } + | { kind: "reconciled" } + | { kind: "not_pending" }; + +export type GainRepairApplyOutcome = + | { kind: "completed" } + | { kind: "promoted" } + | { kind: "blocked" } + | { kind: "conflicted" } + | { kind: "failed" } + | { kind: "reconciled" }; + +// ─── Per-policy reservation (atomic: budget + claim + journal) ─────────────── + +/** + * Atomically reserve one budget unit, claim the queue entry and journal an + * attempt BEFORE any repair work. Returns `budget_exhausted` when the absolute + * ceiling is reached (the tick must stop), `not_pending` when another writer + * already claimed the entry (skip, no budget consumed), and `reconciled` when + * the entry turned out to be naturally repaired / archived / missing (no + * budget consumed, queue cleaned). If this transaction throws, the tick stops + * before any repair work. + */ +export function reserveGainRepairAttempt( + deps: GainRepairAttemptDeps, + policyId: PolicyId, + batchId: string, +): GainRepairReservationOutcome { + const owner = deps.owner; + const budgetKey = gainRepairBudgetKey(owner); + const now = deps.now?.() ?? Date.now(); + return deps.db.tx(() => { + const stored = deps.repos.kv.get(budgetKey, null); + const attempted = parseBudgetAttempted(stored); + if (attempted === null) { + // Corrupt counter: fail closed (stop the tick) without writing — never + // "repair" it into a fresh zero that would re-open spent budget. Log + // the exact key + owner so the operator can find and clear it. + deps.log.warn("gain_repair.budget_corrupt", { + budgetKey, + ownerAgentKind: owner.ownerAgentKind, + ownerProfileId: owner.ownerProfileId, + ownerWorkspaceId: owner.ownerWorkspaceId ?? null, + }); + return { kind: "budget_exhausted" } as const; + } + const limit = deps.config.gainRepairMaxTotal; + if (limit !== null && attempted >= limit) { + return { kind: "budget_exhausted" } as const; + } + const entry = deps.repos.gainRepair.getByPolicy(policyId); + if (!entry || entry.state !== "pending") { + return { kind: "not_pending" } as const; + } + if ( + entry.ownerAgentKind !== owner.ownerAgentKind || + entry.ownerProfileId !== owner.ownerProfileId || + (entry.ownerWorkspaceId ?? null) !== (owner.ownerWorkspaceId ?? null) + ) { + // Defensive: queue selection is owner-scoped, but a tick must never + // claim another namespace's entry even if selection ever widened. Skip + // without consuming budget. + return { kind: "not_pending" } as const; + } + const policy = deps.repos.policies.getById(policyId); + if (!policy || policy.status === "archived") { + // Archived/missing policies are never repair targets — reconcile away. + deps.repos.gainRepair.removeByPolicy(policyId); + return { kind: "reconciled" } as const; + } + if (isNaturallyRepaired(policy, entry)) { + // Already v2-certified with live support and no inference invalidation: + // reconcile WITHOUT recomputation — no duplicate EMA, no budget unit. + deps.repos.gainRepair.removeByPolicy(policyId); + return { kind: "reconciled" } as const; + } + // Reserve: budget + claim + journal in one transaction. + deps.repos.kv.set(budgetKey, { attempted: attempted + 1, initializedAt: stored?.initializedAt ?? now }); + deps.repos.gainRepair.setQueueState(policyId, { + state: "claimed", + attemptCount: entry.attemptCount + 1, + lastAttemptAt: now, + lastAttemptBatchId: batchId, + now, + }); + const journalId = ids.uuid(); + deps.repos.gainRepair.insertJournal({ + id: journalId, + batchId, + ownerAgentKind: owner.ownerAgentKind, + ownerProfileId: owner.ownerProfileId, + ownerWorkspaceId: owner.ownerWorkspaceId ?? null, + policyId, + oldGain: policy.gain, + newGain: null, + oldGainVersion: policy.gainVersion ?? 1, + newGainVersion: null, + oldStatus: policy.status, + newStatus: null, + oldSupport: policy.support, + newSupport: null, + algorithmVersion: GAIN_REPAIR_ALGORITHM_VERSION, + configVersion: configVersionOf(deps.config), + inferenceVersion: deps.inferenceVersion ?? GAIN_INFERENCE_VERSION, + provenance: [], + excludedWithCount: 0, + excludedWithoutCount: 0, + result: "pending", + createdAt: now, + // The post-write timestamp is unknown until the attempt commits; the + // completing path records it via updateJournalOutcome (migration 19). + newUpdatedAt: null, + }); + return { kind: "reserved", journalId, policy } as const; + }); +} + +// ─── Per-policy recompute + atomic commit ──────────────────────────────────── + +/** + * Recompute the policy with FRESH rows and commit policy fields + final queue + * state + journal outcome atomically. Every read (policy + evidence) happens + * inside this transaction; there is no review-token/fingerprint scheme. + * + * • concurrent policy change → journal `conflicted`, leave the entry + * `pending` for a later budgeted attempt, never overwrite; + * • `unknown_owner` / `no_resolved_with` → `blocked` with reason, budget + * already consumed, policy never mutated; + * • valid candidate → raw first-v2 gain, unchanged support, promote only if + * live thresholds qualify; + * • valid active → refresh gain/version, preserve `active` even below the + * archive threshold (timer repair NEVER archives); + * • inference-refresh-marked entries recompute (EMA reset) and on success + * the queue entry is removed; + * • a policy that became naturally repaired since reservation reconciles + * without a duplicate EMA. + */ +export function applyGainRepairAttempt( + deps: GainRepairAttemptDeps, + policyId: PolicyId, + reservation: { journalId: string; policy: PolicyRow }, +): GainRepairApplyOutcome { + const owner = deps.owner; + const now = deps.now?.() ?? Date.now(); + const recompute = deps.recomputePolicyGainFn ?? recomputePolicyGain; + return deps.db.tx(() => { + const policy = deps.repos.policies.getById(policyId); + if (!policy) { + // Deleted while reserved — reconcile away, close the journal as failed + // (the attempt consumed its budget unit but wrote nothing). + deps.repos.gainRepair.removeByPolicy(policyId); + deps.repos.gainRepair.updateJournalOutcome(reservation.journalId, { result: "failed" }); + return { kind: "reconciled" } as const; + } + if (policyChanged(reservation.policy, policy)) { + // Concurrent ordinary-L2 / manual change since reservation: never + // overwrite. Leave the entry pending for a later budgeted attempt. + deps.repos.gainRepair.setQueueState(policyId, { state: "pending", now }); + deps.repos.gainRepair.updateJournalOutcome(reservation.journalId, { + newGain: policy.gain, + newGainVersion: policy.gainVersion ?? 1, + newStatus: policy.status, + newSupport: policy.support, + result: "conflicted", + }); + return { kind: "conflicted" } as const; + } + const entry = deps.repos.gainRepair.getByPolicy(policyId); + if (entry && isNaturallyRepaired(policy, entry)) { + // Race guard: the policy became naturally repaired after reservation. + // Reconcile without recomputation; no duplicate EMA. The reserved budget + // unit is intentionally NOT refunded (reservation is a commitment). + deps.repos.gainRepair.removeByPolicy(policyId); + deps.repos.gainRepair.updateJournalOutcome(reservation.journalId, { + newGain: policy.gain, + newGainVersion: policy.gainVersion ?? 1, + newStatus: policy.status, + newSupport: policy.support, + result: "completed", + }); + return { kind: "reconciled" } as const; + } + + const mode = + entry?.reason === "inference_refresh" ? ("inference_refresh" as const) : ("repair" as const); + const recomputed = recompute( + { + policy, + namespace: namespaceFromOwner(owner), + config: deps.config, + mode, + }, + { + episodes: deps.repos.episodes, + traces: deps.repos.traces, + tracePolicyLinks: deps.repos.tracePolicyLinks, + }, + ); + + if (recomputed.skipReason !== null) { + const blockedReason = + recomputed.skipReason === "unknown_owner" ? "unknown_owner" : "no_resolved_with"; + // No resolved with-evidence / unknown owner: blocked, policy never + // mutated. Budget already consumed (blocked outcomes consume budget). + deps.repos.gainRepair.setQueueState(policyId, { + state: "blocked", + blockedReason, + now, + }); + deps.repos.gainRepair.updateJournalOutcome(reservation.journalId, { + result: "blocked", + excludedWithCount: recomputed.excluded.unresolvedWith, + excludedWithoutCount: recomputed.excluded.unresolvedWithout, + }); + return { kind: "blocked" } as const; + } + + // Valid computation: support never increments; active never archives. + const support = policy.support; + let status: "candidate" | "active"; + if (policy.status === "active") { + status = "active"; + } else { + status = + support >= deps.thresholds.minSupport && recomputed.persistedGain >= deps.thresholds.minGain + ? "active" + : "candidate"; + } + deps.repos.policies.updateStats(policyId, { + support, + gain: recomputed.persistedGain, + gainVersion: 2, + status, + updatedAt: now, + }); + // Completed — final queue state is "removed" (the entry resolves). + deps.repos.gainRepair.removeByPolicy(policyId); + deps.repos.gainRepair.updateJournalOutcome(reservation.journalId, { + newGain: recomputed.persistedGain, + newGainVersion: 2, + newStatus: status, + newSupport: support, + // Record the exact post-write timestamp (migration 19): the + // five-field CAS in `policies.gainRollback` compares against it, so a + // newer timestamp write after this commit is detectable. + newUpdatedAt: now, + provenance: provenanceStrings(recomputed), + excludedWithCount: recomputed.excluded.unresolvedWith, + excludedWithoutCount: recomputed.excluded.unresolvedWithout, + result: "completed", + }); + return { kind: policy.status === "candidate" && status === "active" ? "promoted" : "completed" } as const; + }); +} + +// ─── Interrupted-claim reconcile ───────────────────────────────────────────── + +/** + * Restart reconciliation: every `claimed` queue entry is an interrupted + * attempt (the per-policy transaction never committed — a committed repair + * leaves a final state). Reset to `pending` so a LATER tick can retry as a NEW + * budgeted attempt, and close the orphaned `pending` journal row as `failed`. + * Never replays a committed repair and never refunds the reserved budget unit. + */ +export function reconcileInterruptedGainRepairClaims(deps: GainRepairAttemptDeps): number { + return deps.db.tx(() => { + const claimed = deps.repos.gainRepair.listByOwnerAndState(deps.owner, "claimed"); + for (const row of claimed) { + deps.repos.gainRepair.setQueueState(row.policyId, { state: "pending" }); + if (row.lastAttemptBatchId) { + deps.repos.gainRepair.markInterruptedJournal(row.policyId, row.lastAttemptBatchId); + } + } + return claimed.length; + }); +} + +// ─── Config-generation re-screen (kv, consume-once) ────────────────────────── + +interface StoredRescreen { + generation: number; + inferenceVersion: number; + consumedAt: number; +} + +export interface GainRepairRescreenResult { + consumed: boolean; + requeued: number; + inferenceVersion: number; +} + +/** + * Consume each `gainRepairRescreenGeneration` increase ONCE (and each new + * GAIN_INFERENCE_VERSION): rerun historical screening for blocked inputs at + * the current inference version, then requeue eligible blocked records. + * + * • Only blocked entries whose `blockedReason` indicates an evidence- + * integrity correction (`no_resolved_with`) have their evidence un-stamped + * (live provenance never overwritten), so the idempotent pass revisits + * those groups — including already-inferred inputs. `unknown_owner` blocks + * are owner-integrity, not evidence-integrity: untouched and never + * requeued (auto-mutation cannot repair them). + * • The requeue is TARGETED: a blocked entry flips back to `pending` only + * when its with-evidence now holds ≥ 1 resolved, non-borrowed score under + * the same predicate as the evidence union reconcile. Completed / reconciled + * policies are NOT re-seeded — a re-screen is a blocked-input repair, not + * a queue rebuild (a full rebuild would re-add every already-repaired + * policy and re-process unchanged evidence). + * • Never resets the budget, never writes policy gain/status, never + * consumes an attempt. + */ +export function consumeGainRepairRescreen( + deps: GainRepairAttemptDeps, +): GainRepairRescreenResult { + const version = deps.inferenceVersion ?? GAIN_INFERENCE_VERSION; + const now = deps.now?.() ?? Date.now(); + const key = gainRepairRescreenKey(deps.owner); + const stored = deps.repos.kv.get(key, null); + const gen = deps.config.gainRepairRescreenGeneration; + const pendingGenIncrease = gen > (stored?.generation ?? 0); + const pendingVersionBump = version > (stored?.inferenceVersion ?? 0); + if (!pendingGenIncrease && !pendingVersionBump) { + return { consumed: false, requeued: 0, inferenceVersion: version }; + } + + // One enclosing transaction: un-stamping, historical screening, eligibility + // rechecks, requeue writes and the consumed marker commit or roll back + // together. An inference exception (or any write failure) rolls back the + // whole re-screen so partial evidence un-stamping / partial requeues are + // never observable and the generation is not marked consumed. + return deps.db.tx(() => { + // 1. Un-stamp evidence-integrity blocked entries so the idempotent + // screening pass revisits their episode groups at the current version. + const blocked = deps.repos.gainRepair.listBlockedByOwner(deps.owner); + const evidenceIds = new Set(); + const integrityBlocked: PolicyId[] = []; + for (const row of blocked) { + // Evidence-integrity blocks (`no_resolved_with`) AND rolled-back entries + // resume through the same generation: a rollback parks the queue entry + // as blocked with `rolled_back` and the next generation bump un-stamps + // its evidence, re-screens it and requeues it when resolved. There is no + // separate approval/requeue flow. `unknown_owner` blocks are + // owner-integrity, not evidence-integrity: untouched and never requeued. + if (row.blockedReason !== "no_resolved_with" && row.blockedReason !== "rolled_back") continue; + const policy = deps.repos.policies.getById(row.policyId); + if (!policy || policy.status === "archived") { + // Missing / archived policies are never repair targets — reconcile the + // stale entry away (queue-only). + deps.repos.gainRepair.removeByPolicy(row.policyId); + continue; + } + integrityBlocked.push(row.policyId); + for (const id of deps.repos.tracePolicyLinks.getWithTraceIds(row.policyId)) { + evidenceIds.add(String(id)); + } + for (const id of policy.sourceTraceIds ?? []) evidenceIds.add(String(id)); + } + const traceIds = Array.from(evidenceIds); + for (const id of traceIds) { + deps.repos.traces.unstampGainForRescreen(id as TraceId); + } + + // 2. Idempotent historical screening at the current inference version. + runGainInference({ + db: deps.db, + kv: deps.repos.kv, + episodesRepo: deps.repos.episodes, + tracesRepo: deps.repos.traces, + owner: deps.owner, + inferenceVersion: version, + }); + + // 3. Requeue eligible blocked records (queue-only; never policy fields). + let requeued = 0; + for (const policyId of integrityBlocked) { + const policy = deps.repos.policies.getById(policyId); + if (!policy || policy.status === "archived") continue; + const withIdSet = new Set([ + ...deps.repos.tracePolicyLinks.getWithTraceIds(policyId).map(String), + ...(policy.sourceTraceIds ?? []).map(String), + ]); + if (withIdSet.size === 0) continue; + const policyOwner = normalizeOwner(policy); + let resolved = 0; + for (const r of deps.repos.traces.getGainRowsByIds(Array.from(withIdSet))) { + if (!isResolvedGainValue(r.gainValue)) continue; + if (isBorrowedEvidence(policyOwner, r)) continue; + resolved++; + } + if (resolved > 0) { + // Preserves the entry's reason (incl. `inference_refresh`) and attempt + // metadata — the queue state flips, nothing else. + deps.repos.gainRepair.setQueueState(policyId, { state: "pending", now }); + requeued++; + } + } + // 4. Record consumption in the same transaction as the requeue writes. + deps.repos.kv.set(key, { generation: gen, inferenceVersion: version, consumedAt: now }); + return { consumed: true, requeued, inferenceVersion: version }; + }); +} + +// ─── Tick orchestrator (used ONLY by the timer) ────────────────────────────── + +export function runGainRepairTick(deps: GainRepairAttemptDeps): GainRepairTickResult { + const startedAt = Date.now(); + const batchId = `gr_${ids.span()}`; + const version = deps.inferenceVersion ?? GAIN_INFERENCE_VERSION; + const counts: GainRepairTickCounts = { + attempted: 0, + rescored: 0, + promoted: 0, + blocked: 0, + conflicted: 0, + failed: 0, + reconciled: 0, + }; + + // Gate: batch size 0 pauses repair; v2 disabled means repair is not + // permitted. A gated tick does nothing (including rescreen consumption). + if (!deps.config.gainV2Enabled || deps.config.gainRepairBatchSize <= 0) { + const budget = readGainRepairBudget(deps.repos.kv, deps.owner, deps.config.gainRepairMaxTotal); + return { + ...counts, + batchId, + budget, + inferenceVersion: version, + rescreenConsumed: false, + durationMs: Date.now() - startedAt, + }; + } + + // Restart reconcile FIRST: interrupted claims reset to pending (no replay, + // no refund) and their orphaned journal rows closed as `failed`. This must + // precede the re-screen so a rescreen can never overwrite a `claimed` entry + // (queue upserts would flip it) without the journal ledger being closed. + counts.reconciled += reconcileInterruptedGainRepairClaims(deps); + + // Re-screen generation: consume each increase once, before selection. + const rescreen = consumeGainRepairRescreen(deps); + + // Budget: first enabled campaign initializes once per exact namespace. + const budgetKey = gainRepairBudgetKey(deps.owner); + deps.db.tx(() => { + const stored = deps.repos.kv.get(budgetKey, null); + if (!stored) { + deps.repos.kv.set(budgetKey, { attempted: 0, initializedAt: deps.now?.() ?? Date.now() }); + } + }); + const budgetState = readGainRepairBudget( + deps.repos.kv, + deps.owner, + deps.config.gainRepairMaxTotal, + ); + + // Attempt at most min(batchSize, remainingBudget) per tick. + const cap = Math.min( + deps.config.gainRepairBatchSize, + budgetState.remaining === null ? deps.config.gainRepairBatchSize : budgetState.remaining, + ); + if (cap <= 0) { + return { + ...counts, + batchId, + budget: budgetState, + inferenceVersion: version, + rescreenConsumed: rescreen.consumed, + durationMs: Date.now() - startedAt, + }; + } + + // Exact-namespace candidate-first then active, stable ID order. + const targets = deps.repos.gainRepair.listPendingForRepair(deps.owner, cap); + for (const target of targets) { + let reservation: GainRepairReservationOutcome; + try { + reservation = reserveGainRepairAttempt(deps, target.policyId, batchId); + } catch (err) { + // A reservation that cannot be persisted stops the tick before any + // repair work — transaction state is unknown. + deps.log.warn("gain_repair.reservation_failed", { + batchId, + policyId: target.policyId, + err: err instanceof Error ? err.message : String(err), + }); + break; + } + if (reservation.kind === "budget_exhausted") break; + if (reservation.kind === "reconciled") { + counts.reconciled += 1; + continue; + } + if (reservation.kind === "not_pending") continue; + + counts.attempted += 1; + let outcome: GainRepairApplyOutcome; + try { + outcome = applyGainRepairAttempt(deps, target.policyId, reservation); + } catch (err) { + // The per-policy transaction rolled back (no partial writes): earlier + // commits are preserved, the reservation persists and the attempt is + // consumed. Record failure + mark the claim failed so a later tick can + // retry as a NEW budgeted attempt. + deps.log.warn("gain_repair.attempt_failed", { + batchId, + policyId: target.policyId, + err: err instanceof Error ? err.message : String(err), + }); + try { + deps.db.tx(() => { + deps.repos.gainRepair.setQueueState(target.policyId, { state: "pending" }); + deps.repos.gainRepair.updateJournalOutcome(reservation.journalId, { result: "failed" }); + }); + } catch (recoverErr) { + // Tx state unknown — stop the tick rather than risk double-apply. + deps.log.warn("gain_repair.failure_recovery_failed", { + batchId, + policyId: target.policyId, + err: recoverErr instanceof Error ? recoverErr.message : String(recoverErr), + }); + break; + } + counts.failed += 1; + continue; + } + switch (outcome.kind) { + case "promoted": + counts.promoted += 1; + counts.rescored += 1; + break; + case "completed": + counts.rescored += 1; + break; + case "blocked": + counts.blocked += 1; + break; + case "conflicted": + counts.conflicted += 1; + break; + case "failed": + counts.failed += 1; + break; + case "reconciled": + counts.reconciled += 1; + break; + } + } + + const finalBudget = readGainRepairBudget(deps.repos.kv, deps.owner, deps.config.gainRepairMaxTotal); + const result: GainRepairTickResult = { + ...counts, + batchId, + budget: finalBudget, + inferenceVersion: version, + rescreenConsumed: rescreen.consumed, + durationMs: Date.now() - startedAt, + }; + deps.log.info("gain_repair.tick.done", { + namespace: { + ownerAgentKind: deps.owner.ownerAgentKind, + ownerProfileId: deps.owner.ownerProfileId, + ownerWorkspaceId: deps.owner.ownerWorkspaceId ?? null, + }, + ...result, + config: { + gainV2Enabled: deps.config.gainV2Enabled, + minGainValue: deps.config.minGainValue, + gainRepairBatchSize: deps.config.gainRepairBatchSize, + gainRepairMaxTotal: deps.config.gainRepairMaxTotal, + }, + algorithmVersion: GAIN_REPAIR_ALGORITHM_VERSION, + configVersion: configVersionOf(deps.config), + }); + return result; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Already-v2 / naturally repaired: the policy carries a v2-certified gain with + * live support and the queue entry was NOT explicitly invalidated by an + * inference-rule/input refresh. Such entries reconcile without recomputation — + * a repair recompute would re-EMA unchanged evidence (duplicate EMA). Entries + * marked `inference_refresh` MUST recompute and only resolve on success. + */ +function isNaturallyRepaired(policy: PolicyRow, entry: GainRepairQueueRow | null): boolean { + if (!entry) return false; + if (entry.reason === "inference_refresh") return false; + return (policy.gainVersion ?? 1) === 2 && policy.support > 0; +} + +/** + * Concurrent policy change guard (journal `conflicted`): any change to the + * fields the timer would overwrite — status, support, gain, gain_version, + * updated_at — since the reservation snapshot means the policy is being + * touched elsewhere. Never overwrite; leave pending for a later budgeted + * attempt. + */ +function policyChanged(before: PolicyRow, after: PolicyRow): boolean { + return ( + before.status !== after.status || + before.support !== after.support || + before.gain !== after.gain || + (before.gainVersion ?? 1) !== (after.gainVersion ?? 1) || + before.updatedAt !== after.updatedAt + ); +} + +export function namespaceFromOwner(owner: GainRepairOwner): RuntimeNamespace { + return { + agentKind: owner.ownerAgentKind as RuntimeNamespace["agentKind"], + profileId: owner.ownerProfileId, + ...(owner.ownerWorkspaceId ? { workspaceId: owner.ownerWorkspaceId } : {}), + }; +} + +function provenanceStrings(recomputed: RecomputeGainResult): string[] { + const out: string[] = []; + if (recomputed.provenance.liveNormalized > 0) { + out.push(`live_normalized:${recomputed.provenance.liveNormalized}`); + } + if (recomputed.provenance.inferredNormalized > 0) { + out.push(`inferred_normalized:${recomputed.provenance.inferredNormalized}`); + } + if (recomputed.provenance.legacyUnscaled > 0) { + out.push(`legacy_unscaled:${recomputed.provenance.legacyUnscaled}`); + } + return out; +} + +/** Stable short config fingerprint for journal/audit (no secret material). */ +export function configVersionOf(config: L2Config): string { + const { + gainEmaAlpha, + gainV2Enabled, + minGainValue, + gainRepairBatchSize, + gainRepairMaxTotal, + gainRepairIntervalMs, + gainRepairRescreenGeneration, + } = config; + return ( + `v2=${gainV2Enabled ? 1 : 0};ema=${gainEmaAlpha};minGain=${minGainValue}` + + `;batch=${gainRepairBatchSize};maxTotal=${gainRepairMaxTotal ?? "null"}` + + `;interval=${gainRepairIntervalMs};gen=${gainRepairRescreenGeneration}` + ); +} diff --git a/apps/memos-local-plugin/core/memory/l2/gain.ts b/apps/memos-local-plugin/core/memory/l2/gain.ts index d2c322562..5d91178dd 100644 --- a/apps/memos-local-plugin/core/memory/l2/gain.ts +++ b/apps/memos-local-plugin/core/memory/l2/gain.ts @@ -168,6 +168,8 @@ export type ApplyGainPersist = (args: { policyId: PolicyId; support: number; gain: number; + /** gain certification version written with this update. */ + gainVersion: number; status: "candidate" | "active" | "archived"; updatedAt: number; }) => void; @@ -179,6 +181,8 @@ export function applyGain(args: { thresholds: { minSupport: number; minGain: number; archiveGain: number }; persist: ApplyGainPersist; currentSupport: number; + /** 2 certifies a shared v2 gainValue calculation; 1 invalidates. */ + gainVersion?: number; now?: number; }): { status: "candidate" | "active" | "archived"; support: number; gain: number } { const support = Math.max(0, args.currentSupport + args.deltaSupport); @@ -192,6 +196,7 @@ export function applyGain(args: { policyId: args.gain.policyId, support, gain: args.gain.gain, + gainVersion: args.gainVersion ?? 1, status, updatedAt: args.now ?? Date.now(), }); diff --git a/apps/memos-local-plugin/core/memory/l2/index.ts b/apps/memos-local-plugin/core/memory/l2/index.ts index 9f48078bf..5c484c858 100644 --- a/apps/memos-local-plugin/core/memory/l2/index.ts +++ b/apps/memos-local-plugin/core/memory/l2/index.ts @@ -27,6 +27,19 @@ export { } from "./similarity.js"; export { induceDraft, buildPolicyRow, type InduceInput, type InduceDeps } from "./induce.js"; export { computeGain, nextStatus, applyGain, partition } from "./gain.js"; +export { + recomputePolicyGain, + selectAndComputeGain, + isInductionEligible, + reconcileGainRepairQueueFromEvidenceUnion, + type GainEvidenceTrace, + type RecomputeGainInput, + type RecomputeGainMode, + type RecomputeGainResult, + type RecomputeGainRepos, + type RecomputeGainSkipReason, + type SelectAndComputeInput, +} from "./recompute-gain.js"; export { makeCandidatePool, candidateIdFor, signatureHash } from "./candidate-pool.js"; export type { AssociationResult, diff --git a/apps/memos-local-plugin/core/memory/l2/induce.ts b/apps/memos-local-plugin/core/memory/l2/induce.ts index 2ea750cd2..d61131496 100644 --- a/apps/memos-local-plugin/core/memory/l2/induce.ts +++ b/apps/memos-local-plugin/core/memory/l2/induce.ts @@ -168,6 +168,9 @@ export function buildPolicyRow(args: { boundary: args.draft.boundary, support: 0, gain: 0, + // a fresh draft is uncertified until an actual shared v2 + // gainValue calculation certifies it (never blanket-default to v2). + gainVersion: 1, status: "candidate", sourceEpisodeIds: Array.from(new Set(args.episodeIds)), inducedBy: args.inducedBy, diff --git a/apps/memos-local-plugin/core/memory/l2/l2.ts b/apps/memos-local-plugin/core/memory/l2/l2.ts index 903502b32..e071d4bdc 100644 --- a/apps/memos-local-plugin/core/memory/l2/l2.ts +++ b/apps/memos-local-plugin/core/memory/l2/l2.ts @@ -26,6 +26,7 @@ import type { EpochMs, PolicyId, PolicyRow, + TraceId, TraceRow, } from "../../types.js"; import type { Repos } from "../../storage/repos/index.js"; @@ -34,7 +35,8 @@ import { L2_INDUCTION_PROMPT } from "../../llm/prompts/l2-induction.js"; import { associateTraces } from "./associate.js"; import { makeCandidatePool } from "./candidate-pool.js"; import { buildPolicyRow, induceDraft } from "./induce.js"; -import { applyGain, computeGain, nextStatus, smoothGain } from "./gain.js"; +import { applyGain, nextStatus } from "./gain.js"; +import { isInductionEligible, recomputePolicyGain } from "./recompute-gain.js"; import { signatureOf } from "./signature.js"; import { tracePolicySimilarity } from "./similarity.js"; import type { @@ -48,7 +50,16 @@ import type { } from "./types.js"; export interface RunL2Deps { - repos: Pick; + repos: Pick< + Repos, + | "candidatePool" + | "embeddingRetryQueue" + | "episodes" + | "gainRepair" + | "policies" + | "tracePolicyLinks" + | "traces" + >; db: Parameters[0]["db"]; llm: LlmClient | null; log: Logger; @@ -67,7 +78,9 @@ export async function runL2( const warnings: L2ProcessResult["warnings"] = []; const timings = { associate: 0, candidate: 0, induce: 0, gain: 0, persist: 0, total: 0 }; - const eligibleTraces = input.traces.filter((t) => t.value >= config.minTraceValue && !!(t.vecSummary ?? t.vecAction)); + // v2 admission uses the resolved gainValue floor; legacy keeps minTraceValue + // on V. Both modes require an embedding for cosine association. + const eligibleTraces = input.traces.filter((t) => isInductionEligible(t, config)); log.info("run.start", { episodeId: input.episodeId, sessionId: input.sessionId, @@ -76,6 +89,22 @@ export async function runL2( trigger: input.trigger, }); + // Evidence-link intents recorded during association/induction but written + // ONLY after the policy's gain recompute passes in Step 4. If the recompute + // skips (no resolved with-evidence), the policy write is aborted — links + // must not already be persisted, or the policy is left with orphaned + // evidence rows and no gain/support/status advance to match. + const pendingLinks = new Map>(); + const recordLinkIntent = ( + policyId: PolicyId, + traceId: TraceId, + episodeId: EpisodeId, + ): void => { + const arr = pendingLinks.get(policyId) ?? []; + arr.push({ traceId, episodeId }); + pendingLinks.set(policyId, arr); + }; + // ─── Step 1: Associate ────────────────────────────────────────────────── let associations: AssociationResult[] = []; { @@ -94,19 +123,7 @@ export async function runL2( if (!tr) continue; a.signature = signatureOf(tr); if (a.matchedPolicyId) { - try { - repos.tracePolicyLinks.link({ - traceId: a.traceId, - policyId: a.matchedPolicyId, - episodeId: input.episodeId, - now: input.now ?? Date.now(), - }); - } catch (err) { - warnings.push(stageWarn("trace-policy-link", err, { - traceId: a.traceId, - policyId: a.matchedPolicyId, - })); - } + recordLinkIntent(a.matchedPolicyId, a.traceId as TraceId, input.episodeId); emit(bus, { kind: "l2.trace.associated", episodeId: input.episodeId, @@ -166,17 +183,24 @@ export async function runL2( now: input.now, }); for (const bucket of ready) { + // old candidate-pool entries are revalidated against the + // admission floor (gainValue in enabled mode, V in legacy) before they + // can serve as induction evidence. No historical bulk replay. ONLY the + // filtered eligible IDs may flow into induction evidence bookkeeping, + // links, support accounting and reported induction evidence. const traces = bucket.evidenceTraceIds .map((id) => repos.traces.getById(id)) - .filter((t): t is TraceRow => !!t); - const epIds = bucket.episodeIds as EpisodeId[]; + .filter((t): t is TraceRow => !!t) + .filter((t) => isInductionEligible(t, config)); + const eligibleEvidenceIds = traces.map((t) => t.id); + const epIds = Array.from(new Set(traces.map((t) => t.episodeId))) as EpisodeId[]; if (traces.length === 0 || epIds.length < config.minEpisodesForInduction) { inductions.push({ signature: bucket.signature, policyId: null, poolSize: bucket.candidateIds.length, episodeIds: epIds, - traceIds: bucket.evidenceTraceIds, + traceIds: eligibleEvidenceIds, skippedReason: "too_few_episodes", }); continue; @@ -191,35 +215,26 @@ export async function runL2( policyId: dup.id, poolSize: bucket.candidateIds.length, episodeIds: epIds, - traceIds: bucket.evidenceTraceIds, + traceIds: eligibleEvidenceIds, skippedReason: "duplicate_of", duplicateOfPolicyId: dup.id, }); pool.promote(bucket.candidateIds, dup.id); touched.set(dup.id, dup); const evidence = inductionEvidenceByPolicy.get(dup.id) ?? new Set(); - for (const id of bucket.evidenceTraceIds) evidence.add(id); + for (const id of eligibleEvidenceIds) evidence.add(id); inductionEvidenceByPolicy.set(dup.id, evidence); - for (const traceId of bucket.evidenceTraceIds) { + for (const traceId of eligibleEvidenceIds) { const trace = traces.find((t) => t.id === traceId); if (!trace) continue; - try { - repos.tracePolicyLinks.link({ - traceId, - policyId: dup.id, - episodeId: trace.episodeId, - now: input.now ?? Date.now(), - }); - } catch (err) { - warnings.push(stageWarn("trace-policy-link", err, { traceId, policyId: dup.id })); - } + recordLinkIntent(dup.id, traceId as TraceId, trace.episodeId); } continue; } const draftRes = await induceDraft( { - evidenceTraces: pickOnePerEpisode(traces), + evidenceTraces: pickOnePerEpisode(traces, config), episodeIds: epIds, signatureLabel: bucket.signature, charCap: config.inductionTraceCharCap, @@ -241,7 +256,7 @@ export async function runL2( policyId: null, poolSize: bucket.candidateIds.length, episodeIds: epIds, - traceIds: bucket.evidenceTraceIds, + traceIds: eligibleEvidenceIds, skippedReason: draftRes.reason, }); continue; @@ -264,33 +279,18 @@ export async function runL2( repos.policies.upsert(merged); pool.promote(bucket.candidateIds, duplicate.id); touched.set(duplicate.id, merged); - inductionEvidenceByPolicy.set( - duplicate.id, - new Set(bucket.evidenceTraceIds as string[]), - ); - for (const traceId of bucket.evidenceTraceIds) { + inductionEvidenceByPolicy.set(duplicate.id, new Set(eligibleEvidenceIds)); + for (const traceId of eligibleEvidenceIds) { const trace = traces.find((t) => t.id === traceId); if (!trace) continue; - try { - repos.tracePolicyLinks.link({ - traceId, - policyId: duplicate.id, - episodeId: trace.episodeId, - now: input.now ?? Date.now(), - }); - } catch (err) { - warnings.push(stageWarn("trace-policy-link", err, { - traceId, - policyId: duplicate.id, - })); - } + recordLinkIntent(duplicate.id, traceId as TraceId, trace.episodeId); } inductions.push({ signature: bucket.signature, policyId: duplicate.id, poolSize: bucket.candidateIds.length, episodeIds: epIds, - traceIds: bucket.evidenceTraceIds, + traceIds: eligibleEvidenceIds, skippedReason: "duplicate_of", duplicateOfPolicyId: duplicate.id, }); @@ -315,33 +315,18 @@ export async function runL2( } pool.promote(bucket.candidateIds, policy.id); touched.set(policy.id, policy); - inductionEvidenceByPolicy.set( - policy.id, - new Set(bucket.evidenceTraceIds as string[]), - ); - for (const traceId of bucket.evidenceTraceIds) { + inductionEvidenceByPolicy.set(policy.id, new Set(eligibleEvidenceIds)); + for (const traceId of eligibleEvidenceIds) { const trace = traces.find((t) => t.id === traceId); if (!trace) continue; - try { - repos.tracePolicyLinks.link({ - traceId, - policyId: policy.id, - episodeId: trace.episodeId, - now: input.now ?? Date.now(), - }); - } catch (err) { - warnings.push(stageWarn("trace-policy-link", err, { - traceId, - policyId: policy.id, - })); - } + recordLinkIntent(policy.id, traceId as TraceId, trace.episodeId); } inductions.push({ signature: bucket.signature, policyId: policy.id, poolSize: bucket.candidateIds.length, episodeIds: epIds, - traceIds: bucket.evidenceTraceIds, + traceIds: eligibleEvidenceIds, skippedReason: null, }); emit(bus, { @@ -349,7 +334,7 @@ export async function runL2( episodeId: input.episodeId, policyId: policy.id, signature: bucket.signature, - evidenceTraceIds: bucket.evidenceTraceIds, + evidenceTraceIds: eligibleEvidenceIds, evidenceEpisodeIds: epIds, title: policy.title, }); @@ -385,45 +370,40 @@ export async function runL2( for (const id of inductionIds) withIds.add(id); } const newSupportIds = new Set(withIds); - for (const id of repos.tracePolicyLinks.getWithTraceIds(policy.id)) { - withIds.add(id); - } - // Gain is computed over ALL traces currently in scope — the - // current episode's traces PLUS the induction evidence traces - // (which may come from earlier episodes). Previously we only - // used `input.traces`, which meant a policy induced from two - // past episodes would see an empty `withTraces` and tank its - // gain. Pull missing induction traces from the repo. - const traceById = new Map(); - for (const t of input.traces) traceById.set(t.id, t); - for (const id of withIds) { - if (traceById.has(id)) continue; - const t = repos.traces.getById(id as TraceRow["id"]); - if (t) traceById.set(t.id, t); - } - for (const episodeId of repos.tracePolicyLinks.getLinkedEpisodeIds(policy.id)) { - for (const t of repos.traces.list({ episodeId, limit: 50, newestFirst: true })) { - traceById.set(t.id, t); - } - } - const allTraces = Array.from(traceById.values()) - .sort((a, b) => b.ts - a.ts || b.id.localeCompare(a.id)); - - const withTraces: TraceRow[] = allTraces.filter((t) => withIds.has(t.id)).slice(0, 50); - const withoutTraces: TraceRow[] = allTraces.filter((t) => !withIds.has(t.id)).slice(0, 50); - - const rawGain = computeGain( - { policyId: policy.id, withTraces, withoutTraces }, - { tauSoftmax: config.tauSoftmax }, + // shared evidence selection + gain recomputation. + // The helper unions persisted with-links + current associations + + // induction evidence, excludes NULL-score traces with separate + // counters, and returns raw AND persisted (EMA) gain separately. It + // performs no writes and never touches support. + const recomputed = recomputePolicyGain( + { + policy, + namespace: namespaceFromPolicy(policy), + config, + mode: "ordinary", + currentTraces: input.traces, + withTraceIds: Array.from(withIds), + }, + { + episodes: repos.episodes, + traces: repos.traces, + tracePolicyLinks: repos.tracePolicyLinks, + }, ); - const smoothedGainValue = smoothGain({ - newGain: rawGain.gain, - currentGain: policy.gain, - alpha: config.gainEmaAlpha, - isFirst: policy.support === 0, - }); - const gain = { ...rawGain, gain: smoothedGainValue }; + + if (recomputed.skipReason !== null) { + // No resolved with-evidence → preserve previous policy state: no gain, + // no support, no status write. Exclusion (not skip-of-policy) is the + // rule whenever at least one resolved with-trace remains. + log.info("run.gain.skip", { + policyId: policy.id, + reason: recomputed.skipReason, + unresolvedWith: recomputed.excluded.unresolvedWith, + withEvidence: recomputed.withIds.length, + }); + continue; + } // `deltaSupport` must reflect only the *new* positive evidence // we just observed — both fresh associations AND the induction @@ -433,16 +413,48 @@ export async function runL2( const deltaSupport = newSupportIds.size; const persisted = applyGain({ - gain, + gain: { ...recomputed.raw, gain: recomputed.persistedGain }, deltaSupport, + // Only an actual shared v2 gainValue calculation certifies v2; legacy + // and unresolved-skip writes never do. + gainVersion: recomputed.gainVersion, currentStatus: policy.status, thresholds, currentSupport: policy.support, now: input.now ?? Date.now(), - persist: ({ policyId, support, gain: g, status, updatedAt }) => - repos.policies.updateStats(policyId, { support, gain: g, status, updatedAt }), + persist: ({ policyId, support, gain: g, status, updatedAt, gainVersion }) => + repos.policies.updateStats(policyId, { + support, + gain: g, + status, + updatedAt, + gainVersion, + }), }); + // The gain write committed — flush the deferred evidence links for this + // policy now. (When the recompute skips above, `continue` leaves them + // un-persisted: no orphaned links for a policy whose state never moved.) + const links = pendingLinks.get(policy.id); + if (links) { + for (const link of links) { + try { + repos.tracePolicyLinks.link({ + traceId: link.traceId, + policyId: policy.id, + episodeId: link.episodeId, + now: input.now ?? Date.now(), + }); + } catch (err) { + warnings.push(stageWarn("trace-policy-link", err, { + traceId: link.traceId, + policyId: policy.id, + })); + } + } + pendingLinks.delete(policy.id); + } + emit(bus, { kind: "l2.policy.updated", episodeId: input.episodeId, @@ -465,6 +477,26 @@ export async function runL2( const untouchedCandidates = repos.policies.list({ status: "candidate" }); for (const policy of untouchedCandidates) { if (touched.has(policy.id)) continue; // already handled in Step 4 + // unknown-owner policies are excluded from automatic mutation + // in EVERY mode (v2 or legacy): their gain cannot be validated against + // real evidence ownership, so they are never promoted by the sweep. + if ((policy.ownerAgentKind ?? "unknown") === "unknown") { + continue; + } + // enabled-mode untouched-candidate promotion requires v2 + // certification AND no unresolved inference invalidation: an + // inference-rule/input change enqueues a refresh with an explicit + // reason, and stale certification must never promote an untouched + // candidate while that refresh is unresolved (any queue state). + if (config.gainV2Enabled && !untouchedCandidatePromotionEligible(policy, repos.gainRepair)) { + log.info("run.recheck_candidate_promotion_blocked", { + policyId: policy.id, + reason: (policy.gainVersion ?? 1) !== 2 + ? "not_v2_certified" + : "pending_inference_refresh", + }); + continue; + } const next = nextStatus({ currentStatus: policy.status, support: policy.support, @@ -475,6 +507,9 @@ export async function runL2( repos.policies.updateStats(policy.id, { support: policy.support, gain: policy.gain, + // The sweep does not recompute gain: preserve the policy's current + // certification state instead of blanket-writing a version. + gainVersion: policy.gainVersion ?? 1, status: next, updatedAt: input.now ?? Date.now(), }); @@ -536,6 +571,37 @@ function ownerFromTraces(traces: readonly TraceRow[]): { }; } +/** + * derive the exact namespace context from the policy's own owner + * fields. Ordinary L2 has no ambient namespace; the policy is authoritative + * for evidence owner matching inside `recomputePolicyGain`. + */ +function namespaceFromPolicy(policy: PolicyRow): import("../../types.js").RuntimeNamespace { + return { + agentKind: (policy.ownerAgentKind ?? "unknown") as import("../../types.js").RuntimeNamespace["agentKind"], + profileId: policy.ownerProfileId ?? "default", + ...(policy.ownerWorkspaceId ? { workspaceId: policy.ownerWorkspaceId } : {}), + }; +} + +/** + * Enabled-mode untouched-candidate promotion requires v2 + * certification AND no unresolved inference invalidation. An inference-rule / + * input change enqueues a refresh with the explicit `inference_refresh` + * reason; stale certification must never promote an untouched candidate while + * that refresh is unresolved — in ANY queue state (pending, claimed or + * blocked), since only a successful refreshed calculation removes the entry. + */ +function untouchedCandidatePromotionEligible( + policy: PolicyRow, + gainRepair: RunL2Deps["repos"]["gainRepair"], +): boolean { + if ((policy.gainVersion ?? 1) !== 2) return false; + const entry = gainRepair.getByPolicy(policy.id); + if (entry && entry.reason === "inference_refresh") return false; + return true; +} + // ─── helpers ──────────────────────────────────────────────────────────────── function emit(bus: L2EventBus | undefined, evt: L2Event): void { @@ -562,11 +628,15 @@ function policyVectorText(policy: PolicyRow): string { ].filter(Boolean).join("\n"); } -function pickOnePerEpisode(traces: readonly TraceRow[]): TraceRow[] { +function pickOnePerEpisode(traces: readonly TraceRow[], config: L2Config): TraceRow[] { const byEp = new Map(); for (const t of traces) { const cur = byEp.get(t.episodeId); - if (!cur || t.value > cur.value) byEp.set(t.episodeId, t); + // representative selection uses the evidence score of the + // enabled mode (gainValue when v2 is on; the legacy V otherwise). + const score = (tr: TraceRow): number => + config.gainV2Enabled ? (tr.gainValue ?? tr.value) : tr.value; + if (!cur || score(t) > score(cur)) byEp.set(t.episodeId, t); } return Array.from(byEp.values()); } diff --git a/apps/memos-local-plugin/core/memory/l2/recompute-gain.ts b/apps/memos-local-plugin/core/memory/l2/recompute-gain.ts new file mode 100644 index 000000000..77fd1449f --- /dev/null +++ b/apps/memos-local-plugin/core/memory/l2/recompute-gain.ts @@ -0,0 +1,722 @@ +/** + * `recompute-gain.ts` — shared evidence selection + gain recomputation. + * + * One selection/computation core shared by ordinary L2 (`runL2`), the read-only + * `policies.gainPreview` RPC and the timer repair engine. + * The transaction layer owns policy writes, support accounting, queue progress + * and journal insertion; this module owns none of them. + * + * Selection contract: + * + * 1. Union persisted with-links (`tracePolicyLinks`), current-run with + * evidence (current associations + induction evidence) and current-run + * traces. + * 2. Include directly-linked traces even if older than their episode's + * newest 50; include current-run traces and the newest 50 persisted + * traces per linked episode — read from `episodes.trace_ids_json` (the + * exact reward-pass set), never a scan of all rows sharing `episode_id`. + * 3. Deduplicate by ID, sort timestamp DESC then ID DESC. + * 4. Exclude NULL-score traces BEFORE the final 50-with / 50-without limits + * and count them as `excluded.unresolved{With,Without}`. Dangling IDs, + * invalid scores and out-of-namespace rows are reported separately. The + * pool is never refilled by scanning older unrelated traces. + * 5. Resolved zeros are kept, including without-traces; zero is valid + * evidence. + * 6. Compute whenever ≥ 1 resolved with-trace remains; an empty resolved + * without-set uses the existing prior. Skip (preserving previous policy + * state) for no resolved with-evidence — exclusion, not skip-of-policy, + * is the rule whenever a resolved with-trace remains. + * + * v2 certification: `isFirst = support == 0 OR gain_version != 2` → the raw + * first-v2 gain is used and the EMA is reset. An inference-rule-refresh + * recompute (`mode === "inference_refresh"` / `resetEma`) also resets the EMA + * so a superseded inferred score never blends into its replacement. Later + * ordinary v2 updates keep the EMA (baseline, 5 pseudocounts, softmax-with-set + * mean for ≥ 3 samples, arithmetic mean otherwise, existing coefficient). + * Historical repair never increments support nor repeatedly EMAs unchanged + * evidence — support accounting stays in the transaction layer. + * + * Owner isolation: evidence traces are matched against the POLICY's owner + * fields (falling back to the caller's exact namespace). NULL / `unknown` + * owner traces are the shared space in this codebase (`visibilityWhere` treats + * `owner_agent_kind IS NULL` / `'unknown'` as visible to everyone) — matching + * them is not borrowing another owner's evidence. A trace that carries a REAL + * owner different from the policy owner is counted as out-of-namespace and + * excluded. Unknown-owner policies are reported (`unknownOwner`) and excluded + * from automatic mutation by repair callers. + * + * This module performs NO writes: it never deletes links, never changes + * support, never replaces NULL with zero, and never certifies excluded traces. + */ + +import type { makeEpisodesRepo } from "../../storage/repos/episodes.js"; +import type { makeGainRepairRepo } from "../../storage/repos/gain-repair.js"; +import type { makeKvRepo } from "../../storage/repos/kv.js"; +import type { makePoliciesRepo } from "../../storage/repos/policies.js"; +import type { makeTracePolicyLinksRepo } from "../../storage/repos/trace-policy-links.js"; +import type { makeTracesRepo } from "../../storage/repos/traces.js"; +import { isExactOwner } from "../../storage/repos/_helpers.js"; +import type { StorageDb } from "../../storage/types.js"; +import type { + EpisodeId, + EpochMs, + GainValueSource, + PolicyId, + PolicyRow, + RuntimeNamespace, + TraceId, + TraceRow, +} from "../../types.js"; +import { GAIN_INFERENCE_VERSION, GAIN_REPAIR_QUEUE_SEED_KEY } from "../../reward/gain-inference.js"; +import { rootLogger } from "../../logger/index.js"; +import { computeGain, smoothGain } from "./gain.js"; +import type { GainResult, L2Config } from "./types.js"; + +/** Final with/without caps after resolution. */ +export const MAX_EVIDENCE = 50; +/** Newest persisted traces per linked episode. */ +export const NEWEST_PER_EPISODE = 50; + +const log = rootLogger.child({ channel: "core.memory.l2.gain" }); + +export type RecomputeGainMode = "ordinary" | "inference_refresh" | "preview" | "repair"; + +export type RecomputeGainSkipReason = + | null + | "no_resolved_with" + | /** Policy owner is `unknown` — auto-mutation must not touch it. */ + "unknown_owner"; + +/** + * Narrow evidence view the selection math operates on. Matches the narrow + * `TraceGainRow` projection from `traces.ts` (no text / vector payloads). + */ +export interface GainEvidenceTrace { + id: TraceId; + episodeId: EpisodeId; + ts: EpochMs; + value: number; + gainValue: number | null; + gainValueSource: GainValueSource | null; + ownerAgentKind?: string; + ownerProfileId?: string; + ownerWorkspaceId?: string | null; +} + +export interface RecomputeGainResult { + /** `null` = computation ran; otherwise why auto-mutation must skip. */ + skipReason: RecomputeGainSkipReason; + /** 2 = shared gainValue calculation certified; 1 = legacy/uncertified. */ + gainVersion: 1 | 2; + /** First v2 calculation (support 0 / uncertified / inference refresh). */ + isFirst: boolean; + /** RAW (un-smoothed) `computeGain` result — preview cannot rebuild it from a scalar. */ + raw: GainResult; + /** EMA-smoothed gain to persist (equals `raw.gain` when `isFirst`). */ + persistedGain: number; + /** Resolved with-traces selected after the final 50 cap. */ + selectedWithIds: string[]; + /** Resolved without-traces selected after the final 50 cap. */ + selectedWithoutIds: string[]; + /** Every with-evidence ID in the union (links + caller with-evidence). */ + withIds: string[]; + /** Every pool ID considered (with + current-run + per-episode newest 50). */ + poolIds: string[]; + /** Provenance of the selected with-traces (v2 mode; zeros in legacy mode). */ + provenance: { liveNormalized: number; inferredNormalized: number; legacyUnscaled: number }; + /** Excluded evidence. */ + excluded: { + /** with-traces whose score is NULL/unresolved (v2 mode). */ + unresolvedWith: number; + /** without-traces whose score is NULL/unresolved (v2 mode). */ + unresolvedWithout: number; + /** resolved with-traces cut by the final 50 cap. */ + withBeyondLimit: number; + /** resolved without-traces cut by the final 50 cap. */ + withoutBeyondLimit: number; + }; + /** Rows reported separately. */ + reported: { danglingIds: number; invalidScores: number; outOfNamespace: number }; + /** Policy owner is `unknown` — repair callers must not auto-mutate it. */ + unknownOwner: boolean; +} + +export interface RecomputeGainRepos { + episodes: Pick, "getById">; + traces: Pick, "getGainRowsByIds" | "count">; + tracePolicyLinks: Pick< + ReturnType, + "getWithTraceIds" | "getLinkedEpisodeIds" + >; +} + +export interface RecomputeGainInput { + policy: PolicyRow; + /** Exact namespace of the caller (falls back to the policy owner fields). */ + namespace: RuntimeNamespace; + /** Live L2 config slice (extractAlgorithmConfig output). */ + config: L2Config; + /** + * Caller context. `"ordinary"` = runL2; `"inference_refresh"` resets the + * EMA; `"preview"` forces v2 scoring regardless of the enable flag (the + * preview is a sanity check, not a frozen approval artifact); `"repair"` + * = timer repair, follows the live flag. + */ + mode?: RecomputeGainMode; + /** Current-run traces (the episode's exact scored set). */ + currentTraces?: readonly TraceRow[]; + /** Current-run with-evidence: associations + induction evidence IDs. */ + withTraceIds?: readonly TraceId[]; + /** Explicit EMA reset (inference-rule/input changes must not blend). */ + resetEma?: boolean; +} + +// ─── Admission floor (shared with association/induction) ───────────────────── + +/** + * L2 admission floor. Enabled mode requires a RESOLVED gainValue at/above + * `minGainValue`; disabled (legacy) mode keeps the `minTraceValue` semantics on + * V. Both modes require an embedding (cosine association cannot run without + * one). Old candidate-pool entries are revalidated through this same check at + * induction time — no historical bulk replay. + */ +export function isInductionEligible( + trace: Pick, + config: Pick, +): boolean { + if (!(trace.vecSummary ?? trace.vecAction)) return false; + if (config.gainV2Enabled) { + // Admission requires the SHARED resolved-value predicate FIRST: only a + // finite, in-[-1,1] gainValue can ever be evidence, so an out-of-range + // score can neither associate nor enter induction evidence / support. + // minGainValue is then applied to the resolved score. + const gain = trace.gainValue; + return isResolvedGainValue(gain) && gain != null && gain >= config.minGainValue; + } + return trace.value >= config.minTraceValue; +} + +// ─── Pure selection + computation ──────────────────────────────────────────── + +interface SelectedTrace { + id: string; + row: GainEvidenceTrace; + score: number; + source: GainValueSource | null; +} + +/** Normalized owner triple (NULL → `unknown`/`default` fallbacks applied). */ +export interface NormalizedOwner { + kind: string; + profile: string; + workspace: string | null; +} + +/** Normalize an owner-shaped row (missing fields → `unknown`/`default`). */ +export function normalizeOwner( + o: { + ownerAgentKind?: string | null; + ownerProfileId?: string | null; + ownerWorkspaceId?: string | null; + }, +): NormalizedOwner { + return { + kind: o.ownerAgentKind ?? "unknown", + profile: o.ownerProfileId ?? "default", + workspace: o.ownerWorkspaceId ?? null, + }; +} + +export interface SelectAndComputeInput { + policy: Pick< + PolicyRow, + | "id" + | "support" + | "gain" + | "gainVersion" + | "ownerAgentKind" + | "ownerProfileId" + | "ownerWorkspaceId" + >; + withIds: readonly string[]; + poolIds: readonly string[]; + tracesById: ReadonlyMap; + scoreMode: "gain" | "value"; + config: Pick; + resetEma?: boolean; + /** + * When true, an unknown-owner policy is rejected from AUTO-MUTATION with the + * distinct `"unknown_owner"` skip reason (ordinary/repair/inference-refresh + * callers). Preview stays read-only and reportable and passes false. + */ + rejectUnknownOwner?: boolean; +} + +/** + * Pure selection + gain computation. No I/O, no writes. Callers own support + * accounting, persistence and queue/journal writes. + */ +export function selectAndComputeGain(input: SelectAndComputeInput): RecomputeGainResult { + const { policy } = input; + const policyOwner = normalizeOwner(policy); + const unknownOwner = policyOwner.kind === "unknown"; + const withIdSet = new Set(input.withIds); + const allIds = Array.from(new Set(input.poolIds)); + + const withBuckets: SelectedTrace[] = []; + const withoutBuckets: SelectedTrace[] = []; + let danglingIds = 0; + let invalidScores = 0; + let outOfNamespace = 0; + let unresolvedWith = 0; + let unresolvedWithout = 0; + + for (const id of allIds) { + const row = input.tracesById.get(id); + if (!row) { + danglingIds++; + continue; + } + if (isBorrowedEvidence(policyOwner, row)) { + outOfNamespace++; + continue; + } + const isWith = withIdSet.has(id); + const resolved = resolveScore(row, input.scoreMode); + if (resolved.kind === "unresolved") { + if (isWith) unresolvedWith++; + else unresolvedWithout++; + continue; + } + if (resolved.kind === "invalid") { + invalidScores++; + continue; + } + (isWith ? withBuckets : withoutBuckets).push({ + id, + row, + score: resolved.score, + source: resolved.source, + }); + } + + withBuckets.sort(byTsDescThenIdDesc); + withoutBuckets.sort(byTsDescThenIdDesc); + const selectedWith = withBuckets.slice(0, MAX_EVIDENCE); + const selectedWithout = withoutBuckets.slice(0, MAX_EVIDENCE); + const withBeyondLimit = withBuckets.length - selectedWith.length; + const withoutBeyondLimit = withoutBuckets.length - selectedWithout.length; + + const provenance = { liveNormalized: 0, inferredNormalized: 0, legacyUnscaled: 0 }; + for (const s of selectedWith) { + if (s.source === "live_normalized") provenance.liveNormalized++; + else if (s.source === "inferred_normalized") provenance.inferredNormalized++; + else if (s.source === "legacy_unscaled") provenance.legacyUnscaled++; + } + + const base = { + selectedWithIds: selectedWith.map((s) => s.id), + selectedWithoutIds: selectedWithout.map((s) => s.id), + withIds: Array.from(withIdSet), + poolIds: allIds, + provenance, + excluded: { unresolvedWith, unresolvedWithout, withBeyondLimit, withoutBeyondLimit }, + reported: { danglingIds, invalidScores, outOfNamespace }, + unknownOwner, + }; + + const skipVersion: 1 | 2 = input.scoreMode === "gain" ? 2 : 1; + const skipResult = (skipReason: "no_resolved_with" | "unknown_owner") => ({ + skipReason, + gainVersion: skipVersion, + isFirst: false, + raw: emptyGain(policy.id), + persistedGain: policy.gain, + ...base, + }); + + // Unknown-owner policies are excluded from automatic mutation: + // the caller still receives the full selection report (preview relies on it) + // but auto-mutation paths must not write gain/support/status for them. + if (unknownOwner && input.rejectUnknownOwner === true) { + return skipResult("unknown_owner"); + } + + if (selectedWith.length === 0) { + return skipResult("no_resolved_with"); + } + + const raw = computeGain( + { + policyId: policy.id, + withTraces: toTraceViews(selectedWith), + withoutTraces: toTraceViews(selectedWithout), + }, + { tauSoftmax: input.config.tauSoftmax }, + ); + const isV2 = input.scoreMode === "gain"; + const gainVersion: 1 | 2 = isV2 ? 2 : 1; + const isFirst = isV2 + ? policy.support === 0 || (policy.gainVersion ?? 1) !== 2 || input.resetEma === true + : policy.support === 0; + const persistedGain = smoothGain({ + newGain: raw.gain, + currentGain: policy.gain, + alpha: input.config.gainEmaAlpha, + isFirst, + }); + return { + skipReason: null, + gainVersion, + isFirst, + raw, + persistedGain, + ...base, + }; +} + +// ─── I/O wrapper ───────────────────────────────────────────────────────────── + +/** + * Gather persisted evidence (with-links ∪ `policy.sourceTraceIds` induction + * evidence + per-episode newest 50 from `episodes.trace_ids_json`), merge + * current-run traces, then delegate to the pure core. Read-only. + */ +export function recomputePolicyGain( + input: RecomputeGainInput, + deps: RecomputeGainRepos, +): RecomputeGainResult { + const { policy } = input; + // Persisted with-evidence = direct trace-policy links ∪ the policy's stored + // induction evidence (`sourceTraceIds`). Imported / feedback-derived policies + // frequently carry sourceTraceIds WITHOUT any trace-policy links; ordinary + // L2, preview and repair must all see the same evidence union. + const sourceTraceIds = (policy.sourceTraceIds ?? []).map(String); + const linkedWith = dedup([ + ...deps.tracePolicyLinks.getWithTraceIds(policy.id).map(String), + ...sourceTraceIds, + ]); + const extraWith = (input.withTraceIds ?? []).map(String); + const withIds = dedup([...linkedWith, ...extraWith]); + + const currentIds: string[] = []; + const tracesById = new Map(); + for (const t of input.currentTraces ?? []) { + const key = String(t.id); + currentIds.push(key); + tracesById.set(key, toEvidenceFromTraceRow(t)); + } + + // Linked episodes come from BOTH direct links and the source traces (their + // episodes may have no direct link row at all). + const linkedEpisodeIds = new Set( + deps.tracePolicyLinks.getLinkedEpisodeIds(policy.id).map(String), + ); + if (sourceTraceIds.length > 0) { + for (const r of deps.traces.getGainRowsByIds(sourceTraceIds)) { + linkedEpisodeIds.add(String(r.episodeId)); + // Cache now so the `missing` batch below does not re-fetch these rows. + tracesById.set(String(r.id), toEvidenceFromGainRow(r)); + } + } + + const perEpisodeIds: string[] = []; + for (const epId of linkedEpisodeIds) { + const ep = deps.episodes.getById(epId as EpisodeId); + if (!ep) continue; + const ids = (ep.traceIds ?? []).map(String); + if (ids.length === 0) continue; + const rows = deps.traces.getGainRowsByIds(ids); + // Orphan diagnosability (the pass leaves traces-table rows never folded + // into trace_ids_json unresolved for inference; the L2 pool likewise + // draws only from S): one indexed COUNT per linked episode vs the S + // members actually present, debug-logged when nonzero so baseline + // shifts are diagnosable. No behavior/score change — the pool below is + // built from S exactly as before. + try { + const total = deps.traces.count({ episodeId: epId as EpisodeId }); + const orphansExcluded = Math.max(0, total - rows.length); + if (orphansExcluded > 0) { + log.debug("recompute_gain.orphans_excluded", { + policyId: String(policy.id), + episodeId: epId, + orphansExcluded, + poolMembers: rows.length, + }); + } + } catch { + // Diagnosability must never break selection — skip the signal. + } + // Contract: timestamp DESC then ID DESC (matches the final selection sort). + rows.sort((a, b) => b.ts - a.ts || String(b.id).localeCompare(String(a.id))); + for (const r of rows.slice(0, NEWEST_PER_EPISODE)) perEpisodeIds.push(String(r.id)); + // Cache the fetched members now so the `missing` batch does not re-fetch + // the pool rows we already have in hand for this linked episode. + for (const r of rows) tracesById.set(String(r.id), toEvidenceFromGainRow(r)); + } + + const poolIds = dedup([...withIds, ...currentIds, ...perEpisodeIds]); + + const missing = poolIds.filter((id) => !tracesById.has(id)); + for (const r of deps.traces.getGainRowsByIds(missing)) { + const key = String(r.id); + if (!tracesById.has(key)) tracesById.set(key, toEvidenceFromGainRow(r)); + } + + const scoreMode: "gain" | "value" = + input.mode === "preview" || input.config.gainV2Enabled ? "gain" : "value"; + + return selectAndComputeGain({ + policy, + withIds, + poolIds, + tracesById, + scoreMode, + config: { gainEmaAlpha: input.config.gainEmaAlpha, tauSoftmax: input.config.tauSoftmax }, + resetEma: input.resetEma === true || input.mode === "inference_refresh", + // Unknown-owner policies are excluded from automatic mutation in EVERY + // mode (v2 or legacy): only a successful owner-validated calculation may + // write gain/support/status. Preview is read-only and reportable so it + // always computes. + rejectUnknownOwner: input.mode !== "preview", + }); +} + +// ─── Queue reconciliation from the evidence union (startup) ───────────────── + +export interface GainRepairUnionReconcileDeps extends RecomputeGainRepos { + db: StorageDb; + kv: ReturnType; + gainRepair: ReturnType; + policies: Pick, "list">; + owner: { ownerAgentKind: string; ownerProfileId: string; ownerWorkspaceId?: string | null }; + inferenceVersion?: number; + now?: () => number; +} + +export interface GainRepairUnionReconcileResult { + /** Policies upserted as pending (≥ 1 resolved with-link survives). */ + seeded: number; + /** Policies seeded directly as blocked (zero resolved with-links). */ + blocked: number; + /** Archived / missing queue rows reconciled away. */ + reconciled: number; +} + +/** + * Rebuild the repair queue from the COMPLETE evidence union (with-links ∪ + * `source_trace_ids_json` induction evidence). The repair queue is never + * treated as authoritative: every candidate/active policy of the owner is + * re-derived, archived/missing entries are reconciled away, and policies with + * zero resolved with-links are seeded as `blocked` directly — no attempt, no + * budget spent. + * + * Pure queue/state writes: never touches policy fields, support or status. + */ +export function reconcileGainRepairQueueFromEvidenceUnion( + deps: GainRepairUnionReconcileDeps, +): GainRepairUnionReconcileResult { + const version = deps.inferenceVersion ?? GAIN_INFERENCE_VERSION; + const now = deps.now ?? Date.now; + const owner = deps.owner; + + return deps.db.tx(() => { + const reconciled = deps.gainRepair.reconcileArchivedOrMissing(owner); + // Explicit large cap: `policies.list` defaults to 500 rows and the + // reconcile must rebuild the queue from the COMPLETE evidence union. The + // owner triple is pushed into SQL (kind/profile `=`, workspace NULL- + // exact `IS` — Gate 2) so other owners' rows never leave the database; + // the shared `isExactOwner` predicate stays as the exact in-JS gate + // (policies columns are NOT NULL DEFAULT 'unknown'/'default', so the + // SQL pre-filter cannot narrow beyond what the predicate accepts — + // no semantics change, just fewer rows hydrated). + const ownerFilter = { + ownerAgentKind: owner.ownerAgentKind, + ownerProfileId: owner.ownerProfileId, + ownerWorkspaceId: owner.ownerWorkspaceId ?? null, + }; + const targets = [ + ...deps.policies.list({ status: "candidate", limit: 100_000, ...ownerFilter }), + ...deps.policies.list({ status: "active", limit: 100_000, ...ownerFilter }), + ].filter((p) => isExactOwner(p, owner)); + + let seeded = 0; + let blocked = 0; + for (const policy of targets) { + const withIds = dedup([ + ...deps.tracePolicyLinks.getWithTraceIds(policy.id).map(String), + ...(policy.sourceTraceIds ?? []).map(String), + ]); + let resolved = 0; + if (withIds.length > 0) { + const policyOwner = normalizeOwner(policy); + for (const r of deps.traces.getGainRowsByIds(withIds)) { + // Same validity predicate as the selector: NULL is unresolved and + // non-finite/out-of-range scores are NOT resolved evidence. + if (!isResolvedGainValue(r.gainValue)) continue; + if (isBorrowedEvidence(policyOwner, r)) continue; + resolved++; + } + } + // Preserve explicit inference invalidation: an existing + // `inference_refresh` entry must NOT be downgraded to + // `inferred_evidence_updated` by the rebuild — it survives until a + // successful refreshed calculation resolves it (the timer removes the + // queue entry on completion, so any surviving entry is unresolved). + const existing = deps.gainRepair.getByPolicy(policy.id); + const reason = + existing?.reason === "inference_refresh" + ? "inference_refresh" + : "inferred_evidence_updated"; + const common = { + policyId: policy.id, + ownerAgentKind: owner.ownerAgentKind, + ownerProfileId: owner.ownerProfileId, + ownerWorkspaceId: owner.ownerWorkspaceId ?? null, + inferenceVersion: version, + now: now(), + }; + if (resolved > 0) { + deps.gainRepair.upsertPending({ ...common, reason }); + seeded++; + } else { + deps.gainRepair.upsertBlocked({ + ...common, + reason, + blockedReason: "no_resolved_with", + }); + blocked++; + } + } + + // Keep the durable seed watermark coherent (the union rebuild above + // is authoritative regardless; the marker only prevents older seed paths + // from double-running after a crash). + deps.kv.set(GAIN_REPAIR_QUEUE_SEED_KEY, { + version, + ownerAgentKind: owner.ownerAgentKind, + ownerProfileId: owner.ownerProfileId, + seededAt: now(), + }); + return { seeded, blocked, reconciled }; + }); +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +type ResolvedScore = + | { kind: "resolved"; score: number; source: GainValueSource | null } + | { kind: "unresolved" } + | { kind: "invalid" }; + +/** + * Shared resolved-gainValue validity predicate: NULL is unresolved and + * non-finite / out-of-[-1,1] values are NOT resolved evidence. Used by both + * the selector (`resolveScore`) and the queue rebuild so a policy can never + * be seeded `pending` on an invalid score. + */ +export function isResolvedGainValue(gainValue: number | null | undefined): boolean { + return gainValue != null && Number.isFinite(gainValue) && Math.abs(gainValue) <= 1; +} + +function resolveScore(row: GainEvidenceTrace, mode: "gain" | "value"): ResolvedScore { + if (mode === "gain") { + if (row.gainValue == null) return { kind: "unresolved" }; + if (!isResolvedGainValue(row.gainValue)) return { kind: "invalid" }; + return { kind: "resolved", score: row.gainValue, source: row.gainValueSource ?? null }; + } + if (!Number.isFinite(row.value)) return { kind: "invalid" }; + return { kind: "resolved", score: row.value, source: null }; +} + +/** + * A trace is borrowed evidence only when it carries a REAL owner that differs + * from the policy owner. NULL / `unknown`-owner traces are the shared space + * (see file header) — matching them is not borrowing another owner's evidence. + * Exported for the re-screen requeue, which must apply the SAME + * resolved-evidence predicate as this module's union reconcile. + */ +export function isBorrowedEvidence( + policyOwner: NormalizedOwner, + trace: Pick, +): boolean { + const t = normalizeOwner(trace); + if (t.kind === "unknown") return false; + return !(t.kind === policyOwner.kind && t.profile === policyOwner.profile && t.workspace === policyOwner.workspace); +} + +function toTraceViews(entries: readonly SelectedTrace[]): TraceRow[] { + const out: TraceRow[] = []; + for (const e of entries) { + out.push({ + id: e.row.id, + episodeId: e.row.episodeId, + sessionId: "", + ts: e.row.ts, + userText: "", + agentText: "", + toolCalls: [], + reflection: null, + value: e.score, + alpha: 0, + rHuman: null, + priority: 0, + tags: [], + vecSummary: null, + vecAction: null, + turnId: 0, + schemaVersion: 0, + }); + } + return out; +} + +function emptyGain(policyId: PolicyId): GainResult { + return { + policyId, + gain: 0, + withMean: 0, + withoutMean: 0, + withCount: 0, + withoutCount: 0, + weightedWith: 0, + poolMean: 0, + baseline: 0, + }; +} + +function byTsDescThenIdDesc(a: SelectedTrace, b: SelectedTrace): number { + return b.row.ts - a.row.ts || b.id.localeCompare(a.id); +} + +function toEvidenceFromTraceRow(t: TraceRow): GainEvidenceTrace { + return { + id: t.id, + episodeId: t.episodeId, + ts: t.ts, + value: t.value, + gainValue: t.gainValue ?? null, + gainValueSource: t.gainValueSource ?? null, + ownerAgentKind: t.ownerAgentKind, + ownerProfileId: t.ownerProfileId, + ownerWorkspaceId: t.ownerWorkspaceId, + }; +} + +function toEvidenceFromGainRow(r: ReturnType["getGainRowsByIds"]>[number]): GainEvidenceTrace { + return { + id: r.id as TraceId, + episodeId: r.episodeId as EpisodeId, + ts: r.ts, + value: r.value, + gainValue: r.gainValue, + gainValueSource: r.gainValueSource, + ownerAgentKind: r.ownerAgentKind, + ownerProfileId: r.ownerProfileId, + ownerWorkspaceId: r.ownerWorkspaceId, + }; +} + +function dedup(ids: readonly string[]): string[] { + return Array.from(new Set(ids)); +} diff --git a/apps/memos-local-plugin/core/memory/l2/subscriber.ts b/apps/memos-local-plugin/core/memory/l2/subscriber.ts index 2682ab7b0..3fd772dfc 100644 --- a/apps/memos-local-plugin/core/memory/l2/subscriber.ts +++ b/apps/memos-local-plugin/core/memory/l2/subscriber.ts @@ -15,7 +15,7 @@ import type { LlmClient } from "../../llm/index.js"; import type { Logger } from "../../logger/types.js"; -import type { EpisodeId, TraceRow } from "../../types.js"; +import type { EpisodeId, TraceId, TraceRow } from "../../types.js"; import type { Repos } from "../../storage/repos/index.js"; import type { RewardEventBus, RewardResult } from "../../reward/index.js"; import type { StorageDb } from "../../storage/types.js"; @@ -24,7 +24,16 @@ import type { L2Config, L2EventBus } from "./types.js"; export interface L2SubscriberDeps { db: StorageDb; - repos: Pick; + repos: Pick< + Repos, + | "candidatePool" + | "embeddingRetryQueue" + | "episodes" + | "gainRepair" + | "policies" + | "tracePolicyLinks" + | "traces" + >; rewardBus: RewardEventBus; l2Bus: L2EventBus; llm: LlmClient | null; @@ -155,7 +164,6 @@ export function attachL2Subscriber(deps: L2SubscriberDeps): L2SubscriberHandle { } }, async runOnce(episodeId, opts): Promise { - const ep = deps.repos.traces; // just to silence TS unused check const traces: TraceRow[] = []; const rows = deps.db .prepare<{ episode_id: string }, { id: string }>( @@ -163,7 +171,7 @@ export function attachL2Subscriber(deps: L2SubscriberDeps): L2SubscriberHandle { ) .all({ episode_id: episodeId }); for (const r of rows) { - const t = ep.getById(r.id as unknown as Parameters[0]); + const t = deps.repos.traces.getById(r.id as TraceId); if (t) traces.push(t); } if (traces.length === 0) return; diff --git a/apps/memos-local-plugin/core/memory/l2/types.ts b/apps/memos-local-plugin/core/memory/l2/types.ts index 33da18e8e..0cccf98e6 100644 --- a/apps/memos-local-plugin/core/memory/l2/types.ts +++ b/apps/memos-local-plugin/core/memory/l2/types.ts @@ -41,7 +41,7 @@ export interface L2Config { tauSoftmax: number; /** When true, call the LLM to induce new L2 policies; else skip induction. */ useLlm: boolean; - /** Minimum trace V (after reward) to consider for any L2 update. */ + /** Minimum trace V (after reward) to consider for any L2 update (legacy mode). */ minTraceValue: number; /** Minimum #distinct episodes required to mint a new L2 policy. */ minEpisodesForInduction: number; @@ -49,6 +49,26 @@ export interface L2Config { inductionTraceCharCap: number; /** EMA alpha for gain smoothing. */ gainEmaAlpha: number; + /** + * use `gainValue` (clamp(N·V, -1, 1)) for L2 gain/induction and + * permit configured repair. Disabled keeps legacy `minTraceValue` semantics + * on V; disabling v2 after v2 gains are written is not a clean rollback. + */ + gainV2Enabled: boolean; + /** resolved `gainValue` induction floor for enabled mode. */ + minGainValue: number; + /** repair attempts per timer tick (integer 0..25); 0 pauses repair. */ + gainRepairBatchSize: number; + /** timer cadence in ms (integer 60000..86399999). */ + gainRepairIntervalMs: number; + /** durable absolute total-attempt ceiling; null = unlimited. */ + gainRepairMaxTotal: number | null; + /** config-driven re-screen generation (nonnegative integer). */ + gainRepairRescreenGeneration: number; + /** Per-boot historical inference group cap. */ + gainInferenceBootMaxGroups: number; + /** Per-boot historical inference time budget in ms. */ + gainInferenceBootTimeBudgetMs: number; } // ─── Pattern signature ───────────────────────────────────────────────────── diff --git a/apps/memos-local-plugin/core/pipeline/deps.ts b/apps/memos-local-plugin/core/pipeline/deps.ts index 79714b35e..fdf164eda 100644 --- a/apps/memos-local-plugin/core/pipeline/deps.ts +++ b/apps/memos-local-plugin/core/pipeline/deps.ts @@ -136,6 +136,20 @@ export function extractAlgorithmConfig( minEpisodesForInduction: alg.l2Induction.minEpisodesForInduction, inductionTraceCharCap: alg.l2Induction.traceCharCap, gainEmaAlpha: alg.l2Induction.gainEmaAlpha, + // all SIX v2 scoring/repair keys are enumerated explicitly so + // runL2 and the shared recompute helper never read past the typed slice + // slice, and the repair callers share one L2Config shape. + // The repair timer / preview / rollback (Phases C/D) read the full + // ResolvedConfig handle instead: handle.config.algorithm.l2Induction, + // never handle.algorithm. + gainV2Enabled: alg.l2Induction.gainV2Enabled, + minGainValue: alg.l2Induction.minGainValue, + gainRepairBatchSize: alg.l2Induction.gainRepairBatchSize, + gainRepairIntervalMs: alg.l2Induction.gainRepairIntervalMs, + gainRepairMaxTotal: alg.l2Induction.gainRepairMaxTotal, + gainRepairRescreenGeneration: alg.l2Induction.gainRepairRescreenGeneration, + gainInferenceBootMaxGroups: alg.l2Induction.gainInferenceBootMaxGroups, + gainInferenceBootTimeBudgetMs: alg.l2Induction.gainInferenceBootTimeBudgetMs, }, l3Abstraction: alg.l3Abstraction, skill: alg.skill, @@ -275,6 +289,7 @@ export function buildPipelineSubscribers( llm: bgLlm, bus: buses.reward, cfg: algorithm.reward, + db: deps.db, evaluator: { reflectionProvider: bgReflectLlm?.provider, reflectionModel: bgReflectLlm?.model, diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index 660ea737c..5486b44c3 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -54,6 +54,8 @@ import type { EmbeddingMaintenanceStats, MemoryCore, MemorySearchExecutionOptions, + GainPreviewResult, + GainRollbackResult, Unsubscribe, } from "../../agent-contract/memory-core.js"; import type { @@ -124,6 +126,14 @@ import type { TraceCandidate, } from "../retrieval/types.js"; import type { UserFeedback } from "../reward/types.js"; +import { runGainInference, GAIN_INFERENCE_VERSION, GAIN_INFERENCE_BOOT_MAX_GROUPS, GAIN_INFERENCE_BOOT_TIME_BUDGET_MS } from "../reward/gain-inference.js"; +import { reconcileGainRepairQueueFromEvidenceUnion } from "../memory/l2/recompute-gain.js"; +import { runGainRepairTick } from "../memory/l2/gain-repair.js"; +import { + previewGainRepair as previewGainRepairImpl, + rollbackGainRepair as rollbackGainRepairImpl, +} from "../memory/l2/gain-maintenance.js"; +import type { L2Config } from "../memory/l2/types.js"; // ─── Public bootstrap helpers ─────────────────────────────────────────────── @@ -270,6 +280,70 @@ export async function bootstrapMemoryCoreFull( } const repos = makeRepos(db); + // ─── Historical gain inference ── + // Idempotent TypeScript pass that runs after migrations and BEFORE recovery + // / timer / consumers start, so partially converted groups are never + // observable. It never alters V/priority or any policy field; it only + // stamps traces.gain_value / gain_value_source / gain_inference_version and + // then seeds/reconciles the repair queue for affected candidate/active + // policies (archived policies are not repair targets). Restarts never + // rescan stamped unresolved groups. + { + try { + const owner = ownerFromNamespace(namespace); + // Bounded per-boot inference (review): the pass stays + // synchronous in init order — inference before repair scheduling, so + // ordinary consumers never observe partially converted groups — but a + // large backlog must not run unbounded into the initWatchdogMs kill + // (PR #40 precedent). Resume needs no new durable state: every attempt + // stamps gain_inference_version (unstamped rows are revisited next + // boot) and new stamps clear the queue-seed watermark in the same + // transaction. Deliberately NOT gated behind gainV2Enabled (rollout + // step 1 requires inference verified while the flag is false), and + // unresolved rows stay NULL (never zero) so partial progress degrades + // safe. + const gainReport = runGainInference({ + db, + kv: repos.kv, + episodesRepo: repos.episodes, + tracesRepo: repos.traces, + owner, + maxGroups: config.algorithm.l2Induction.gainInferenceBootMaxGroups ?? GAIN_INFERENCE_BOOT_MAX_GROUPS, + timeBudgetMs: + config.algorithm.l2Induction.gainInferenceBootTimeBudgetMs ?? GAIN_INFERENCE_BOOT_TIME_BUDGET_MS, + }); + // full queue reconciliation from the evidence union (with + // links ∪ source_trace_ids_json induction evidence). The repair queue + // is NOT authoritative: every candidate/active policy of the owner is + // re-derived, and policies with zero resolved with-links are seeded + // directly as blocked (no attempt, no budget spent); the + // budget cap is not consumed. Never touches policy fields. + const queueResult = reconcileGainRepairQueueFromEvidenceUnion({ + db, + kv: repos.kv, + gainRepair: repos.gainRepair, + policies: repos.policies, + traces: repos.traces, + episodes: repos.episodes, + tracePolicyLinks: repos.tracePolicyLinks, + owner, + }); + if (gainReport.candidateGroups > 0 || gainReport.truncated || queueResult.seeded > 0 || queueResult.blocked > 0 || queueResult.reconciled > 0) { + log.info("gain_inference.startup", { + report: gainReport, + queue: queueResult, + }); + } + } catch (err) { + // The pass must never prevent bootstrap — a failure leaves traces + // unscreened (they will be picked up on a later boot) and the rest of + // the pipeline continues untouched. + log.warn("gain_inference.startup_failed", { + err: err instanceof Error ? err.message : String(err), + }); + } + } + // ─── Host LLM bridge ── // Register the adapter-supplied bridge BEFORE constructing any // LlmClient so the very first call site sees a non-null bridge. @@ -727,6 +801,117 @@ export function createMemoryCore( let startupRecoveryCancelled = false; let lastStaleScan = 0; let lastDirtyClosedScan = 0; + + // ─── Gain-repair timer (independent of autoRecovery) ── + // One periodic timer (default 15 min from gainRepairIntervalMs), unref'd, + // cleared on shutdown. It does NOT depend on reward traffic, L2 traffic or + // autoRecoveryEnabled; there is NO immediate run at init — the first attempt + // happens on the first enabled interval tick. Overlapping ticks are skipped + // (single-flight per namespace), and in-flight work is awaited before + // storage closes so a mid-attempt shutdown never leaves a torn transaction. + let gainRepairTimer: ReturnType | null = null; + let gainRepairInFlight: Promise | null = null; + /** + * shape the current process-loaded `l2Induction` slice (+ reward + * knobs) into the shared `L2Config`. Shared by the repair timer and the + * two maintenance RPCs so all three always see the same process-loaded + * scoring configuration; a config-file edit applies after a daemon + * restart. See the NOTE above: per-read freshness within the process, + * not a file reload. + */ + function l2ConfigSlice(): L2Config { + const raw = handle.config.algorithm.l2Induction; + const reward = handle.config.algorithm.reward; + return { + minSimilarity: raw.minSimilarity, + candidateTtlDays: raw.candidateTtlDays, + gamma: reward.gamma, + tauSoftmax: reward.tauSoftmax, + useLlm: raw.useLlm, + minTraceValue: raw.minTraceValue, + minEpisodesForInduction: raw.minEpisodesForInduction, + inductionTraceCharCap: raw.traceCharCap, + gainEmaAlpha: raw.gainEmaAlpha, + gainV2Enabled: raw.gainV2Enabled, + minGainValue: raw.minGainValue, + gainRepairBatchSize: raw.gainRepairBatchSize, + gainRepairIntervalMs: raw.gainRepairIntervalMs, + gainRepairMaxTotal: raw.gainRepairMaxTotal, + gainRepairRescreenGeneration: raw.gainRepairRescreenGeneration, + gainInferenceBootMaxGroups: raw.gainInferenceBootMaxGroups, + gainInferenceBootTimeBudgetMs: raw.gainInferenceBootTimeBudgetMs, + }; + } + async function runGainRepairTickSafe(): Promise { + try { + // Shape the current ResolvedConfig slice into the shared L2Config + // (never handle.algorithm, which is a frozen pipeline-build snapshot). + // NOTE: "current" means the process-loaded config object — there is no + // in-process config reload, so a config-file edit only takes effect + // after a daemon restart. The per-tick read simply avoids going stale + // within the process lifetime; it does not pick up file changes live. + const l2Config = l2ConfigSlice(); + // Gate here too (the engine also gates): batch size 0 pauses repair; + // v2 disabled means repair is not permitted. Gated ticks do nothing. + if (!l2Config.gainV2Enabled || l2Config.gainRepairBatchSize <= 0) return; + const owner = ownerFromNamespace(handle.namespace); + const result = await runGainRepairTick({ + db: handle.db, + repos: handle.repos, + config: l2Config, + owner, + thresholds: { + minSupport: handle.config.algorithm.skill.minSupport, + minGain: handle.config.algorithm.skill.minGain, + archiveGain: handle.config.algorithm.l2Induction.archiveGain, + }, + log: log.child({ channel: "core.memory.l2.gain_repair" }), + inferenceVersion: GAIN_INFERENCE_VERSION, + }); + log.info("gain_repair.tick", { + batchId: result.batchId, + attempted: result.attempted, + rescored: result.rescored, + promoted: result.promoted, + blocked: result.blocked, + conflicted: result.conflicted, + failed: result.failed, + reconciled: result.reconciled, + budget: result.budget, + durationMs: result.durationMs, + }); + } catch (err) { + // Repair errors use a dedicated audit event — NEVER l2.failed and never + // a policy_generate/api_log failure row. + log.warn("gain_repair.tick.error", { + err: err instanceof Error ? err.message : String(err), + }); + } + } + function startGainRepairTimer(): void { + if (gainRepairTimer) { + clearInterval(gainRepairTimer); + gainRepairTimer = null; + } + const intervalMs = handle.config.algorithm.l2Induction.gainRepairIntervalMs; + if (!Number.isFinite(intervalMs) || intervalMs <= 0) return; + // Scored repairs can silently stay paused if v2 is enabled while the + // batch size is zero — surface that once so it is not mistaken for a + // healthy idle timer. + const live = l2ConfigSlice(); + if (live.gainV2Enabled && live.gainRepairBatchSize <= 0) { + log.warn("gain_repair.timer.paused", { + reason: "gainRepairBatchSize is 0 while gainV2Enabled is true", + }); + } + gainRepairTimer = setInterval(() => { + if (gainRepairInFlight) return; // single-flight: overlapping tick skipped + gainRepairInFlight = runGainRepairTickSafe().finally(() => { + gainRepairInFlight = null; + }); + }, intervalMs); + (gainRepairTimer as unknown as { unref?: () => void }).unref?.(); + } async function autoFinalizeStaleTasks(): Promise { if (!autoRecoveryEnabled) return; const nowMs = Date.now(); @@ -1274,6 +1459,12 @@ export function createMemoryCore( (rescoreInterval as unknown as { unref?: () => void }).unref?.(); } + // independent gain-repair timer. Same setInterval+unref + // pattern as the rescore timer, but NOT gated on autoRecoveryEnabled and + // NOT on L2/reward traffic. No immediate run at init: the first attempt + // happens on the first enabled interval tick. + startGainRepairTimer(); + // Wire `memory_add` into the api_logs table on EVERY turn so the // Logs viewer shows per-turn capture activity. `capture.lite.done` // fires once per `onTurnEnd` (the per-turn lite capture path); @@ -2008,6 +2199,21 @@ export function createMemoryCore( }); } } + // clear the gain-repair timer and wait for any + // in-flight tick so a mid-attempt shutdown never closes storage with + // a torn reservation/outcome transaction. + if (gainRepairTimer) { + clearInterval(gainRepairTimer); + gainRepairTimer = null; + } + if (gainRepairInFlight) { + try { + await gainRepairInFlight; + } catch { + /* the tick already logs its own error */ + } + gainRepairInFlight = null; + } try { await hubRuntime?.stop(); } catch (err) { @@ -3382,6 +3588,79 @@ export function createMemoryCore( return updated ? policyRowToDTO(updated) : null; } + /** + * `policies.gainPreview`. Read-only, paginated, exact + * namespace: recompute every candidate/active policy of the namespace + * through the shared helper in preview mode and report gains, counts, + * proposed transitions, queue/budget state and legacy summaries. ZERO + * database writes — a sanity check, not a frozen approval artifact. + */ + async function previewGainRepair(input: { + namespace: RuntimeNamespace; + limit?: number; + offset?: number; + }): Promise { + ensureLive(); + if (!input?.namespace) { + throw new MemosError( + "invalid_argument", + "policies.gainPreview: 'namespace' is required (exact namespace)", + ); + } + // Read-only preview: never mutates the process default namespace — + // `gainMaintenanceDeps(input.namespace)` already scopes every read. + return previewGainRepairImpl( + gainMaintenanceDeps(input.namespace), + { limit: input.limit, offset: input.offset }, + ); + } + + /** + * `policies.gainRollback`. Policy-field CAS rollback of one + * journal batch or an explicit journal-ID list within the exact namespace. + * See the contract docstring for the compare-before-write, fresh-timestamp, + * support/evidence-preservation and no-budget-refund guarantees, and the + * pause-before-rollback / re-screen-resumption / backup operational steps. + */ + async function rollbackGainRepair(input: { + namespace: RuntimeNamespace; + batchId?: string; + journalIds?: readonly string[]; + }): Promise { + ensureLive(); + if (!input?.namespace) { + throw new MemosError( + "invalid_argument", + "policies.gainRollback: 'namespace' is required (exact namespace)", + ); + } + // Read-only concerns only; never mutates the process default namespace. + return rollbackGainRepairImpl( + gainMaintenanceDeps(input.namespace), + { batchId: input.batchId, journalIds: input.journalIds }, + ); + } + + /** + * shared deps for the two maintenance RPCs: the process-loaded + * config slice (same builder the timer uses; a config-file edit applies + * after a daemon restart), the exact-namespace owner and the live + * promotion thresholds. + */ + function gainMaintenanceDeps(namespace: RuntimeNamespace) { + return { + db: handle.db, + repos: handle.repos, + config: l2ConfigSlice(), + owner: ownerFromNamespace(namespace), + thresholds: { + minSupport: handle.config.algorithm.skill.minSupport, + minGain: handle.config.algorithm.skill.minGain, + }, + inferenceVersion: GAIN_INFERENCE_VERSION, + }; + } + async function getWorldModel( id: string, namespace?: RuntimeNamespace, @@ -4514,6 +4793,12 @@ export function createMemoryCore( boundary: dto.boundary, support: dto.support ?? 0, gain: dto.gain ?? 0, + // imports must never trust supplied policy + // certification: bundle-v1 has no gainValue provenance on the + // trace side, so imported policies stay UNRESOLVED and + // UNCERTIFIED (gain_version 1). Eligible (candidate/active) + // policies are queued below without changing status/support. + gainVersion: 1, status: dto.status, experienceType: dto.experienceType ?? "success_pattern", evidencePolarity: dto.evidencePolarity ?? "positive", @@ -4534,6 +4819,24 @@ export function createMemoryCore( createdAt: dto.createdAt ?? Date.now(), updatedAt: dto.updatedAt ?? Date.now(), }); + // queue eligible uncertified imported policies WITHOUT + // changing their status/support. The next boot's union reconcile + // re-derives pending/blocked from actual resolved evidence + // (imported traces are unresolved until screened). + try { + if (dto.status === "candidate" || dto.status === "active") { + handle.repos.gainRepair.upsertPending({ + policyId: dto.id, + ownerAgentKind: dto.ownerAgentKind ?? defaultOwner.ownerAgentKind, + ownerProfileId: dto.ownerProfileId ?? defaultOwner.ownerProfileId, + ownerWorkspaceId: dto.ownerWorkspaceId ?? defaultOwner.ownerWorkspaceId, + reason: "inferred_evidence_updated", + inferenceVersion: GAIN_INFERENCE_VERSION, + }); + } + } catch { + // Queue bookkeeping must never fail the policy import. + } batchImported++; } catch { batchSkipped++; @@ -5245,6 +5548,8 @@ export function createMemoryCore( setPolicyStatus, deletePolicy, editPolicyGuidance, + previewGainRepair, + rollbackGainRepair, getWorldModel, listWorldModels, countWorldModels, diff --git a/apps/memos-local-plugin/core/reward/gain-inference.ts b/apps/memos-local-plugin/core/reward/gain-inference.ts new file mode 100644 index 000000000..5c3a17db6 --- /dev/null +++ b/apps/memos-local-plugin/core/reward/gain-inference.ts @@ -0,0 +1,707 @@ +/** + * `gain-inference.ts` — idempotent historical gain inference. + * + * A one-time TypeScript pass that runs after migrations and before consumers + * / timers. For every episode's reward-pass set S (the distinct IDs in + * `episodes.trace_ids_json` — NEVER all `episode_id` rows) it: + * + * 1. requires finite V ∈ [-1,1] and finite r_human ∈ [-1,1] on every member, + * with reward consistency within 1e-9; + * 2. requires nonzero V to share R's sign (R = 0 ⇒ all V = 0); + * 3. cross-checks `meta.reward.traceIds` exact-set equality when present; + * 4. conserving groups → gainValue = clamp(V · nonzeroCount, -1, 1) with + * `inferred_normalized` provenance; + * 5. non-conserving groups that still satisfy integrity → gainValue = V with + * `legacy_unscaled` provenance; + * 6. everything else stays unresolved (NULL gain_value / NULL source). + * + * Every screening attempt — including unresolved ones — stamps + * `gain_inference_version`, so a restart never rescans stamped groups. On an + * inference-version bump, lower-stamp `inferred_normalized` / `legacy_unscaled` + * rows are revisited. `live_normalized` rows are never overwritten. + * + * Orphan rows sharing `episode_id` but not listed in S are outside the reward + * pass: they stay unresolved and are reported separately. + */ + +import type { EpisodeId, TraceId } from "../types.js"; +import { contributionGainValues } from "./gain-value.js"; +import { rootLogger } from "../logger/index.js"; +import type { StorageDb } from "../storage/types.js"; +import type { makeEpisodesRepo } from "../storage/repos/episodes.js"; +import type { makeTracesRepo } from "../storage/repos/traces.js"; +import type { makeGainRepairRepo } from "../storage/repos/gain-repair.js"; +import type { makeKvRepo } from "../storage/repos/kv.js"; +import type { GainValueSource } from "../types.js"; + +export const GAIN_INFERENCE_VERSION = 1; + +/** + * Per-boot bound for the startup inference pass (review): the pass + * runs synchronously inside bridge init (spec ordering — before repair + * scheduling, so consumers never observe partially converted groups), and an + * unbounded run over a large backlog risks the `initWatchdogMs` kill → + * restart crash-loop (PR #40 precedent). Both bounds are deliberately + * conservative against the 120s watchdog default; resume is durable with NO + * new state (every attempt stamps `gain_inference_version`, so unstamped + * rows are revisited next boot and new stamps already clear the queue-seed + * watermark in the same transaction). + */ +export const GAIN_INFERENCE_BOOT_MAX_GROUPS = 2000; +/** Wall-clock budget per boot for the startup inference pass. */ +export const GAIN_INFERENCE_BOOT_TIME_BUDGET_MS = 30_000; + +/** Audit boundary for legacy_unscaled reports — not an eligibility gate. */ +export const GAIN_POST_CUTOVER_BOUNDARY_MS = Date.parse("2026-06-22T00:00:00Z"); + +const REWARD_CONSISTENCY_TOLERANCE = 1e-9; +const CONSERVATION_ABS_TOLERANCE = 0.002; +const CONSERVATION_REL_TOLERANCE = 0.01; + +const log = rootLogger.child({ channel: "core.reward.gain_inference" }); + +// ─── Pure per-group screening ──────────────────────────────────────────────── + +export interface GainGroupMember { + id: string; + episodeId: string; + value: number; + rHuman: number | null; + ts: number; + ownerAgentKind?: string; + ownerProfileId?: string; + ownerWorkspaceId?: string | null; +} + +export interface GainGroupInput { + episodeId: string; + /** S — distinct trace IDs listed in `episodes.trace_ids_json`. */ + traceIds: readonly string[]; + /** Fetched members; must cover every id in `traceIds`. */ + members: readonly GainGroupMember[]; + /** + * `episode.meta.reward.traceIds` when present. May be any JSON value — a + * scalar/object/wrong-shaped value MUST resolve the group unresolved (NULL), + * never a numeric gain and never a passed cross-check. + */ + metaRewardTraceIds?: unknown; + episodeOwnerAgentKind?: string; + episodeOwnerProfileId?: string; + /** + * Episode workspace for the ownership check. NULL-exact like the SQL + * owner filters (`owner_workspace_id IS @workspace_id`): NULL matches + * only NULL, never a wildcard. Absent (undefined) skips the workspace + * comparison for callers that do not carry it. + */ + episodeOwnerWorkspaceId?: string | null; +} + +export type GainGroupStatus = GainValueSource | "unresolved"; + +export interface GainGroupOutcome { + status: GainGroupStatus; + reason: string; + /** gainValue per member id (only for inferred_normalized / legacy_unscaled). */ + gainByTraceId: ReadonlyMap; + /** Number of nonzero contributions in S. */ + nonzeroCount: number; + sumV: number; + reward: number; +} + +export function screenGainGroup(input: GainGroupInput): GainGroupOutcome { + const { episodeId, traceIds } = input; + const members = input.members; + const S = Array.from(new Set(traceIds)); + + const unresolved = (reason: string): GainGroupOutcome => ({ + status: "unresolved", + reason, + gainByTraceId: new Map(), + nonzeroCount: 0, + sumV: 0, + reward: 0, + }); + + // A nonempty, valid list is required. + if (S.length === 0) return unresolved("empty_set"); + + // Every listed member must exist — never derive N from a partial set. + if (members.length !== S.length) return unresolved("missing_listed_member"); + const memberById = new Map(members.map((m) => [m.id, m])); + for (const id of S) { + if (!memberById.has(id)) return unresolved("missing_listed_member"); + } + + // Members must belong to the episode (and, when known, the episode owner). + // Workspace follows the Gate 2 NULL-exact convention shared with the SQL + // owner filters (`owner_workspace_id IS @workspace_id` in the repair queue + // and policies repos): normalize both sides with ?? null so NULL matches + // only NULL. Skipped groups resolve unresolved and keep the existing + // unresolved audit counting. + for (const m of members) { + if (m.episodeId !== episodeId) return unresolved("member_outside_episode"); + } + const episodeWorkspace = input.episodeOwnerWorkspaceId ?? null; + if ( + input.episodeOwnerAgentKind || + input.episodeOwnerProfileId || + input.episodeOwnerWorkspaceId !== undefined + ) { + for (const m of members) { + const mKind = m.ownerAgentKind ?? "unknown"; + const mProfile = m.ownerProfileId ?? "default"; + const mWorkspace = m.ownerWorkspaceId ?? null; + if ( + (input.episodeOwnerAgentKind && mKind !== input.episodeOwnerAgentKind) || + (input.episodeOwnerProfileId && mProfile !== input.episodeOwnerProfileId) || + (input.episodeOwnerWorkspaceId !== undefined && mWorkspace !== episodeWorkspace) + ) { + return unresolved("mixed_ownership"); + } + } + } + + // 1. Finite V and r_human within [-1, 1], reward consistency within 1e-9. + for (const m of members) { + if (!Number.isFinite(m.value) || Math.abs(m.value) > 1) return unresolved("invalid_value"); + if (m.rHuman == null || !Number.isFinite(m.rHuman) || Math.abs(m.rHuman) > 1) { + return unresolved("invalid_r_human"); + } + } + let reward = members[0]!.rHuman as number; + for (const m of members) { + if (Math.abs((m.rHuman as number) - reward) > REWARD_CONSISTENCY_TOLERANCE) { + return unresolved("mixed_reward"); + } + } + + // 2. Sign check: nonzero V must share R's sign; R = 0 requires all V = 0. + if (reward === 0) { + if (members.some((m) => m.value !== 0)) return unresolved("sign_mismatch"); + } else { + for (const m of members) { + if (m.value !== 0 && Math.sign(m.value) !== Math.sign(reward)) { + return unresolved("sign_mismatch"); + } + } + } + + // 3. meta.reward.traceIds exact-set cross-check when present. Only a real + // array of string IDs may take part: a scalar/object/array-of-non-strings + // means the group stays unresolved — it must never produce a numeric gain + // and must never pass the exact-set comparison. + if (input.metaRewardTraceIds != null) { + if ( + !Array.isArray(input.metaRewardTraceIds) || + !(input.metaRewardTraceIds as unknown[]).every((id) => typeof id === "string") + ) { + return unresolved("trace_ids_mismatch"); + } + const metaSet = new Set(input.metaRewardTraceIds as string[]); + if (metaSet.size !== S.length || !S.every((id) => metaSet.has(id))) { + return unresolved("trace_ids_mismatch"); + } + } + + const sumV = members.reduce((acc, m) => acc + m.value, 0); + const nonzeroCount = members.filter((m) => m.value !== 0).length; + + // 4./5. Conservation decides provenance; otherwise legacy_unscaled. + const tolerance = Math.max(CONSERVATION_ABS_TOLERANCE, Math.abs(reward) * CONSERVATION_REL_TOLERANCE); + const conserving = Math.abs(sumV - reward) <= tolerance; + + const gainByTraceId = new Map(); + if (conserving) { + const scaled = contributionGainValues(members.map((m) => m.value)); + members.forEach((m, i) => gainByTraceId.set(m.id, scaled[i]!)); + return { + status: "inferred_normalized", + reason: "conserving", + gainByTraceId, + nonzeroCount, + sumV, + reward, + }; + } + for (const m of members) gainByTraceId.set(m.id, m.value); + return { + status: "legacy_unscaled", + reason: "non_conserving_with_integrity", + gainByTraceId, + nonzeroCount, + sumV, + reward, + }; +} + +// ─── Storage pass ──────────────────────────────────────────────────────────── + +export interface GainInferenceDeps { + db: StorageDb; + kv: ReturnType; + episodesRepo: ReturnType; + tracesRepo: ReturnType; + owner: { ownerAgentKind: string; ownerProfileId: string; ownerWorkspaceId?: string | null }; + inferenceVersion?: number; + pageSize?: number; + /** + * Max episode groups screened per call. Unbounded when omitted; the + * bootstrap passes `GAIN_INFERENCE_BOOT_MAX_GROUPS`. Partial progress is + * durable (attempt stamps) so the next boot resumes where this one stopped. + */ + maxGroups?: number; + /** + * Wall-clock budget (ms) per call, measured with `now`. Unbounded when + * omitted; the bootstrap passes `GAIN_INFERENCE_BOOT_TIME_BUDGET_MS`. + */ + timeBudgetMs?: number; + /** Clock for the wall-clock budget (injectable for tests). */ + now?: () => number; +} + +export interface GainInferenceCounts { + groups: number; + traces: number; +} + +export interface GainInferenceReport { + /** Episodes screened this call (capped by maxGroups/timeBudgetMs when set). */ + candidateGroups: number; + /** + * True when the call stopped early on maxGroups/timeBudgetMs with + * unscreened backlog remaining. The next boot resumes durably via the + * attempt stamps — no new state needed. + */ + truncated: boolean; + /** Trace rows stamped this run (any outcome). */ + stampedTraces: number; + inferredNormalized: GainInferenceCounts; + legacyUnscaled: GainInferenceCounts; + unresolved: GainInferenceCounts; + /** legacy_unscaled groups whose newest member is on/after the cutover. */ + postCutoverLegacy: GainInferenceCounts; + /** legacy_unscaled groups with unknown member chronology (ts ≤ 0 / non-finite). */ + unknownChronology: GainInferenceCounts; + /** Groups inferred without meta.reward.traceIds (audit only). */ + auditMetaAbsent: number; + /** Traces sharing episode_id but not listed in S, left unresolved. */ + orphansOutsideS: number; + /** Episodes whose trace_ids_json is invalid JSON (skipped, reported). */ + invalidJsonGroups: number; + /** All trace ids stamped this run (input to queue seeding). */ + affectedTraceIds: string[]; +} + +export function runGainInference(deps: GainInferenceDeps): GainInferenceReport { + const version = deps.inferenceVersion ?? GAIN_INFERENCE_VERSION; + const pageSize = Math.max(1, Math.min(deps.pageSize ?? 500, 5000)); + const owner = deps.owner; + + const report: GainInferenceReport = { + candidateGroups: 0, + truncated: false, + stampedTraces: 0, + inferredNormalized: { groups: 0, traces: 0 }, + legacyUnscaled: { groups: 0, traces: 0 }, + unresolved: { groups: 0, traces: 0 }, + postCutoverLegacy: { groups: 0, traces: 0 }, + unknownChronology: { groups: 0, traces: 0 }, + auditMetaAbsent: 0, + orphansOutsideS: 0, + invalidJsonGroups: 0, + affectedTraceIds: [], + }; + + const invalidJson = deps.db + .prepare( + `SELECT COUNT(*) AS n FROM episodes WHERE json_valid(trace_ids_json) = 0`, + ) + .get()!; + report.invalidJsonGroups = invalidJson.n; + if (invalidJson.n > 0) { + log.warn("gain_inference.invalid_trace_ids_json", { episodes: invalidJson.n }); + } + + const selectRawTraceIds = deps.db.prepare<{ id: string }, { trace_ids_json: string }>( + `SELECT trace_ids_json FROM episodes WHERE id=@id`, + ); + // Guarded orphan count: json_each only ever sees a valid array (CASE), and + // NOT EXISTS (not NOT IN) so a NULL array element can never mask an orphan. + // IS_ARRAY(X) explicitly classifies JSON shape via a nested CASE: json_type() + // is evaluated ONLY inside `CASE WHEN json_valid(X) = 1 THEN ...`, so it can + // never see malformed input (bare json_type throws on malformed JSON — probed + // 2026-09-14). This matters because json_array_length() returns 0 — not NULL + // — for valid non-array JSON, so a length-based classifier would wrongly send + // object/scalar episodes down the array branch. + const IS_ARRAY = (x: string) => + `CASE WHEN json_valid(${x}) = 1 THEN (CASE WHEN json_type(${x}) = 'array' THEN 1 ELSE 0 END) ELSE 0 END`; + const countOrphans = deps.db.prepare<{ episode_id: string; raw: string }, { n: number }>( + `SELECT COUNT(*) AS n + FROM traces t + WHERE t.episode_id = @episode_id + AND ${IS_ARRAY("@raw")} = 1 + AND NOT EXISTS ( + SELECT 1 + FROM json_each(CASE WHEN ${IS_ARRAY("@raw")} = 1 THEN @raw ELSE '[]' END) je + WHERE je.value = t.id + )`, + ); + // Candidate selection is guaranteed throw-safe: json_each and json_type are + // only ever fed values guarded by the nested CASE idiom, so one malformed + // row cannot abort the startup pass. Invalid JSON (json_valid = 0) AND every + // valid non-array JSON (object/scalar — IS_ARRAY = 0) are routed through the + // episode-member branch regardless of their JSON values, so their member + // traces are stamped unresolved instead of being silently skipped (no + // restart re-scan loops). + const selectCandidates = deps.db.prepare< + { + version: number; + kind: string; + profile: string; + workspace_id: string | null; + after_id: string; + page_size: number; + }, + { id: string } + >( + `SELECT DISTINCT e.id AS id + FROM episodes e + WHERE (e.owner_agent_kind = @kind AND e.owner_profile_id = @profile + OR e.owner_agent_kind = 'unknown') + -- Gate 2 workspace-exactness, same convention as the repair queue + -- and policies owner filters: IS is NULL-safe, so a NULL-workspace + -- tick matches only NULL-workspace episodes (never a wildcard). + AND e.owner_workspace_id IS @workspace_id + AND e.id > @after_id + AND ( + (${IS_ARRAY("e.trace_ids_json")} = 1 + AND EXISTS ( + SELECT 1 + FROM json_each(CASE WHEN ${IS_ARRAY("e.trace_ids_json")} = 1 + THEN e.trace_ids_json ELSE '[]' END) je + JOIN traces t ON t.id = je.value + WHERE t.gain_inference_version < @version + AND (t.gain_value_source IS NULL + OR t.gain_value_source IN ('inferred_normalized','legacy_unscaled')) + )) + OR + (${IS_ARRAY("e.trace_ids_json")} = 0 + AND EXISTS ( + SELECT 1 FROM traces t2 + WHERE t2.episode_id = e.id + AND t2.gain_inference_version < @version + AND (t2.gain_value_source IS NULL + OR t2.gain_value_source IN ('inferred_normalized','legacy_unscaled')) + )) + ) + ORDER BY e.id + LIMIT @page_size`, + ); + + let afterId = ""; + // Per-boot bound (see GAIN_INFERENCE_BOOT_*): stop selecting new groups + // once the group cap or the wall-clock budget is reached. The pass stays + // fully synchronous in init order (no deferral — spec requires + // inference to complete before repair scheduling/consumers run); partial + // progress is durable via attempt stamps so the next boot resumes. + // NOT gated behind gainV2Enabled: rollout step 1 requires inference + // verified while the flag is false, and unresolved rows stay NULL (never + // zero), so partial progress degrades safe. + const maxGroups = + deps.maxGroups === undefined ? Number.POSITIVE_INFINITY : Math.max(1, Math.floor(deps.maxGroups)); + const nowFn = deps.now ?? Date.now; + const deadline = + deps.timeBudgetMs === undefined ? undefined : nowFn() + Math.max(0, deps.timeBudgetMs); + let processedGroups = 0; + let stoppedEarly = false; + for (;;) { + const candidates = selectCandidates.all({ + version, + kind: owner.ownerAgentKind, + profile: owner.ownerProfileId, + workspace_id: owner.ownerWorkspaceId ?? null, + after_id: afterId, + page_size: pageSize, + }); + if (candidates.length === 0) break; + + for (const { id } of candidates) { + if (processedGroups >= maxGroups || (deadline !== undefined && nowFn() >= deadline)) { + stoppedEarly = true; + break; + } + processedGroups += 1; + report.candidateGroups += 1; + const ep = deps.episodesRepo.getById(id as EpisodeId); + if (!ep) continue; + const raw = selectRawTraceIds.get({ id })?.trace_ids_json ?? "[]"; + + // S must be an array of string IDs. Any scalar/object/wrong-shaped + // value (or invalid JSON) has no valid S: the group is unresolved and + // the existing episode-member traces get an attempt stamp so a restart + // never re-scans them. + let parsed: unknown = null; + try { + parsed = JSON.parse(raw); + } catch { + parsed = null; // invalid JSON — no throw, handled as wrong-shaped + } + const isStringIdArray = + Array.isArray(parsed) && (parsed as unknown[]).every((x) => typeof x === "string"); + const idList = isStringIdArray ? (parsed as string[]) : null; + + // A valid but EMPTY array is a real (empty) reward pass: nothing to + // stamp and nothing to screen. + if (idList !== null && idList.length === 0) continue; + + if (idList === null) { + // Wrong-shaped S: stamp existing episode members unresolved in short + // bounded pages. No gain, no source — attempt version only. Any stamp + // durably invalidates the queue-seed watermark IN THE SAME transaction + // so same-version newly stamped work is never skipped by reconcile. + let stamped = 0; + let afterMemberId = ""; + for (;;) { + // One malformed huge episode can page through tens of thousands of + // members; honor the deadline between pages so a single group cannot + // overrun the whole boot budget. Partial pages are resumable — the + // members already stamped carry the attempt version. + if (deadline !== undefined && nowFn() >= deadline) { + stoppedEarly = true; + break; + } + const members = deps.tracesRepo.listGainRowsForEpisode(id, { + limit: 2000, + afterId: afterMemberId, + }); + if (members.length === 0) break; + deps.db.tx(() => { + let pageStamped = 0; + for (const tr of members) { + if (tr.gainValueSource === "live_normalized") continue; + if (tr.gainInferenceVersion >= version) continue; + const res = deps.tracesRepo.stampGain(tr.id, { + gainValue: null, + source: null, + inferenceVersion: version, + }); + if (res.changes > 0) { + pageStamped += 1; + report.affectedTraceIds.push(tr.id); + } + } + if (pageStamped > 0) deps.kv.del(GAIN_REPAIR_QUEUE_SEED_KEY); + stamped += pageStamped; + }); + afterMemberId = members[members.length - 1]!.id; + } + report.unresolved.groups += 1; + report.unresolved.traces += stamped; + report.stampedTraces += stamped; + continue; + } + + // Valid array S: bounded narrow member read (chunked, no text/vector + // payloads), then per-group screening. + const S = Array.from(new Set(idList)); + const fetched = deps.tracesRepo.getGainRowsByIds(S); + const byId = new Map(fetched.map((t) => [String(t.id), t])); + + let orphans = 0; + try { + orphans = countOrphans.get({ episode_id: id, raw })?.n ?? 0; + } catch { + orphans = 0; // defensive — the query is guarded; never abort the pass + } + report.orphansOutsideS += orphans; + + const meta = (ep as unknown as { meta?: Record }).meta ?? {}; + const metaReward = meta.reward as { traceIds?: unknown } | undefined; + const outcome = screenGainGroup({ + episodeId: id, + traceIds: S, + members: S.map((tid) => { + const tr = byId.get(String(tid)); + return { + id: String(tid), + episodeId: tr ? String(tr.episodeId) : id, + value: tr?.value ?? Number.NaN, + rHuman: tr?.rHuman ?? null, + ts: tr?.ts ?? 0, + ownerAgentKind: tr?.ownerAgentKind ?? "unknown", + ownerProfileId: tr?.ownerProfileId ?? "default", + ownerWorkspaceId: tr?.ownerWorkspaceId ?? null, + }; + }), + metaRewardTraceIds: metaReward && metaReward.traceIds != null ? metaReward.traceIds : null, + episodeOwnerAgentKind: ep.ownerAgentKind, + episodeOwnerProfileId: ep.ownerProfileId, + episodeOwnerWorkspaceId: ep.ownerWorkspaceId, + }); + + // Every group that receives a numeric gain counts toward + // `auditMetaAbsent` when no reward metadata is available — including + // legacy groups, which carry no meta by construction. Only genuinely + // unresolved groups (no gain at all) are exempt. + if (outcome.status !== "unresolved") { + if (metaReward == null || metaReward.traceIds == null) report.auditMetaAbsent += 1; + } + + // Stamp the group in a short transaction. Only members that actually + // need screening are stamped; live_normalized is never overwritten. + // Any stamp durably invalidates the queue-seed watermark IN THE SAME + // transaction, so same-version newly stamped work (e.g. groups added + // after an earlier seed) is re-derived by the next reconcile even after + // a stamp-then-crash-before-reconcile. + let stampedInGroup = 0; + deps.db.tx(() => { + for (const tid of S) { + const tr = byId.get(String(tid)); + if (!tr) continue; // ghost member — nothing to stamp + if (tr.gainValueSource === "live_normalized") continue; + if (tr.gainInferenceVersion >= version) continue; + const gain = outcome.gainByTraceId.get(String(tid)); + const res = deps.tracesRepo.stampGain(String(tid) as TraceId, { + gainValue: outcome.status === "unresolved" ? null : (gain ?? null), + source: outcome.status === "unresolved" ? null : outcome.status, + inferenceVersion: version, + }); + if (res.changes > 0) { + stampedInGroup += 1; + report.affectedTraceIds.push(String(tid)); + } + } + if (stampedInGroup > 0) deps.kv.del(GAIN_REPAIR_QUEUE_SEED_KEY); + }); + report.stampedTraces += stampedInGroup; + + const counts = + outcome.status === "inferred_normalized" + ? report.inferredNormalized + : outcome.status === "legacy_unscaled" + ? report.legacyUnscaled + : report.unresolved; + counts.groups += 1; + counts.traces += stampedInGroup; + + if (outcome.status === "legacy_unscaled") { + let newestTs = 0; + let unknown = false; + for (const tid of S) { + const tr = byId.get(String(tid)); + const ts = tr?.ts ?? 0; + if (!Number.isFinite(ts) || ts <= 0) unknown = true; + if (ts > newestTs) newestTs = ts; + } + if (unknown) { + report.unknownChronology.groups += 1; + report.unknownChronology.traces += stampedInGroup; + } else if (newestTs >= GAIN_POST_CUTOVER_BOUNDARY_MS) { + report.postCutoverLegacy.groups += 1; + report.postCutoverLegacy.traces += stampedInGroup; + } + } + } + if (stoppedEarly) break; + afterId = candidates[candidates.length - 1]!.id; + } + report.truncated = stoppedEarly; + + log.info("gain_inference.done", { + version, + ...report, + }); + return report; +} + +// ─── Queue seeding / reconciliation (startup, durable) ─────────────────────── + +/** + * kv watermark key recording the inference version for which queue seeding + * already ran for a given owner. When the watermark is behind the current + * inference version (or absent — e.g. a crash between trace commits and + * seeding), reconciliation re-derives the seed set from stored state. + */ +export const GAIN_REPAIR_QUEUE_SEED_KEY = "pipeline.gain_repair_queue_seed.v1"; + +export interface GainRepairQueueReconcileDeps { + db: StorageDb; + kv: ReturnType; + gainRepair: ReturnType; + tracesRepo: ReturnType; + owner: { ownerAgentKind: string; ownerProfileId: string; ownerWorkspaceId?: string | null }; + reason?: import("../storage/repos/gain-repair.js").GainRepairQueueReason; + inferenceVersion?: number; + now?: () => number; +} + +export interface GainRepairQueueReconcileResult { + seeded: number; + reconciled: number; + alreadySeeded: boolean; +} + +/** + * Seed/reconcile the candidate/active repair queue after historical + * screening. Never writes policy fields. Archived or missing policies are + * reconciled away; affected candidate/active policies get a pending entry so + * later phases (timer repair) can recompute their gain. + * + * Durable by construction: the seed set is derived from STORED state — every + * trace stamped at the current inference version → affected policies via the + * current evidence source (`policies.source_trace_ids_json`) — never from + * in-memory ids collected this startup. A crash between trace commits and + * queue seeding leaves the watermark behind, so the next restart recomputes + * the seed set from the database and no work is permanently omitted. The + * whole reconcile (reconciliation + seeding + watermark) commits in one + * transaction; a seeding failure rolls back and is retried on the next boot. + */ +export function reconcileGainRepairQueue( + deps: GainRepairQueueReconcileDeps, +): GainRepairQueueReconcileResult { + const version = deps.inferenceVersion ?? GAIN_INFERENCE_VERSION; + const now = deps.now ?? Date.now; + + return deps.db.tx(() => { + const reconciled = deps.gainRepair.reconcileArchivedOrMissing(deps.owner); + const stored = deps.kv.get< + | { version: number; ownerAgentKind: string; ownerProfileId: string; ownerWorkspaceId?: string | null } + | null + >(GAIN_REPAIR_QUEUE_SEED_KEY, null); + // Exact-namespace watermark: the workspace is part of the owner identity, + // so two owners that share kind+profile but differ by workspace must not + // share the seed watermark (one would skip seeding for the other). + const alreadySeeded = + stored != null && + stored.version >= version && + stored.ownerAgentKind === deps.owner.ownerAgentKind && + stored.ownerProfileId === deps.owner.ownerProfileId && + (stored.ownerWorkspaceId ?? null) === (deps.owner.ownerWorkspaceId ?? null); + if (alreadySeeded) return { seeded: 0, reconciled, alreadySeeded: true }; + + // Derive the seed set from stored state: all member traces stamped at the + // current inference version → affected policies via source_trace_ids_json. + const stampedTraceIds = deps.tracesRepo.listTraceIdsStampedAt(version); + const policyIds = deps.gainRepair.findAffectedPolicyIds(new Set(stampedTraceIds), deps.owner); + for (const policyId of policyIds) { + deps.gainRepair.upsertPending({ + policyId, + ownerAgentKind: deps.owner.ownerAgentKind, + ownerProfileId: deps.owner.ownerProfileId, + ownerWorkspaceId: deps.owner.ownerWorkspaceId ?? null, + reason: deps.reason ?? "inferred_evidence_updated", + inferenceVersion: version, + now: now(), + }); + } + deps.kv.set(GAIN_REPAIR_QUEUE_SEED_KEY, { + version, + ownerAgentKind: deps.owner.ownerAgentKind, + ownerProfileId: deps.owner.ownerProfileId, + ownerWorkspaceId: deps.owner.ownerWorkspaceId ?? null, + seededAt: now(), + }); + return { seeded: policyIds.length, reconciled, alreadySeeded: false }; + }); +} diff --git a/apps/memos-local-plugin/core/reward/gain-value.ts b/apps/memos-local-plugin/core/reward/gain-value.ts new file mode 100644 index 000000000..cf625cafb --- /dev/null +++ b/apps/memos-local-plugin/core/reward/gain-value.ts @@ -0,0 +1,25 @@ +/** + * `gain-value.ts` — contribution-adjusted gain scores. + * + * The normalized backprop value V distributes the episode reward across + * contributors, so a long episode mechanically dilutes each V by 1/N. For + * L2 gain / induction we want the *contribution-adjusted* score: scale each + * V by the number of nonzero contributors and clamp to [-1, 1]. + * + * Zero-weight padding (α = 0 ⇒ V = 0) must not change the multiplier, so + * only nonzero values count toward N. + * + * This helper is pure: it never queries the database and never mutates its + * input. Live scoring calls it with the exact reward-pass set + * (`backprop(...).updates` values, i.e. `episode.traceIds`); historical + * conversion calls it only for `inferred_normalized` groups (legacy_unscaled + * copies V directly). + */ + +export function contributionGainValues(values: readonly number[]): number[] { + if (values.some((value) => !Number.isFinite(value) || Math.abs(value) > 1)) { + throw new RangeError("gain value input must be finite normalized credit"); + } + const contributors = values.filter((value) => value !== 0).length; + return values.map((value) => Math.max(-1, Math.min(1, value * contributors))); +} diff --git a/apps/memos-local-plugin/core/reward/reward.ts b/apps/memos-local-plugin/core/reward/reward.ts index 0da87e5cb..9f11aec52 100644 --- a/apps/memos-local-plugin/core/reward/reward.ts +++ b/apps/memos-local-plugin/core/reward/reward.ts @@ -21,10 +21,12 @@ import { ERROR_CODES, MemosError } from "../../agent-contract/errors.js"; import type { LlmClient } from "../llm/index.js"; import { rootLogger } from "../logger/index.js"; import type { EpisodeId, EpochMs, TraceRow } from "../types.js"; +import type { StorageDb } from "../storage/index.js"; import type { makeEpisodesRepo } from "../storage/repos/episodes.js"; import type { makeFeedbackRepo } from "../storage/repos/feedback.js"; import type { makeTracesRepo } from "../storage/repos/traces.js"; import { backprop } from "./backprop.js"; +import { contributionGainValues } from "./gain-value.js"; import { scoreHuman } from "./human-scorer.js"; import { buildTaskSummary } from "./task-summary.js"; import type { @@ -46,6 +48,12 @@ export interface RewardDeps { llm: LlmClient | null; bus: RewardEventBus; cfg: RewardConfig; + /** + * The storage database. When provided, the whole `updateScore` loop for an + * episode's traces commits in one transaction so a mid-loop SQL failure can + * never leave some of the episode's traces updated and the rest untouched. + */ + db?: StorageDb; evaluator?: { reflectionProvider?: string; reflectionModel?: string; @@ -232,16 +240,59 @@ export function createRewardRunner(deps: RewardDeps): RewardRunner { }); tMetrics.backprop = now() - tBackStart; + // contribution-adjusted gain over the EXACT live scored set + // (backprop updates for episode.traceIds, never all episode_id rows). + // Attached to the result so reward.updated subscribers see it, then + // persisted atomically with V below so the two can never drift. + let gainValues: number[] = []; + try { + gainValues = contributionGainValues(bp.updates.map((u) => u.value)); + for (let i = 0; i < bp.updates.length; i++) { + bp.updates[i]!.gainValue = gainValues[i]; + bp.updates[i]!.gainValueSource = "live_normalized"; + } + } catch (err) { + // V is always finite and within [-1, 1] by construction; treat a + // violation as a persist-stage warning rather than crashing the run. + warnings.push({ + stage: "persist.traces.gain", + message: "failed to compute contribution gain values", + detail: errDetail(err), + }); + } + // Step 4: persist. + // Live scores intentionally keep gain_inference_version = 0 (never + // screened by historical inference): updateScore writes V + gain but no + // stamp. Safe because the inference pass only selects + // NULL/inferred_normalized/legacy_unscaled sources and stampGain refuses + // live_normalized rows, so a version-0 live row is never revisited. const tPersistStart = now(); try { - for (const u of bp.updates) { - deps.tracesRepo.updateScore(u.traceId, { - value: u.value, - alpha: u.alpha, - rHuman: humanScore.rHuman, - priority: u.priority, - }); + // updateScore leaves gain_value/gain_value_source untouched when BOTH + // keys are omitted (undefined) — so a failed gain batch MUST omit them + // rather than writing explicit NULLs, or it would clobber pre-existing + // live_normalized provenance on every trace in the batch. V/alpha/ + // priority persist normally either way. + const gainOk = gainValues.length === bp.updates.length; + const writeScores = (): void => { + for (let i = 0; i < bp.updates.length; i++) { + const u = bp.updates[i]!; + deps.tracesRepo.updateScore(u.traceId, { + value: u.value, + alpha: u.alpha, + rHuman: humanScore.rHuman, + priority: u.priority, + ...(gainOk + ? { gainValue: gainValues[i]! as number, gainValueSource: "live_normalized" as const } + : {}), + }); + } + }; + if (deps.db) { + deps.db.tx(writeScores); + } else { + writeScores(); } } catch (err) { warnings.push({ diff --git a/apps/memos-local-plugin/core/reward/types.ts b/apps/memos-local-plugin/core/reward/types.ts index 0e545ef0e..2b3aa612f 100644 --- a/apps/memos-local-plugin/core/reward/types.ts +++ b/apps/memos-local-plugin/core/reward/types.ts @@ -13,6 +13,7 @@ import type { EpochMs, FeedbackId, FeedbackRow, + GainValueSource, SessionId, TraceId, TraceRow, @@ -150,6 +151,14 @@ export interface BackpropUpdate { alpha: number; /** priority ∝ max(V, 0) · decay(Δt). */ priority: number; + /** + * contribution-adjusted gain `clamp(N·V_t, -1, 1)` where N + * counts nonzero contributions in the reward-pass set. Computed by the + * reward runner over the exact live scored set; not produced by backprop. + */ + gainValue?: number; + /** `live_normalized` for every live pass. */ + gainValueSource?: GainValueSource; } export interface BackpropResult { diff --git a/apps/memos-local-plugin/core/storage/migrations/019-policy-gain-value.sql b/apps/memos-local-plugin/core/storage/migrations/019-policy-gain-value.sql new file mode 100644 index 000000000..dbf96cd87 --- /dev/null +++ b/apps/memos-local-plugin/core/storage/migrations/019-policy-gain-value.sql @@ -0,0 +1,109 @@ +-- separate policy gain from normalized reward credit. +-- +-- SCHEMA-ONLY migration. It adds columns and the repair queue/journal tables +-- and does NOTHING else: no data conversion, no queue seeding, no policy or +-- trace writes, no budget reset. Historical inference is an idempotent +-- TypeScript pass (core/reward/gain-inference.ts) that runs after migrations +-- and before consumers/timer start. +-- +-- Owner-column decision: the queue/journal tables declare their own +-- owner_agent_kind / owner_profile_id / owner_workspace_id columns inline +-- (matching migration 007's NS_TABLES convention) instead of being added to +-- the migrator's NS_TABLES list. NS_TABLES drives migration-007 backfill and +-- shared index creation for tables that predate namespacing; these tables are +-- new and namespaced from birth, so handling it inline here keeps the blast +-- radius contained. +-- +-- Post-write timestamp: the journal also records the policy `updated_at` +-- written by the completing repair attempt. +-- `policies.gainRollback` compares-and-swaps all five repair-written policy +-- fields (status, support, gain, gain_version, updated_at) against the +-- recorded post-write state before restoring anything. Without this column a +-- newer-timestamp write could not be detected. Rows carrying NULL here (never +-- recorded, or non-completing outcomes) are NOT rollback-eligible — a +-- rollback must prove ownership of the exact post-write state, never guess +-- it. + +-- traces: NULL gain_value means UNRESOLVED, not neutral zero. +ALTER TABLE traces + ADD COLUMN gain_value REAL; + +ALTER TABLE traces + ADD COLUMN gain_value_source TEXT + CHECK (gain_value_source IS NULL OR gain_value_source IN + ('live_normalized','inferred_normalized','legacy_unscaled')); + +-- 0 = never screened; stamped (1, 2, ...) on EVERY historical screening +-- attempt, including attempts that stay unresolved, so a restart never +-- rescans stamped groups. +ALTER TABLE traces + ADD COLUMN gain_inference_version INTEGER NOT NULL DEFAULT 0; + +-- policies: version 2 certifies the shared gain calculation; the column is +-- additive and default 1 (uncertified). Nothing here writes it. +ALTER TABLE policies + ADD COLUMN gain_version INTEGER NOT NULL DEFAULT 1; + +-- Repair queue keyed by policy ID. archived policies are never repair +-- targets; state is pending/blocked/claimed with reason + attempt metadata. +CREATE TABLE IF NOT EXISTS gain_repair_queue ( + policy_id TEXT PRIMARY KEY REFERENCES policies(id) ON DELETE CASCADE, + owner_agent_kind TEXT NOT NULL DEFAULT 'unknown', + owner_profile_id TEXT NOT NULL DEFAULT 'default', + owner_workspace_id TEXT, + state TEXT NOT NULL DEFAULT 'pending' + CHECK (state IN ('pending','blocked','claimed')), + reason TEXT, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_attempt_at INTEGER, + last_attempt_batch_id TEXT, + inference_version INTEGER NOT NULL DEFAULT 1, + blocked_reason TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_gain_repair_queue_owner_state + ON gain_repair_queue(owner_agent_kind, owner_profile_id, state, updated_at); + +-- Repair journal: batch/attempt ID, namespace, old/new policy fields, +-- config/algorithm versions, provenance/exclusion counts, timestamp, result. +CREATE TABLE IF NOT EXISTS gain_repair_journal ( + id TEXT PRIMARY KEY, + batch_id TEXT NOT NULL, + owner_agent_kind TEXT NOT NULL DEFAULT 'unknown', + owner_profile_id TEXT NOT NULL DEFAULT 'default', + owner_workspace_id TEXT, + policy_id TEXT REFERENCES policies(id) ON DELETE SET NULL, + old_gain REAL, + new_gain REAL, + old_gain_version INTEGER, + new_gain_version INTEGER, + old_status TEXT, + new_status TEXT, + old_support INTEGER, + new_support INTEGER, + algorithm_version TEXT, + config_version TEXT, + inference_version INTEGER NOT NULL DEFAULT 1, + provenance_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(provenance_json)), + excluded_with_count INTEGER NOT NULL DEFAULT 0, + excluded_without_count INTEGER NOT NULL DEFAULT 0, + result TEXT NOT NULL DEFAULT 'pending' + CHECK (result IN + ('pending','completed','blocked','conflicted','failed','rolled_back')), + created_at INTEGER NOT NULL +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_gain_repair_journal_owner_ts + ON gain_repair_journal(owner_agent_kind, owner_profile_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_gain_repair_journal_batch + ON gain_repair_journal(batch_id); + +-- Post-write timestamp used by rollback CAS; nullable for non-completing +-- outcomes. Records the post-write policy `updated_at` for the five-field CAS +-- rollback. NULLABLE so rows that never record it (non-completing outcomes) +-- stay NOT rollback-eligible. +ALTER TABLE gain_repair_journal + ADD COLUMN new_updated_at INTEGER; diff --git a/apps/memos-local-plugin/core/storage/migrator.ts b/apps/memos-local-plugin/core/storage/migrator.ts index b858f7aa3..412716dbd 100644 --- a/apps/memos-local-plugin/core/storage/migrator.ts +++ b/apps/memos-local-plugin/core/storage/migrator.ts @@ -210,6 +210,15 @@ function applyMigration(db: StorageDb, file: MigrationFile): void { } return; } + if (file.version === 19 && file.name === "policy-gain-value") { + // Same guard as 012/018: a partial schema can lack `traces` or `policies`, where + // the added columns are meaningless. This file also creates the repair + // queue/journal tables, so it is skipped whole rather than half-applied. + if (tableExists(db, "traces") && tableExists(db, "policies")) { + db.exec(fs.readFileSync(file.fullPath, "utf8")); + } + return; + } db.exec(fs.readFileSync(file.fullPath, "utf8")); } diff --git a/apps/memos-local-plugin/core/storage/repos/_helpers.ts b/apps/memos-local-plugin/core/storage/repos/_helpers.ts index 6286074cd..9bdbd5dc9 100644 --- a/apps/memos-local-plugin/core/storage/repos/_helpers.ts +++ b/apps/memos-local-plugin/core/storage/repos/_helpers.ts @@ -127,6 +127,40 @@ export function defaultOwnerFields(ns?: RuntimeNamespace | null): { }; } +/** Owner triple identifying one exact namespace (workspace NULL-exact). */ +export interface OwnerTriple { + ownerAgentKind: string; + ownerProfileId: string; + ownerWorkspaceId?: string | null; +} + +export type OwnerishRow = { + ownerAgentKind?: string | null; + ownerProfileId?: string | null; + ownerWorkspaceId?: string | null; +}; + +/** + * Exact-namespace match (union reconcile + preview/rollback). + * Callers must NEVER use visibility rules here: a row owned by another + * namespace is invisible, not merely hidden. + * + * NOTE on `??` vs `ownerFieldsFromRaw`'s `||`: this predicate deliberately + * does NOT build on `ownerFieldsFromRaw`, which coerces empty strings to + * the fallbacks (`||`). Both pre-existing call sites used `??` (empty + * string stays an empty string and only matches an identically-owned + * namespace), so the shared version preserves that exact semantic. + * Workspace stays NULL-exact on both sides (`IS`-style: NULL matches only + * NULL, never a wildcard — Gate 2). + */ +export function isExactOwner(row: OwnerishRow, owner: OwnerTriple): boolean { + return ( + (row.ownerAgentKind ?? "unknown") === owner.ownerAgentKind && + (row.ownerProfileId ?? "default") === owner.ownerProfileId && + (row.ownerWorkspaceId ?? null) === (owner.ownerWorkspaceId ?? null) + ); +} + export function visibilityWhere(ns: RuntimeNamespace, alias = ""): { sql: string; params: Record; diff --git a/apps/memos-local-plugin/core/storage/repos/gain-repair.ts b/apps/memos-local-plugin/core/storage/repos/gain-repair.ts new file mode 100644 index 000000000..d079c00ff --- /dev/null +++ b/apps/memos-local-plugin/core/storage/repos/gain-repair.ts @@ -0,0 +1,741 @@ +/** + * `gain-repair.ts` — repair queue + journal repo. + * + * Schema surface: the schema exists via migration 19; this repo + * provides the queue read/reconcile operations used by the idempotent + * inference pass at startup (seed/reconcile candidate/active repair work) and + * a journal writer for later phases. Policy FIELD writes belong to the + * transaction layer, never to this repo. + * + * Queue is keyed by policy ID with pending/blocked/claimed state plus reason + * and attempt metadata. Archived policies are never repair targets; entries + * whose policy is archived or missing are reconciled away. + * + * Journal surface: journal reads (`getJournalById`, + * `listJournalByBatch`) plus the recorded post-write timestamp + * (`new_updated_at`, migration 19) and the `rolled_back` result marker back + * the `policies.gainRollback` CAS. Policy FIELD writes still belong to the + * transaction layer (the rollback commit), never to this repo. + */ + +import { now } from "../../time.js"; +import type { PolicyId } from "../../types.js"; +import type { StorageDb } from "../types.js"; +import { buildInsert } from "../tx.js"; +import { fromJsonText, ownerFieldsFromRaw, toJsonText } from "./_helpers.js"; + +export type GainRepairQueueState = "pending" | "blocked" | "claimed"; + +export type GainRepairQueueReason = + | "inferred_evidence_updated" + | "inference_refresh" + | "blocked_evidence" + | "manual"; + +export interface GainRepairQueueRow { + policyId: PolicyId; + ownerAgentKind: string; + ownerProfileId: string; + ownerWorkspaceId: string | null; + state: GainRepairQueueState; + reason: GainRepairQueueReason | null; + attemptCount: number; + lastAttemptAt: number | null; + lastAttemptBatchId: string | null; + inferenceVersion: number; + blockedReason: string | null; + createdAt: number; + updatedAt: number; +} + +export interface GainRepairJournalRow { + id: string; + batchId: string; + ownerAgentKind: string; + ownerProfileId: string; + ownerWorkspaceId: string | null; + policyId: PolicyId | null; + oldGain: number | null; + newGain: number | null; + oldGainVersion: number | null; + newGainVersion: number | null; + oldStatus: string | null; + newStatus: string | null; + oldSupport: number | null; + newSupport: number | null; + algorithmVersion: string | null; + configVersion: string | null; + inferenceVersion: number; + provenance: string[]; + excludedWithCount: number; + excludedWithoutCount: number; + result: "pending" | "completed" | "blocked" | "conflicted" | "failed" | "rolled_back"; + createdAt: number; + /** + * (migration 19) — the policy `updated_at` written by the + * completing repair attempt. NULL for rows that never recorded it and for + * non-completing outcomes; such rows are NOT rollback-eligible because the + * five-field CAS cannot prove the exact post-write state. + */ + newUpdatedAt: number | null; +} + +const QUEUE_COLUMNS = [ + "policy_id", + "owner_agent_kind", + "owner_profile_id", + "owner_workspace_id", + "state", + "reason", + "attempt_count", + "last_attempt_at", + "last_attempt_batch_id", + "inference_version", + "blocked_reason", + "created_at", + "updated_at", +]; + +const JOURNAL_COLUMNS = [ + "id", + "batch_id", + "owner_agent_kind", + "owner_profile_id", + "owner_workspace_id", + "policy_id", + "old_gain", + "new_gain", + "old_gain_version", + "new_gain_version", + "old_status", + "new_status", + "old_support", + "new_support", + "algorithm_version", + "config_version", + "inference_version", + "provenance_json", + "excluded_with_count", + "excluded_without_count", + "result", + "created_at", + "new_updated_at", +]; + +export interface GainRepairPendingTarget { + policyId: PolicyId; + reason: GainRepairQueueReason | null; + attemptCount: number; + inferenceVersion: number; + blockedReason: string | null; + policyStatus: "candidate" | "active"; + policyGainVersion: number; + policySupport: number; +} + +export interface GainRepairJournalOutcomePatch { + newGain?: number | null; + newGainVersion?: number | null; + newStatus?: string | null; + newSupport?: number | null; + /** Post-write policy `updated_at` — set only by the completing repair path. */ + newUpdatedAt?: number | null; + provenance?: string[]; + excludedWithCount?: number; + excludedWithoutCount?: number; + result: GainRepairJournalRow["result"]; +} + +export function makeGainRepairRepo(db: StorageDb) { + // Upsert semantics: the INSERT payload's zero/null attempt fields + // initialize NEW rows only. The ON CONFLICT clause deliberately omits + // attempt_count / last_attempt_at / last_attempt_batch_id, so re-seeding + // an already-queued policy PRESERVES its attempt metadata — a re-upsert + // never resets the attempt counter. + const upsertQueue = db.prepare( + `INSERT INTO gain_repair_queue (${QUEUE_COLUMNS.join(", ")}) + VALUES (${QUEUE_COLUMNS.map((c) => `@${c}`).join(", ")}) + ON CONFLICT(policy_id) DO UPDATE SET + state = excluded.state, + reason = excluded.reason, + inference_version = excluded.inference_version, + blocked_reason = excluded.blocked_reason, + updated_at = excluded.updated_at`, + ); + const insertJournal = db.prepare(buildInsert({ table: "gain_repair_journal", columns: JOURNAL_COLUMNS })); + const selectByPolicy = db.prepare<{ policy_id: string }, RawQueueRow>( + `SELECT ${QUEUE_COLUMNS.join(", ")} FROM gain_repair_queue WHERE policy_id=@policy_id`, + ); + const selectByOwnerAndState = db.prepare< + { kind: string; profile: string; workspace_id: string | null; state: string }, + RawQueueRow + >( + `SELECT ${QUEUE_COLUMNS.join(", ")} FROM gain_repair_queue + WHERE owner_agent_kind=@kind + AND owner_profile_id=@profile + AND owner_workspace_id IS @workspace_id + AND state=@state + ORDER BY policy_id`, + ); + // Candidate-first selection: pending entries whose policy is still a + // repair target (candidate/active, never archived), stable ID order. + const selectPendingForRepair = db.prepare< + { kind: string; profile: string; workspace_id: string | null; limit: number }, + RawQueueRow & { + policy_status: "candidate" | "active"; + policy_gain_version: number; + policy_support: number; + } + >( + `SELECT q.${QUEUE_COLUMNS.join(", q.")}, p.status AS policy_status, + p.gain_version AS policy_gain_version, p.support AS policy_support + FROM gain_repair_queue q + JOIN policies p ON p.id = q.policy_id + WHERE q.owner_agent_kind=@kind + AND q.owner_profile_id=@profile + AND q.owner_workspace_id IS @workspace_id + AND q.state='pending' + AND p.status IN ('candidate','active') + ORDER BY CASE WHEN p.status='candidate' THEN 0 ELSE 1 END, q.policy_id + LIMIT @limit`, + ); + const selectBlockedByOwner = db.prepare< + { kind: string; profile: string; workspace_id: string | null }, + RawQueueRow + >( + `SELECT ${QUEUE_COLUMNS.join(", ")} FROM gain_repair_queue + WHERE owner_agent_kind=@kind + AND owner_profile_id=@profile + AND owner_workspace_id IS @workspace_id + AND state='blocked' + ORDER BY policy_id`, + ); + const deleteByPolicy = db.prepare<{ policy_id: string }>( + `DELETE FROM gain_repair_queue WHERE policy_id=@policy_id`, + ); + const deleteByOwner = db.prepare<{ kind: string; profile: string; workspace_id: string | null }>( + `DELETE FROM gain_repair_queue + WHERE owner_agent_kind=@kind + AND owner_profile_id=@profile + AND owner_workspace_id IS @workspace_id`, + ); + const updateQueueState = db.prepare<{ + policy_id: string; + state: GainRepairQueueState; + attempt_count?: number | null; + last_attempt_at?: number | null; + last_attempt_batch_id?: string | null; + blocked_reason?: string | null; + updated_at: number; + }>( + `UPDATE gain_repair_queue + SET state=@state, + attempt_count=COALESCE(@attempt_count, attempt_count), + last_attempt_at=COALESCE(@last_attempt_at, last_attempt_at), + last_attempt_batch_id=COALESCE(@last_attempt_batch_id, last_attempt_batch_id), + blocked_reason=CASE WHEN @state='pending' THEN NULL + ELSE COALESCE(@blocked_reason, blocked_reason) END, + updated_at=@updated_at + WHERE policy_id=@policy_id`, + ); + const updateJournalOutcome = db.prepare<{ + id: string; + new_gain: number | null; + new_gain_version: number | null; + new_status: string | null; + new_support: number | null; + new_updated_at: number | null; + provenance_json: string; + excluded_with_count: number; + excluded_without_count: number; + result: GainRepairJournalRow["result"]; + }>( + `UPDATE gain_repair_journal + SET new_gain=@new_gain, + new_gain_version=@new_gain_version, + new_status=@new_status, + new_support=@new_support, + new_updated_at=@new_updated_at, + provenance_json=@provenance_json, + excluded_with_count=@excluded_with_count, + excluded_without_count=@excluded_without_count, + result=@result + WHERE id=@id`, + ); + const selectJournalById = db.prepare<{ id: string }, RawJournalRow>( + `SELECT ${JOURNAL_COLUMNS.join(", ")} FROM gain_repair_journal WHERE id=@id`, + ); + // deterministic policy-ID order so batch rollback reports and + // applies in a stable sequence. + const selectJournalByBatch = db.prepare<{ batch_id: string }, RawJournalRow>( + `SELECT ${JOURNAL_COLUMNS.join(", ")} FROM gain_repair_journal + WHERE batch_id=@batch_id + ORDER BY policy_id`, + ); + const setJournalResult = db.prepare<{ id: string; result: GainRepairJournalRow["result"] }>( + `UPDATE gain_repair_journal SET result=@result WHERE id=@id`, + ); + const markJournalInterrupted = db.prepare<{ policy_id: string; batch_id: string }>( + `UPDATE gain_repair_journal SET result='failed' + WHERE policy_id=@policy_id AND batch_id=@batch_id AND result='pending'`, + ); + + return { + upsertPending(row: { + policyId: PolicyId; + ownerAgentKind: string; + ownerProfileId: string; + ownerWorkspaceId?: string | null; + reason?: GainRepairQueueReason | null; + inferenceVersion?: number; + now?: number; + }): void { + const ts = row.now ?? now(); + upsertQueue.run({ + policy_id: row.policyId, + owner_agent_kind: row.ownerAgentKind, + owner_profile_id: row.ownerProfileId, + owner_workspace_id: row.ownerWorkspaceId ?? null, + state: "pending", + reason: row.reason ?? null, + attempt_count: 0, + last_attempt_at: null, + last_attempt_batch_id: null, + inference_version: row.inferenceVersion ?? 1, + blocked_reason: null, + created_at: ts, + updated_at: ts, + }); + }, + + /** + * Seed a policy directly as blocked (zero resolved with-links, + * no attempt, no budget). Attempt metadata is only initialized for NEW + * rows; an existing row keeps its counters (see the upsert clause above). + */ + upsertBlocked(row: { + policyId: PolicyId; + ownerAgentKind: string; + ownerProfileId: string; + ownerWorkspaceId?: string | null; + reason?: GainRepairQueueReason | null; + blockedReason?: string | null; + inferenceVersion?: number; + now?: number; + }): void { + const ts = row.now ?? now(); + upsertQueue.run({ + policy_id: row.policyId, + owner_agent_kind: row.ownerAgentKind, + owner_profile_id: row.ownerProfileId, + owner_workspace_id: row.ownerWorkspaceId ?? null, + state: "blocked", + reason: row.reason ?? null, + attempt_count: 0, + last_attempt_at: null, + last_attempt_batch_id: null, + inference_version: row.inferenceVersion ?? 1, + blocked_reason: row.blockedReason ?? null, + created_at: ts, + updated_at: ts, + }); + }, + + insertJournal(row: GainRepairJournalRow): void { + insertJournal.run({ + id: row.id, + batch_id: row.batchId, + owner_agent_kind: row.ownerAgentKind, + owner_profile_id: row.ownerProfileId, + owner_workspace_id: row.ownerWorkspaceId, + policy_id: row.policyId, + old_gain: row.oldGain, + new_gain: row.newGain, + old_gain_version: row.oldGainVersion, + new_gain_version: row.newGainVersion, + old_status: row.oldStatus, + new_status: row.newStatus, + old_support: row.oldSupport, + new_support: row.newSupport, + algorithm_version: row.algorithmVersion, + config_version: row.configVersion, + inference_version: row.inferenceVersion, + provenance_json: toJsonText(row.provenance), + excluded_with_count: row.excludedWithCount, + excluded_without_count: row.excludedWithoutCount, + result: row.result, + created_at: row.createdAt, + new_updated_at: row.newUpdatedAt ?? null, + }); + }, + + /** + * read one journal row by ID. The rollback layer enforces + * exact-namespace authorization on the returned owner columns; a row from + * another namespace must be treated as not-found, never surfaced. + */ + getJournalById(id: string): GainRepairJournalRow | null { + const r = selectJournalById.get({ id }); + return r ? mapJournalRow(r) : null; + }, + + /** + * every journal row of one attempt batch, stable policy-ID + * order. Callers filter to their exact namespace; foreign rows are + * invisible (the batch is "unknown" outside its namespace). + */ + listJournalByBatch(batchId: string): GainRepairJournalRow[] { + return selectJournalByBatch.all({ batch_id: batchId }).map(mapJournalRow); + }, + + /** + * mark a journal row `rolled_back` after a successful CAS + * rollback. Touches ONLY the result marker; the recorded old/new fields + * stay intact as the audit trail (a second rollback attempt sees + * `rolled_back` and is refused as not eligible). + */ + setJournalResult(id: string, result: GainRepairJournalRow["result"]): void { + setJournalResult.run({ id, result }); + }, + + getByPolicy(policyId: PolicyId): GainRepairQueueRow | null { + const r = selectByPolicy.get({ policy_id: String(policyId) }); + return r ? mapQueueRow(r) : null; + }, + + /** + * pending repair targets of an exact owner, candidate first then + * stable policy-ID order. Joins policies so archived/missing targets are + * never selected. + */ + listPendingForRepair( + owner: { ownerAgentKind: string; ownerProfileId: string; ownerWorkspaceId?: string | null }, + limit: number, + ): GainRepairPendingTarget[] { + const rows = selectPendingForRepair.all({ + kind: owner.ownerAgentKind, + profile: owner.ownerProfileId, + workspace_id: owner.ownerWorkspaceId ?? null, + limit: Math.max(0, Math.floor(limit)), + }); + return rows.map((r) => ({ + policyId: r.policy_id as PolicyId, + reason: r.reason, + attemptCount: r.attempt_count, + inferenceVersion: r.inference_version, + blockedReason: r.blocked_reason, + policyStatus: r.policy_status, + policyGainVersion: r.policy_gain_version, + policySupport: r.policy_support, + })); + }, + + listByOwnerAndState( + owner: { ownerAgentKind: string; ownerProfileId: string; ownerWorkspaceId?: string | null }, + state: GainRepairQueueState, + ): GainRepairQueueRow[] { + return selectByOwnerAndState + .all({ + kind: owner.ownerAgentKind, + profile: owner.ownerProfileId, + workspace_id: owner.ownerWorkspaceId ?? null, + state, + }) + .map(mapQueueRow); + }, + + listByOwnerAndStates( + owner: { ownerAgentKind: string; ownerProfileId: string; ownerWorkspaceId?: string | null }, + states: readonly GainRepairQueueState[], + ): GainRepairQueueRow[] { + if (states.length === 0) return []; + const placeholders = states.map((_, i) => `@state_${i}`).join(","); + const rows = db + .prepare<{ kind: string; profile: string; workspace_id: string | null; [k: string]: unknown }, RawQueueRow>( + `SELECT ${QUEUE_COLUMNS.join(", ")} FROM gain_repair_queue + WHERE owner_agent_kind=@kind + AND owner_profile_id=@profile + AND owner_workspace_id IS @workspace_id + AND state IN (${placeholders}) + ORDER BY policy_id`, + ) + .all({ + kind: owner.ownerAgentKind, + profile: owner.ownerProfileId, + workspace_id: owner.ownerWorkspaceId ?? null, + ...Object.fromEntries(states.map((s, i) => [`state_${i}`, s])), + }); + return rows.map(mapQueueRow); + }, + + listBlockedByOwner(owner: { + ownerAgentKind: string; + ownerProfileId: string; + ownerWorkspaceId?: string | null; + }): GainRepairQueueRow[] { + return selectBlockedByOwner + .all({ + kind: owner.ownerAgentKind, + profile: owner.ownerProfileId, + workspace_id: owner.ownerWorkspaceId ?? null, + }) + .map(mapQueueRow); + }, + + /** + * transition an entry to a new state while PRESERVING attempt + * metadata unless explicitly overridden. The timer engine owns these + * transitions (always inside a `db.tx` reservation/outcome commit). + */ + setQueueState( + policyId: PolicyId, + patch: { + state: GainRepairQueueState; + attemptCount?: number; + lastAttemptAt?: number | null; + lastAttemptBatchId?: string | null; + blockedReason?: string | null; + now?: number; + }, + ): void { + updateQueueState.run({ + policy_id: String(policyId), + state: patch.state, + attempt_count: patch.attemptCount ?? null, + last_attempt_at: patch.lastAttemptAt ?? null, + last_attempt_batch_id: patch.lastAttemptBatchId ?? null, + blocked_reason: patch.blockedReason ?? null, + updated_at: patch.now ?? now(), + }); + }, + + /** + * write the final journal outcome for one attempt. The row was + * inserted at reservation with result='pending' (the claim marker); this + * completes it with the post-attempt fields. `null` provenance is + * preserved as the stored default when omitted. + */ + updateJournalOutcome(id: string, patch: GainRepairJournalOutcomePatch): void { + updateJournalOutcome.run({ + id, + new_gain: patch.newGain ?? null, + new_gain_version: patch.newGainVersion ?? null, + new_status: patch.newStatus ?? null, + new_support: patch.newSupport ?? null, + new_updated_at: patch.newUpdatedAt ?? null, + provenance_json: toJsonText(patch.provenance ?? []), + excluded_with_count: patch.excludedWithCount ?? 0, + excluded_without_count: patch.excludedWithoutCount ?? 0, + result: patch.result, + }); + }, + + /** + * close an interrupted claim's journal ledger: any row still + * `pending` for this policy/batch is marked `failed` (the reservation + * consumed its budget unit but the per-policy transaction never + * committed). Runs inside the interrupted-claim reconcile transaction. + */ + markInterruptedJournal(policyId: PolicyId, batchId: string): void { + markJournalInterrupted.run({ policy_id: String(policyId), batch_id: batchId }); + }, + + removeByPolicy(policyId: PolicyId): void { + deleteByPolicy.run({ policy_id: String(policyId) }); + }, + + removeAllForOwner(owner: { ownerAgentKind: string; ownerProfileId: string; ownerWorkspaceId?: string | null }): void { + deleteByOwner.run({ + kind: owner.ownerAgentKind, + profile: owner.ownerProfileId, + workspace_id: owner.ownerWorkspaceId ?? null, + }); + }, + + /** + * Policy IDs (candidate/active, same owner) whose `source_trace_ids_json` + * intersects the affected trace set. Bounded bulk reads — chunked IN lists + * over json_each, never one query per trace. Archived policies are never + * repair targets and are excluded here. Policies with malformed or + * non-array source lists match nothing (guarded expansion) instead of + * aborting the query. + */ + findAffectedPolicyIds( + affectedTraceIds: ReadonlySet, + owner: { ownerAgentKind: string; ownerProfileId: string; ownerWorkspaceId?: string | null }, + ): PolicyId[] { + if (affectedTraceIds.size === 0) return []; + const ids = Array.from(affectedTraceIds); + const found = new Set(); + const CHUNK_SIZE = 900; + for (let i = 0; i < ids.length; i += CHUNK_SIZE) { + const chunk = ids.slice(i, i + CHUNK_SIZE); + const placeholders = chunk.map((_, j) => `@tid_${j}`).join(","); + const params: Record = { + kind: owner.ownerAgentKind, + profile: owner.ownerProfileId, + workspace_id: owner.ownerWorkspaceId ?? null, + }; + chunk.forEach((id, j) => { + params[`tid_${j}`] = id; + }); + // Guarded json_each: only a valid ARRAY feeds the expansion (nested + // CASE idiom shared with the episodes.trace_ids_json handling in + // gain-inference.ts). json_type() is evaluated ONLY inside + // `CASE WHEN json_valid(...) = 1`, because bare json_type throws on + // malformed JSON in this SQLite build (probed 2026-09-14). Malformed, + // scalar, object, or NULL source lists degrade to '[]', so one bad + // policy row is skipped instead of aborting queue reconciliation. + const sql = ` + SELECT DISTINCT p.id AS id + FROM policies p + JOIN json_each( + CASE WHEN json_valid(p.source_trace_ids_json) = 1 + THEN (CASE WHEN json_type(p.source_trace_ids_json) = 'array' + THEN p.source_trace_ids_json ELSE '[]' END) + ELSE '[]' END + ) AS je + WHERE je.value IN (${placeholders}) + AND p.status IN ('candidate','active') + AND p.owner_agent_kind = @kind + AND p.owner_profile_id = @profile + AND p.owner_workspace_id IS @workspace_id`; + const rows = db.prepare(sql).all(params); + for (const r of rows) found.add(r.id); + } + return Array.from(found).sort(); + }, + + /** + * Reconcile the queue against reality: drop entries whose policy is + * archived (not a repair target) or no longer exists. Called at startup + * after the inference pass, alongside seeding. + */ + reconcileArchivedOrMissing(owner: { + ownerAgentKind: string; + ownerProfileId: string; + ownerWorkspaceId?: string | null; + }): number { + const res = db + .prepare<{ kind: string; profile: string; workspace_id: string | null }>( + `DELETE FROM gain_repair_queue + WHERE owner_agent_kind = @kind + AND owner_profile_id = @profile + AND owner_workspace_id IS @workspace_id + AND (policy_id NOT IN (SELECT id FROM policies) + OR EXISTS (SELECT 1 FROM policies p WHERE p.id = gain_repair_queue.policy_id AND p.status = 'archived'))`, + ) + .run({ + kind: owner.ownerAgentKind, + profile: owner.ownerProfileId, + workspace_id: owner.ownerWorkspaceId ?? null, + }); + return Number(res.changes); + }, + + countByState( + owner: { ownerAgentKind: string; ownerProfileId: string; ownerWorkspaceId?: string | null }, + state: GainRepairQueueState, + ): number { + const r = db + .prepare<{ kind: string; profile: string; workspace_id: string | null; state: string }, { n: number }>( + `SELECT COUNT(*) AS n FROM gain_repair_queue + WHERE owner_agent_kind=@kind + AND owner_profile_id=@profile + AND owner_workspace_id IS @workspace_id + AND state=@state`, + ) + .get({ + kind: owner.ownerAgentKind, + profile: owner.ownerProfileId, + workspace_id: owner.ownerWorkspaceId ?? null, + state, + }); + return r?.n ?? 0; + }, + }; +} + +interface RawQueueRow { + policy_id: string; + owner_agent_kind: string; + owner_profile_id: string; + owner_workspace_id: string | null; + state: GainRepairQueueState; + reason: GainRepairQueueReason | null; + attempt_count: number; + last_attempt_at: number | null; + last_attempt_batch_id: string | null; + inference_version: number; + blocked_reason: string | null; + created_at: number; + updated_at: number; +} + +function mapQueueRow(r: RawQueueRow): GainRepairQueueRow { + return { + policyId: r.policy_id as PolicyId, + ...ownerFieldsFromRaw(r), + state: r.state, + reason: r.reason, + attemptCount: r.attempt_count, + lastAttemptAt: r.last_attempt_at, + lastAttemptBatchId: r.last_attempt_batch_id, + inferenceVersion: r.inference_version, + blockedReason: r.blocked_reason, + createdAt: r.created_at, + updatedAt: r.updated_at, + }; +} + +interface RawJournalRow { + id: string; + batch_id: string; + owner_agent_kind: string; + owner_profile_id: string; + owner_workspace_id: string | null; + policy_id: string | null; + old_gain: number | null; + new_gain: number | null; + old_gain_version: number | null; + new_gain_version: number | null; + old_status: string | null; + new_status: string | null; + old_support: number | null; + new_support: number | null; + algorithm_version: string | null; + config_version: string | null; + inference_version: number; + provenance_json: string; + excluded_with_count: number; + excluded_without_count: number; + result: GainRepairJournalRow["result"]; + created_at: number; + new_updated_at: number | null; +} + +function mapJournalRow(r: RawJournalRow): GainRepairJournalRow { + return { + id: r.id, + batchId: r.batch_id, + ...ownerFieldsFromRaw(r), + policyId: r.policy_id as PolicyId | null, + oldGain: r.old_gain, + newGain: r.new_gain, + oldGainVersion: r.old_gain_version, + newGainVersion: r.new_gain_version, + oldStatus: r.old_status, + newStatus: r.new_status, + oldSupport: r.old_support, + newSupport: r.new_support, + algorithmVersion: r.algorithm_version, + configVersion: r.config_version, + inferenceVersion: r.inference_version, + provenance: fromJsonText(r.provenance_json, []), + excludedWithCount: r.excluded_with_count, + excludedWithoutCount: r.excluded_without_count, + result: r.result, + createdAt: r.created_at, + newUpdatedAt: r.new_updated_at, + }; +} diff --git a/apps/memos-local-plugin/core/storage/repos/index.ts b/apps/memos-local-plugin/core/storage/repos/index.ts index 5f3038dab..8de09a31b 100644 --- a/apps/memos-local-plugin/core/storage/repos/index.ts +++ b/apps/memos-local-plugin/core/storage/repos/index.ts @@ -12,6 +12,7 @@ import { makeDecisionRepairsRepo } from "./decision_repairs.js"; import { makeEmbeddingRetryQueueRepo } from "./embedding_retry_queue.js"; import { makeEpisodesRepo } from "./episodes.js"; import { makeFeedbackRepo } from "./feedback.js"; +import { makeGainRepairRepo } from "./gain-repair.js"; import { makeHubRepo } from "./hub.js"; import { makeKvRepo } from "./kv.js"; import { makeMigrationsRepo } from "./migrations.js"; @@ -31,6 +32,7 @@ export interface Repos { embeddingRetryQueue: ReturnType; episodes: ReturnType; feedback: ReturnType; + gainRepair: ReturnType; hub: ReturnType; kv: ReturnType; migrations: ReturnType; @@ -53,6 +55,7 @@ export function makeRepos(db: StorageDb): Repos { embeddingRetryQueue: makeEmbeddingRetryQueueRepo(db), episodes: makeEpisodesRepo(db), feedback: makeFeedbackRepo(db), + gainRepair: makeGainRepairRepo(db), hub: makeHubRepo(db, kv), kv, migrations: makeMigrationsRepo(db), @@ -74,6 +77,7 @@ export { makeDecisionRepairsRepo } from "./decision_repairs.js"; export { makeEmbeddingRetryQueueRepo } from "./embedding_retry_queue.js"; export { makeEpisodesRepo } from "./episodes.js"; export { makeFeedbackRepo } from "./feedback.js"; +export { makeGainRepairRepo } from "./gain-repair.js"; export { makeHubRepo } from "./hub.js"; export { makeKvRepo } from "./kv.js"; export { makeMigrationsRepo } from "./migrations.js"; diff --git a/apps/memos-local-plugin/core/storage/repos/policies.ts b/apps/memos-local-plugin/core/storage/repos/policies.ts index 29920f60a..0ccbb8c31 100644 --- a/apps/memos-local-plugin/core/storage/repos/policies.ts +++ b/apps/memos-local-plugin/core/storage/repos/policies.ts @@ -27,6 +27,7 @@ const COLUMNS = [ "boundary", "support", "gain", + "gain_version", "status", "experience_type", "evidence_polarity", @@ -70,7 +71,7 @@ export function makePoliciesRepo(db: StorageDb) { const updateStats = db.prepare( buildUpdate({ table: "policies", - columns: ["id", "support", "gain", "status", "updated_at"], + columns: ["id", "support", "gain", "gain_version", "status", "updated_at"], }), ); const selectById = db.prepare<{ id: string }, RawPolicyRow>( @@ -91,6 +92,7 @@ export function makePoliciesRepo(db: StorageDb) { p: { support: number; gain: number; + gainVersion: number; status: PolicyRow["status"]; updatedAt: number; }, @@ -99,6 +101,7 @@ export function makePoliciesRepo(db: StorageDb) { id, support: p.support, gain: p.gain, + gain_version: p.gainVersion, status: p.status, updated_at: p.updatedAt, }); @@ -114,7 +117,13 @@ export function makePoliciesRepo(db: StorageDb) { const tr = timeRangeWhere(filter, "updated_at"); const fragments: string[] = []; const params: Record = { ...tr.params }; - if (filter.status) { + if (filter.statusIn && filter.statusIn.length > 0) { + const placeholders = filter.statusIn.map((_, i) => `@status_in_${i}`).join(","); + fragments.push(`status IN (${placeholders})`); + filter.statusIn.forEach((s, i) => { + params[`status_in_${i}`] = s; + }); + } else if (filter.status) { fragments.push(`status = @status`); params.status = filter.status; } @@ -122,6 +131,18 @@ export function makePoliciesRepo(db: StorageDb) { fragments.push(`support >= @min_support`); params.min_support = filter.minSupport; } + if (filter.ownerAgentKind !== undefined) { + fragments.push(`owner_agent_kind = @owner_agent_kind`); + params.owner_agent_kind = filter.ownerAgentKind; + } + if (filter.ownerProfileId !== undefined) { + fragments.push(`owner_profile_id = @owner_profile_id`); + params.owner_profile_id = filter.ownerProfileId; + } + if (filter.ownerWorkspaceId !== undefined) { + fragments.push(`owner_workspace_id IS @owner_workspace_id`); + params.owner_workspace_id = filter.ownerWorkspaceId ?? null; + } if (tr.sql) fragments.push(tr.sql); const where = joinWhere(fragments); const page = buildPageClauses(filter, "updated_at"); @@ -133,7 +154,13 @@ export function makePoliciesRepo(db: StorageDb) { const tr = timeRangeWhere(filter, "updated_at"); const fragments: string[] = []; const params: Record = { ...tr.params }; - if (filter.status) { + if (filter.statusIn && filter.statusIn.length > 0) { + const placeholders = filter.statusIn.map((_, i) => `@status_in_${i}`).join(","); + fragments.push(`status IN (${placeholders})`); + filter.statusIn.forEach((s, i) => { + params[`status_in_${i}`] = s; + }); + } else if (filter.status) { fragments.push(`status = @status`); params.status = filter.status; } @@ -141,6 +168,18 @@ export function makePoliciesRepo(db: StorageDb) { fragments.push(`support >= @min_support`); params.min_support = filter.minSupport; } + if (filter.ownerAgentKind !== undefined) { + fragments.push(`owner_agent_kind = @owner_agent_kind`); + params.owner_agent_kind = filter.ownerAgentKind; + } + if (filter.ownerProfileId !== undefined) { + fragments.push(`owner_profile_id = @owner_profile_id`); + params.owner_profile_id = filter.ownerProfileId; + } + if (filter.ownerWorkspaceId !== undefined) { + fragments.push(`owner_workspace_id IS @owner_workspace_id`); + params.owner_workspace_id = filter.ownerWorkspaceId ?? null; + } if (tr.sql) fragments.push(tr.sql); const where = joinWhere(fragments); const sql = `SELECT COUNT(*) AS n FROM policies ${where}`; @@ -401,6 +440,7 @@ interface RawPolicyRow { boundary: string; support: number; gain: number; + gain_version: number; status: "candidate" | "active" | "archived"; experience_type: NonNullable | null; evidence_polarity: NonNullable | null; @@ -454,6 +494,7 @@ function rowToParams(row: PolicyRow): Record { boundary: row.boundary, support: row.support, gain: row.gain, + gain_version: row.gainVersion ?? 1, status: row.status, experience_type: row.experienceType ?? "success_pattern", evidence_polarity: row.evidencePolarity ?? "positive", @@ -490,6 +531,7 @@ function mapRow(r: RawPolicyRow): PolicyRow { boundary: r.boundary, support: r.support, gain: r.gain, + gainVersion: r.gain_version, status: r.status, experienceType: normalizeExperienceType(r.experience_type), evidencePolarity: normalizeEvidencePolarity(r.evidence_polarity), diff --git a/apps/memos-local-plugin/core/storage/repos/traces.ts b/apps/memos-local-plugin/core/storage/repos/traces.ts index d8bcea5e3..aead8c6d5 100644 --- a/apps/memos-local-plugin/core/storage/repos/traces.ts +++ b/apps/memos-local-plugin/core/storage/repos/traces.ts @@ -1,7 +1,15 @@ import type { ToolCallDTO } from "../../../agent-contract/dto.js"; -import type { EmbeddingVector, EpisodeId, SessionId, ShareScope, TraceId, TraceRow } from "../../types.js"; +import type { + EmbeddingVector, + EpisodeId, + GainValueSource, + SessionId, + ShareScope, + TraceId, + TraceRow, +} from "../../types.js"; import type { StorageDb, TraceListFilter } from "../types.js"; -import { buildInClause, buildInsert, buildUpdate } from "../tx.js"; +import { buildInClause, buildInsert } from "../tx.js"; import { scanAndTopK, topKCosine, type VectorHit, type VectorRow } from "../vector.js"; import { buildPageClauses, @@ -44,6 +52,9 @@ const COLUMNS = [ "shared_at", "turn_id", "schema_version", + "gain_value", + "gain_value_source", + "gain_inference_version", ]; export type TraceSearchMeta = { @@ -92,23 +103,111 @@ const DEDUP_COLUMNS = [ "tool_calls_json", ] as const; +/** + * Narrow projection used by the gain-inference pass. Carries only the + * columns screening needs (id, value, r_human, episode/namespace linkage, + * timestamps, gain columns) — NEVER the text / `tool_calls_json` / + * `vec_summary` / `vec_action` payload columns. A 500k-trace episode reads + * kilobytes, not gigabytes. + */ +export interface TraceGainRow { + id: string; + episodeId: string; + ownerAgentKind: string; + ownerProfileId: string; + ownerWorkspaceId: string | null; + ts: number; + value: number; + rHuman: number | null; + gainValue: number | null; + gainValueSource: GainValueSource | null; + gainInferenceVersion: number; +} + +const GAIN_COLUMNS = [ + "id", + "episode_id", + "owner_agent_kind", + "owner_profile_id", + "owner_workspace_id", + "ts", + "value", + "r_human", + "gain_value", + "gain_value_source", + "gain_inference_version", +] as const; + +interface RawGainRow { + id: string; + episode_id: string; + owner_agent_kind: string; + owner_profile_id: string; + owner_workspace_id: string | null; + ts: number; + value: number; + r_human: number | null; + gain_value: number | null; + gain_value_source: GainValueSource | null; + gain_inference_version: number; +} + +function mapGainRow(r: RawGainRow): TraceGainRow { + return { + id: r.id, + episodeId: r.episode_id, + ownerAgentKind: r.owner_agent_kind, + ownerProfileId: r.owner_profile_id, + ownerWorkspaceId: r.owner_workspace_id, + ts: r.ts, + value: r.value, + rHuman: r.r_human, + gainValue: r.gain_value, + gainValueSource: r.gain_value_source, + gainInferenceVersion: r.gain_inference_version, + }; +} + +// The one provenance value that historical inference/stamping must never +// overwrite. Defined once so every guarded statement shares the same +// type-checked literal and its SQL-quoted form stays in sync. +const LIVE_GAIN_SOURCE: GainValueSource = "live_normalized"; +const SQL_LIVE_GAIN_SOURCE = `'${LIVE_GAIN_SOURCE}'`; + export function makeTracesRepo(db: StorageDb) { const insert = db.prepare(buildInsert({ table: "traces", columns: COLUMNS })); const upsert = db.prepare( buildInsert({ table: "traces", columns: COLUMNS, onConflict: "replace" }), ); - const updateScalars = db.prepare( - buildUpdate({ - table: "traces", - columns: ["id", "value", "alpha", "r_human", "priority"], - }), - ); const selectById = db.prepare<{ id: string }, RawTraceRow>( `SELECT ${COLUMNS.join(", ")} FROM traces WHERE id=@id`, ); const selectLatestTimestamp = db.prepare( `SELECT ts FROM traces ORDER BY ts DESC, id DESC LIMIT 1`, ); + // Two fixed update statements (base vs. with-gain): prepared once so the + // hot scoring loop never rebuilds SQL text or facades per trace. + const updateScoreBase = db.prepare<{ + id: string; + value: number; + alpha: number; + r_human: number | null; + priority: number; + }>( + `UPDATE traces SET value=@value, alpha=@alpha, r_human=@r_human, priority=@priority WHERE id=@id`, + ); + const updateScoreWithGain = db.prepare<{ + id: string; + value: number; + alpha: number; + r_human: number | null; + priority: number; + gain_value: number | null; + gain_value_source: string | null; + }>( + `UPDATE traces SET value=@value, alpha=@alpha, r_human=@r_human, priority=@priority, + gain_value=@gain_value, gain_value_source=@gain_value_source WHERE id=@id`, + ); return { insert(row: TraceRow): void { @@ -119,17 +218,89 @@ export function makeTracesRepo(db: StorageDb) { upsert.run(rowToParams(row)); }, + /** + * Update normalized reward credit (V / α / r_human / priority) for a + * trace. When `gainValue`/`gainValueSource` are supplied they are written + * in the SAME UPDATE statement as V — atomic live-score persistence + * There is no window where V is refreshed but gain provenance + * is stale. Omitted gain fields are left untouched so non-reward callers + * (e.g. explicit trace feedback) never silently clear provenance. + * + * Live scores intentionally keep `gain_inference_version` at 0: a live + * score is not a historical screening attempt, and stamping it would blur + * the restart watermark (0 doubles as "never screened", which is exactly + * what a live row is). Do NOT stamp here — a sentinel version could + * disturb stamp comparisons on the inference path. This stays safe + * because the inference pass never selects live rows: candidate + * selection only admits NULL/`inferred_normalized`/`legacy_unscaled` + * sources, per-row screening skips `live_normalized`, and `stampGain` + * refuses to overwrite it — so version 0 needs no source tag to keep + * live rows out of historical inference. + */ updateScore( id: TraceId, - scores: { value: number; alpha: number; rHuman?: number | null; priority: number }, + scores: { + value: number; + alpha: number; + rHuman?: number | null; + priority: number; + gainValue?: number | null; + gainValueSource?: GainValueSource | null; + }, ): void { - updateScalars.run({ + // The two gain keys are atomic: writing one without the other would + // clear the paired column. Reject the one-sided case up front. + const hasGain = scores.gainValue !== undefined || scores.gainValueSource !== undefined; + const hasBoth = scores.gainValue !== undefined && scores.gainValueSource !== undefined; + if (hasGain && !hasBoth) { + throw new Error( + "traces.updateScore: gainValue and gainValueSource must be provided together", + ); + } + const base = { id, value: scores.value, alpha: scores.alpha, r_human: nullable(scores.rHuman ?? null) as number | null, priority: scores.priority, - }); + }; + if (hasGain) { + updateScoreWithGain.run({ + ...base, + gain_value: scores.gainValue ?? null, + gain_value_source: scores.gainValueSource ?? null, + }); + } else { + updateScoreBase.run(base); + } + }, + + /** + * stamp a historical screening attempt on one trace. Writes + * gainValue/source and the inference version together, and refuses to + * overwrite `live_normalized` provenance (defense in depth — the + * inference candidate selection already excludes live rows). + */ + stampGain( + id: TraceId, + patch: { gainValue: number | null; source: GainValueSource | null; inferenceVersion: number }, + ): { changes: number } { + const res = db + .prepare<{ id: string; gain_value: number | null; gain_value_source: string | null; version: number }>( + `UPDATE traces + SET gain_value = @gain_value, + gain_value_source = @gain_value_source, + gain_inference_version = @version + WHERE id = @id + AND (gain_value_source IS NULL OR gain_value_source != ${SQL_LIVE_GAIN_SOURCE})`, + ) + .run({ + id, + gain_value: patch.gainValue, + gain_value_source: patch.source, + version: patch.inferenceVersion, + }); + return { changes: Number(res.changes) }; }, getById(id: TraceId): TraceRow | null { @@ -142,6 +313,26 @@ export function makeTracesRepo(db: StorageDb) { return selectLatestTimestamp.get()?.ts ?? null; }, + /** + * un-stamp one trace so the idempotent screening pass + * re-visits its episode group at the current inference version (config- + * generation re-screen of blocked inputs). Never touches `live_normalized` + * provenance: real live scores always supersede historical inference. + */ + unstampGainForRescreen(id: TraceId): { changes: number } { + const res = db + .prepare<{ id: string }>( + `UPDATE traces + SET gain_value = NULL, + gain_value_source = NULL, + gain_inference_version = 0 + WHERE id = @id + AND (gain_value_source IS NULL OR gain_value_source != ${SQL_LIVE_GAIN_SOURCE})`, + ) + .run({ id }); + return { changes: Number(res.changes) }; + }, + getManyByIds(ids: readonly TraceId[]): TraceRow[] { if (ids.length === 0) return []; const placeholders = buildInClause(ids.length); @@ -150,6 +341,65 @@ export function makeTracesRepo(db: StorageDb) { return rows.map(mapRow); }, + /** + * bounded narrow-projection member read for a reward-pass set. + * Chunked IN lists keep SQLite under its variable limit regardless of S's + * size, and only the screening columns are projected (no text/vector + * payloads). Preserves input order and de-duplicates. + */ + getGainRowsByIds(ids: readonly string[], opts: { chunkSize?: number } = {}): TraceGainRow[] { + if (ids.length === 0) return []; + const dedup = Array.from(new Set(ids)); + const CHUNK_SIZE = Math.max(1, Math.min(opts.chunkSize ?? 900, 900)); + const out: TraceGainRow[] = []; + for (let i = 0; i < dedup.length; i += CHUNK_SIZE) { + const chunk = dedup.slice(i, i + CHUNK_SIZE); + const placeholders = buildInClause(chunk.length); + const sql = `SELECT ${GAIN_COLUMNS.join(", ")} FROM traces WHERE id ${placeholders}`; + const rows = db.prepare(sql).all(chunk); + for (const r of rows) out.push(mapGainRow(r)); + } + return out; + }, + + /** + * narrow-projection, keyset-paged read of an episode's traces. + * Used only for malformed/wrong-shaped `trace_ids_json` groups where no + * valid S exists: the existing members are stamped unresolved so a + * restart never re-scans them. + */ + listGainRowsForEpisode( + episodeId: EpisodeId | string, + opts: { limit?: number; afterId?: string } = {}, + ): TraceGainRow[] { + const limit = Math.max(1, Math.min(opts.limit ?? 2000, 5000)); + const params: Record = { episode_id: String(episodeId), limit }; + let after = ""; + if (opts.afterId) { + after = "AND id > @after_id"; + params.after_id = opts.afterId; + } + const sql = `SELECT ${GAIN_COLUMNS.join( + ", ", + )} FROM traces WHERE episode_id = @episode_id ${after} ORDER BY id LIMIT @limit`; + return db.prepare(sql).all(params).map(mapGainRow); + }, + + /** + * every trace id stamped by a given inference version. Feeds the + * durable queue reconciliation: the seed set is derived from stored state + * (all stamped member traces → affected policies), so a crash between + * trace commits and queue seeding leaves no permanently omitted work. + */ + listTraceIdsStampedAt(version: number): string[] { + const rows = db + .prepare<{ version: number }, { id: string }>( + `SELECT id FROM traces WHERE gain_inference_version = @version AND gain_inference_version > 0`, + ) + .all({ version }); + return rows.map((r) => r.id); + }, + /** * Cheap existence check: does ANY trace in `ids` carry a timestamp * strictly greater than `ts`? @@ -896,6 +1146,9 @@ interface RawTraceRow { shared_at: number | null; turn_id: number; schema_version: number; + gain_value: number | null; + gain_value_source: GainValueSource | null; + gain_inference_version: number; } function normalizeSignatures(sigs: readonly string[] | undefined): string[] { @@ -947,6 +1200,9 @@ function rowToParams(row: TraceRow): Record { shared_at: row.share?.sharedAt ?? null, turn_id: row.turnId ?? null, schema_version: row.schemaVersion, + gain_value: row.gainValue ?? null, + gain_value_source: row.gainValueSource ?? null, + gain_inference_version: row.gainInferenceVersion ?? 0, }; } @@ -981,5 +1237,8 @@ function mapRow(r: RawTraceRow): TraceRow { : null, turnId: r.turn_id, schemaVersion: r.schema_version, + gainValue: r.gain_value, + gainValueSource: r.gain_value_source, + gainInferenceVersion: r.gain_inference_version, }; } diff --git a/apps/memos-local-plugin/core/storage/types.ts b/apps/memos-local-plugin/core/storage/types.ts index 3e02a0d14..453c9210b 100644 --- a/apps/memos-local-plugin/core/storage/types.ts +++ b/apps/memos-local-plugin/core/storage/types.ts @@ -97,8 +97,13 @@ export interface TraceListFilter extends PageOptions, TimeRange { export interface PolicyListFilter extends PageOptions, TimeRange { status?: "candidate" | "active" | "archived"; + /** Alternative to `status`: match any of the listed statuses in one query. */ + statusIn?: Array<"candidate" | "active" | "archived">; /** Minimum support count. */ minSupport?: number; + ownerAgentKind?: string; + ownerProfileId?: string; + ownerWorkspaceId?: string | null; } export interface SkillListFilter extends PageOptions { diff --git a/apps/memos-local-plugin/core/types.ts b/apps/memos-local-plugin/core/types.ts index 0e4f87f52..f17999d74 100644 --- a/apps/memos-local-plugin/core/types.ts +++ b/apps/memos-local-plugin/core/types.ts @@ -51,6 +51,23 @@ export interface OwnedRow { ownerWorkspaceId?: string | null; } +// ─── Gain scores ───────────────────────────────────────────────────────────── + +/** + * Provenance of a trace's `gainValue`. + * + * - `live_normalized`: written atomically with V by a real reward pass + * (`clamp(N·V, -1, 1)`); historical inference must NEVER overwrite it. + * - `inferred_normalized`: historical screening found a conserving group and + * applied contributor scaling. + * - `legacy_unscaled`: historical screening could not conserve reward but all + * integrity checks passed — gainValue equals the historical V as-is. + * + * `null` gain_value_source means unresolved (NULL gain_value is unresolved, + * NOT neutral zero). + */ +export type GainValueSource = "live_normalized" | "inferred_normalized" | "legacy_unscaled"; + // ─── Embeddings ────────────────────────────────────────────────────────────── export type EmbeddingVector = Float32Array; @@ -141,6 +158,20 @@ export interface TraceRow extends OwnedRow { turnId: EpochMs; /** Schema version that wrote this row (helps with migrations). */ schemaVersion: number; + /** + * contribution-adjusted gain (clamp(N·V, -1, 1)). NULL is + * unresolved, never neutral zero. Written atomically with `value` by live + * reward passes; historical inference stamps it with explicit provenance. + */ + gainValue?: number | null; + /** provenance of `gainValue` (see {@link GainValueSource}). */ + gainValueSource?: GainValueSource | null; + /** + * inference version of the last historical screening attempt. + * 0 = never screened; 1, 2, … stamped on EVERY attempt including unresolved + * ones so a restart never rescans stamped groups. Live scoring never stamps. + */ + gainInferenceVersion?: number; } export interface PolicyRow extends OwnedRow { @@ -153,6 +184,14 @@ export interface PolicyRow extends OwnedRow { support: number; gain: number; status: "candidate" | "active" | "archived"; + /** + * gain certification version. 2 certifies the SHARED gainValue + * calculation (clamp(N·V, -1, 1) provenance); 1 (the migration default) is + * uncertified. Only actual v2 calculations certify; feedback/salience, + * legacy-mode and import writes invalidate certification back to 1. Never + * blanket-defaulted to 2 on inserts/upserts. + */ + gainVersion?: number; /** * User-facing "experience" classification. The Policies tab is the storage * backing for the viewer's "经验" surface; these fields distinguish success diff --git a/apps/memos-local-plugin/templates/config.demo.yaml b/apps/memos-local-plugin/templates/config.demo.yaml index 7359f38bc..92e8435c7 100644 --- a/apps/memos-local-plugin/templates/config.demo.yaml +++ b/apps/memos-local-plugin/templates/config.demo.yaml @@ -46,6 +46,13 @@ algorithm: # Production default 0.01 (already low after 2026-04). Demo pushes # it lower so even the very first turn's traces feed induction. minTraceValue: 0.005 + # v2 gain scoring stays OFF in the demo (shipping default). + gainV2Enabled: false + minGainValue: 0.02 + gainRepairBatchSize: 0 # integer 0..25 attempts per tick; 0 pauses repair + gainRepairIntervalMs: 900000 # integer ms, 60000..86399999 (15 minutes) + gainRepairMaxTotal: null # null = unlimited; else nonnegative int ceiling + gainRepairRescreenGeneration: 0 # nonnegative int; +1 re-screens blocked evidence l3Abstraction: # Production default 0.6 — works for homogeneous corpora. The demo diff --git a/apps/memos-local-plugin/templates/config.hermes.yaml b/apps/memos-local-plugin/templates/config.hermes.yaml index 4c04daba3..b14920cfd 100644 --- a/apps/memos-local-plugin/templates/config.hermes.yaml +++ b/apps/memos-local-plugin/templates/config.hermes.yaml @@ -35,6 +35,17 @@ storage: algorithm: lightweightMemory: enabled: true # true = low-cost summaries only; false = memory self-evolution with tasks/experiences/world models/skills + l2Induction: + # v2 gain scoring is OFF by default. Flip gainV2Enabled on only + # after the schema-only migration + inference pass + gainPreview + # inspection (see the rollout notes). Repair stays paused at + # gainRepairBatchSize: 0 until the timer phase is enabled. + gainV2Enabled: false + minGainValue: 0.02 + gainRepairBatchSize: 0 # integer 0..25 attempts per tick; 0 pauses repair + gainRepairIntervalMs: 900000 # integer ms, 60000..86399999 (15 minutes) + gainRepairMaxTotal: null # null = unlimited; else nonnegative int ceiling + gainRepairRescreenGeneration: 0 # nonnegative int; +1 re-screens blocked evidence hub: enabled: false diff --git a/apps/memos-local-plugin/templates/config.openclaw.yaml b/apps/memos-local-plugin/templates/config.openclaw.yaml index 743b78fd2..64ec2dd4a 100644 --- a/apps/memos-local-plugin/templates/config.openclaw.yaml +++ b/apps/memos-local-plugin/templates/config.openclaw.yaml @@ -34,6 +34,17 @@ storage: algorithm: lightweightMemory: enabled: true # true = low-cost summaries only; false = memory self-evolution with tasks/experiences/world models/skills + l2Induction: + # v2 gain scoring is OFF by default. Flip gainV2Enabled on only + # after the schema-only migration + inference pass + gainPreview + # inspection (see the rollout notes). Repair stays paused at + # gainRepairBatchSize: 0 until the timer phase is enabled. + gainV2Enabled: false + minGainValue: 0.02 + gainRepairBatchSize: 0 # integer 0..25 attempts per tick; 0 pauses repair + gainRepairIntervalMs: 900000 # integer ms, 60000..86399999 (15 minutes) + gainRepairMaxTotal: null # null = unlimited; else nonnegative int ceiling + gainRepairRescreenGeneration: 0 # nonnegative int; +1 re-screens blocked evidence hub: enabled: false diff --git a/apps/memos-local-plugin/tests/unit/agent-contract/gain-maintenance-contract.test.ts b/apps/memos-local-plugin/tests/unit/agent-contract/gain-maintenance-contract.test.ts new file mode 100644 index 000000000..c0dfe07c2 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/agent-contract/gain-maintenance-contract.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; + +import { RPC_METHODS, isRpcMethodName } from "../../../agent-contract/jsonrpc.js"; + +describe("gain maintenance RPC contract", () => { + it("registers policies.gainPreview and policies.gainRollback with exact names", () => { + expect(RPC_METHODS.POLICIES_GAIN_PREVIEW).toBe("policies.gainPreview"); + expect(RPC_METHODS.POLICIES_GAIN_ROLLBACK).toBe("policies.gainRollback"); + expect(isRpcMethodName("policies.gainPreview")).toBe(true); + expect(isRpcMethodName("policies.gainRollback")).toBe(true); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/bridge/methods.test.ts b/apps/memos-local-plugin/tests/unit/bridge/methods.test.ts index 0a06af068..6598a7bce 100644 --- a/apps/memos-local-plugin/tests/unit/bridge/methods.test.ts +++ b/apps/memos-local-plugin/tests/unit/bridge/methods.test.ts @@ -7,7 +7,11 @@ import { describe, expect, it, vi } from "vitest"; import { makeDispatcher } from "../../../bridge/methods.js"; -import type { MemoryCore } from "../../../agent-contract/memory-core.js"; +import type { + GainPreviewResult, + GainRollbackResult, + MemoryCore, +} from "../../../agent-contract/memory-core.js"; import { MemosError } from "../../../agent-contract/errors.js"; function stubCore(overrides: Partial = {}): MemoryCore { @@ -91,6 +95,11 @@ function stubCore(overrides: Partial = {}): MemoryCore { setPolicyStatus: vi.fn(async () => null), deletePolicy: vi.fn(async () => ({ deleted: false })), editPolicyGuidance: vi.fn(async () => null), + previewGainRepair: vi.fn(async () => ({}) as GainPreviewResult), + rollbackGainRepair: vi.fn( + async () => + ({ ok: true, batchId: null, rolledBack: [], rolledBackAt: 0 }) as GainRollbackResult, + ), sharePolicy: vi.fn(async () => null), updatePolicy: vi.fn(async () => null), getWorldModel: vi.fn(async () => null), diff --git a/apps/memos-local-plugin/tests/unit/config/load.test.ts b/apps/memos-local-plugin/tests/unit/config/load.test.ts index 2d9f22f81..09880e4b2 100644 --- a/apps/memos-local-plugin/tests/unit/config/load.test.ts +++ b/apps/memos-local-plugin/tests/unit/config/load.test.ts @@ -347,6 +347,56 @@ viewer: }); }); +describe("algorithm.l2Induction — gain repair keys", () => { + it("ships the gain-repair shipping defaults on a bare config", () => { + const cfg = resolveConfig({}); + expect(cfg.algorithm.l2Induction.gainV2Enabled).toBe(false); + expect(cfg.algorithm.l2Induction.minGainValue).toBe(0.02); + expect(cfg.algorithm.l2Induction.gainRepairBatchSize).toBe(0); + expect(cfg.algorithm.l2Induction.gainRepairIntervalMs).toBe(900_000); + expect(cfg.algorithm.l2Induction.gainRepairMaxTotal).toBeNull(); + expect(cfg.algorithm.l2Induction.gainRepairRescreenGeneration).toBe(0); + }); + + it.each([ + ["batch_size_0", { gainRepairBatchSize: 0 }], + ["batch_size_25", { gainRepairBatchSize: 25 }], + ["interval_min", { gainRepairIntervalMs: 60_000 }], + ["interval_max", { gainRepairIntervalMs: 86_399_999 }], + ["interval_15m", { gainRepairIntervalMs: 900_000 }], + ["max_total_0", { gainRepairMaxTotal: 0 }], + ["max_total_25", { gainRepairMaxTotal: 25 }], + ["max_total_null", { gainRepairMaxTotal: null }], + ["rescreen_0", { gainRepairRescreenGeneration: 0 }], + ["rescreen_3", { gainRepairRescreenGeneration: 3 }], + ["gain_v2_on", { gainV2Enabled: true }], + ["min_gain_0", { minGainValue: 0 }], + ])("accepts valid %s", (_label, patch: Record) => { + const cfg = resolveConfig({ algorithm: { l2Induction: patch } }); + for (const [k, v] of Object.entries(patch)) { + expect(cfg.algorithm.l2Induction[k as keyof typeof cfg.algorithm.l2Induction]).toBe(v); + } + }); + + it.each([ + ["batch_size_26", { gainRepairBatchSize: 26 }], + ["batch_size_neg", { gainRepairBatchSize: -1 }], + ["batch_size_fraction", { gainRepairBatchSize: 0.5 }], + ["interval_below_min", { gainRepairIntervalMs: 59_999 }], + ["interval_above_max", { gainRepairIntervalMs: 86_400_000 }], + ["interval_fraction", { gainRepairIntervalMs: 900_000.5 }], + ["max_total_neg", { gainRepairMaxTotal: -1 }], + ["max_total_fraction", { gainRepairMaxTotal: 1.5 }], + ["rescreen_neg", { gainRepairRescreenGeneration: -1 }], + ["rescreen_fraction", { gainRepairRescreenGeneration: 0.5 }], + ["min_gain_above_1", { minGainValue: 1.5 }], + ])("rejects invalid %s", (_label, patch) => { + expect(() => + resolveConfig({ algorithm: { l2Induction: patch } }), + ).toThrow(/schema validation/); + }); + }); + describe("config/loadConfig MEMOS_HOME override", () => { const SAVED = process.env["MEMOS_HOME"]; beforeEach(() => { delete process.env["MEMOS_HOME"]; }); diff --git a/apps/memos-local-plugin/tests/unit/memory/l2/gain-repair.test.ts b/apps/memos-local-plugin/tests/unit/memory/l2/gain-repair.test.ts new file mode 100644 index 000000000..98bc6e6c7 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/memory/l2/gain-repair.test.ts @@ -0,0 +1,797 @@ +/** + * Unit tests for `core/memory/l2/gain-repair.ts` — the per-policy + * attempt engine: durable total-attempt budget in kv, atomic + * reservation + per-policy recompute commits, interrupted-claim reconcile and + * the config-generation re-screen. + * + * RED-GREEN: every scenario below targets the spec contract FIRST (failing), + * then the engine was implemented to satisfy it. + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { rootLogger } from "../../../../core/logger/index.js"; +import type { L2Config } from "../../../../core/memory/l2/types.js"; +import { + applyGainRepairAttempt, + consumeGainRepairRescreen, + GAIN_REPAIR_BUDGET_KEY, + gainRepairBudgetKey, + gainRepairRescreenKey, + readGainRepairBudget, + reconcileInterruptedGainRepairClaims, + reserveGainRepairAttempt, + runGainRepairTick, + type GainRepairAttemptDeps, + type GainRepairOwner, +} from "../../../../core/memory/l2/gain-repair.js"; +import { GAIN_INFERENCE_VERSION } from "../../../../core/reward/gain-inference.js"; +import { recomputePolicyGain as realRecompute } from "../../../../core/memory/l2/recompute-gain.js"; +import type { + EpisodeId, + GainValueSource, + PolicyId, + PolicyRow, + SessionId, + TraceId, + TraceRow, +} from "../../../../core/types.js"; +import { ensureEpisode } from "./_helpers.js"; +import type { TmpDbHandle } from "../../../helpers/tmp-db.js"; +import { makeTmpDb } from "../../../helpers/tmp-db.js"; + +const NOW = 1_700_000_000_000; +const OWNER: GainRepairOwner = { + ownerAgentKind: "openclaw", + ownerProfileId: "default", + ownerWorkspaceId: null, +}; + +function baseConfig(overrides: Partial = {}): L2Config { + return { + minSimilarity: 0.8, + candidateTtlDays: 30, + gamma: 0.9, + tauSoftmax: 0.5, + useLlm: true, + minTraceValue: 0.01, + minEpisodesForInduction: 1, + inductionTraceCharCap: 2_000, + gainEmaAlpha: 0.4, + gainV2Enabled: true, + minGainValue: 0.02, + gainRepairBatchSize: 25, + gainRepairIntervalMs: 900_000, + gainRepairMaxTotal: null, + gainRepairRescreenGeneration: 0, + ...overrides, + }; +} + +const THRESHOLDS = { minSupport: 2, minGain: 0.04, archiveGain: -0.05 }; + +function policyRow(overrides: Partial = {}): PolicyRow { + return { + id: "po_1" as PolicyRow["id"], + title: "title", + trigger: "trigger", + procedure: "procedure", + verification: "verification", + boundary: "boundary", + support: 0, + gain: 0, + gainVersion: 1, + status: "candidate", + sourceEpisodeIds: [], + inducedBy: "unit-test", + decisionGuidance: { preference: [], antiPattern: [] }, + vec: null, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +interface SeedPolicyOpts { + id: string; + status?: "candidate" | "active"; + support?: number; + gain?: number; + gainVersion?: number; + /** Resolved gainValue for the evidence trace. Undefined = unresolved (NULL). */ + gainValue?: number | null; + reason?: "inferred_evidence_updated" | "inference_refresh" | null; + owner?: Partial; + linkEvidence?: boolean; + blocked?: boolean; +} + +let handle: TmpDbHandle | null = null; + +function seedPolicy(opts: SeedPolicyOpts): PolicyRow { + const h = handle!; + const owner = { ...OWNER, ...(opts.owner ?? {}) }; + const episodeId = `ep_${opts.id}`; + const sessionId = "s_rec"; + const traceId = `tr_${opts.id}`; + ensureEpisode(h, episodeId, sessionId); + const gainValue = opts.gainValue === undefined ? 0.6 : opts.gainValue; + const source: GainValueSource | null = gainValue == null ? null : "inferred_normalized"; + h.repos.traces.insert({ + id: traceId as TraceId, + episodeId: episodeId as EpisodeId, + sessionId: sessionId as SessionId, + ts: NOW, + userText: "", + agentText: "", + toolCalls: [], + reflection: null, + value: gainValue ?? 0.5, + alpha: 0.5, + rHuman: 0.5, + priority: 0, + tags: [], + vecSummary: null, + vecAction: null, + turnId: 0, + schemaVersion: 1, + gainValue, + gainValueSource: source, + gainInferenceVersion: GAIN_INFERENCE_VERSION, + ownerAgentKind: owner.ownerAgentKind, + ownerProfileId: owner.ownerProfileId, + ownerWorkspaceId: owner.ownerWorkspaceId ?? null, + }); + h.repos.episodes.appendTrace(episodeId as EpisodeId, [traceId]); + + const policy = policyRow({ + id: opts.id as PolicyRow["id"], + status: opts.status ?? "candidate", + support: opts.support ?? 0, + gain: opts.gain ?? 0, + gainVersion: opts.gainVersion ?? 1, + sourceTraceIds: [traceId], + ownerAgentKind: owner.ownerAgentKind, + ownerProfileId: owner.ownerProfileId, + ownerWorkspaceId: owner.ownerWorkspaceId ?? null, + }); + h.repos.policies.insert(policy); + if (opts.linkEvidence !== false) { + h.repos.tracePolicyLinks.link({ + traceId: traceId as TraceId, + policyId: opts.id as PolicyId, + episodeId: episodeId as EpisodeId, + now: NOW, + }); + } + + if (opts.blocked) { + h.repos.gainRepair.upsertBlocked({ + policyId: opts.id as PolicyId, + ownerAgentKind: owner.ownerAgentKind, + ownerProfileId: owner.ownerProfileId, + ownerWorkspaceId: owner.ownerWorkspaceId ?? null, + reason: opts.reason ?? "inferred_evidence_updated", + blockedReason: "no_resolved_with", + inferenceVersion: GAIN_INFERENCE_VERSION, + now: NOW, + }); + } else { + h.repos.gainRepair.upsertPending({ + policyId: opts.id as PolicyId, + ownerAgentKind: owner.ownerAgentKind, + ownerProfileId: owner.ownerProfileId, + ownerWorkspaceId: owner.ownerWorkspaceId ?? null, + reason: opts.reason ?? "inferred_evidence_updated", + inferenceVersion: GAIN_INFERENCE_VERSION, + now: NOW, + }); + } + return policy; +} + +function deps(h: TmpDbHandle, config: L2Config, extra: Partial = {}): GainRepairAttemptDeps { + return { + db: h.db, + repos: h.repos, + config, + owner: OWNER, + thresholds: THRESHOLDS, + log: rootLogger.child({ channel: "test.gain_repair" }), + now: () => NOW, + inferenceVersion: GAIN_INFERENCE_VERSION, + ...extra, + }; +} + +/** + * Pre-mark the re-screen generation as consumed so a tick's rescreen step + * does not re-run the evidence-union queue rebuild (which pre-blocks zero-resolved + * policies and would mask engine-level outcome accounting). The engine's own + * re-screen behavior is tested in the dedicated describe block. + */ +function preConsumeRescreen(h: TmpDbHandle): void { + h.repos.kv.set(gainRepairRescreenKey(OWNER), { + generation: 0, + inferenceVersion: GAIN_INFERENCE_VERSION, + consumedAt: NOW, + }); +} + +describe("memory/l2/gain-repair — durable budget + tick", () => { + beforeEach(() => { + handle = makeTmpDb(); + preConsumeRescreen(handle); + }); + afterEach(() => { + handle?.cleanup(); + handle = null; + }); + + it("first tick attempts exactly 25 of 30 pending (batch 25 / maxTotal 25); second tick and restart attempt zero; raise to 30 → exactly 5", () => { + for (let i = 0; i < 30; i++) { + seedPolicy({ + id: `po_${String(i).padStart(2, "0")}`, + status: i < 20 ? "candidate" : "active", + support: i < 20 ? 0 : 3, + gainVersion: i < 20 ? 1 : 1, + }); + } + const cfg = baseConfig({ gainRepairBatchSize: 25, gainRepairMaxTotal: 25 }); + const result = runGainRepairTick(deps(handle!, cfg)); + expect(result.attempted).toBe(25); + expect(result.budget.attempted).toBe(25); + expect(result.budget.remaining).toBe(0); + + // Second tick (same process) — ceiling reached. + const second = runGainRepairTick(deps(handle!, cfg)); + expect(second.attempted).toBe(0); + + // "Restart" — a fresh engine over the same DB sees the same budget. + const restart = runGainRepairTick(deps(handle!, cfg)); + expect(restart.attempted).toBe(0); + expect(restart.budget.attempted).toBe(25); + + // Raise the ceiling to 30 → exactly 5 more. + const raised = runGainRepairTick( + deps(handle!, baseConfig({ gainRepairBatchSize: 25, gainRepairMaxTotal: 30 })), + ); + expect(raised.attempted).toBe(5); + expect(raised.budget.attempted).toBe(30); + expect(raised.budget.remaining).toBe(0); + }); + + it("null ceiling is unlimited and never resets the counter", () => { + for (let i = 0; i < 30; i++) { + seedPolicy({ id: `po_${String(i).padStart(2, "0")}` }); + } + const cfg = baseConfig({ gainRepairBatchSize: 25, gainRepairMaxTotal: null }); + const first = runGainRepairTick(deps(handle!, cfg)); + expect(first.attempted).toBe(25); + const second = runGainRepairTick(deps(handle!, cfg)); + expect(second.attempted).toBe(5); + expect(second.budget.attempted).toBe(30); + expect(second.budget.remaining).toBeNull(); + }); + + it("lowering maxTotal below attempted pauses new attempts without resetting", () => { + for (let i = 0; i < 5; i++) seedPolicy({ id: `po_${i}` }); + runGainRepairTick(deps(handle!, baseConfig({ gainRepairBatchSize: 25, gainRepairMaxTotal: 5 }))); + // Lower the ceiling below attempted → paused. + const paused = runGainRepairTick( + deps(handle!, baseConfig({ gainRepairBatchSize: 25, gainRepairMaxTotal: 3 })), + ); + expect(paused.attempted).toBe(0); + expect(paused.budget.remaining).toBe(0); + // Raise again → resumes from the preserved counter (2 more = ceiling 5). + const resumed = runGainRepairTick( + deps(handle!, baseConfig({ gainRepairBatchSize: 25, gainRepairMaxTotal: 5 })), + ); + expect(resumed.budget.attempted).toBe(5); + expect(resumed.attempted).toBe(0); // nothing left pending + }); + + it("batch size 0 pauses repair (tick does nothing) and re-enable resumes with preserved budget", () => { + for (let i = 0; i < 5; i++) seedPolicy({ id: `po_${i}` }); + const paused = runGainRepairTick(deps(handle!, baseConfig({ gainRepairBatchSize: 0 }))); + expect(paused.attempted).toBe(0); + expect(readGainRepairBudget(handle!.repos.kv, OWNER, null).initialized).toBe(false); + + const enabled = runGainRepairTick( + deps(handle!, baseConfig({ gainRepairBatchSize: 25, gainRepairMaxTotal: 10 })), + ); + expect(enabled.attempted).toBe(5); + // Pause again → no new attempts; budget preserved. + const repause = runGainRepairTick(deps(handle!, baseConfig({ gainRepairBatchSize: 0 }))); + expect(repause.attempted).toBe(0); + expect(readGainRepairBudget(handle!.repos.kv, OWNER, null).attempted).toBe(5); + }); + + it("v2 disabled means repair is not permitted (tick does nothing, no budget init)", () => { + for (let i = 0; i < 3; i++) seedPolicy({ id: `po_${i}` }); + const cfg = baseConfig({ gainV2Enabled: false, gainRepairBatchSize: 25 }); + const result = runGainRepairTick(deps(handle!, cfg)); + expect(result.attempted).toBe(0); + expect(readGainRepairBudget(handle!.repos.kv, OWNER, null).initialized).toBe(false); + }); + + it("blocked (no resolved with-evidence) and conflicted attempts consume budget", () => { + // po_noev: queued pending with an unresolved evidence trace (NULL gainValue). + seedPolicy({ + id: "po_noev", + status: "candidate", + support: 0, + gainVersion: 1, + gainValue: null, + }); + // po_ok: repairable. + seedPolicy({ id: "po_ok", status: "candidate" }); + + const result = runGainRepairTick( + deps(handle!, baseConfig({ gainRepairBatchSize: 25, gainRepairMaxTotal: 10 })), + ); + expect(result.attempted).toBe(2); + expect(result.blocked).toBe(1); + expect(result.rescored).toBe(1); + expect(result.budget.attempted).toBe(2); + + const noev = handle!.repos.gainRepair.getByPolicy("po_noev" as PolicyId); + expect(noev?.state).toBe("blocked"); + expect(noev?.blockedReason).toBe("no_resolved_with"); + // Policy fields untouched. + const pol = handle!.repos.policies.getById("po_noev" as PolicyId); + expect(pol?.gain).toBe(0); + expect(pol?.gainVersion).toBe(1); + expect(pol?.status).toBe("candidate"); + }); + + it("unknown-owner entries are blocked and never mutated", () => { + seedPolicy({ id: "po_unk", status: "candidate", support: 2, gainVersion: 1 }); + const h = handle!; + const policy = h.repos.policies.getById("po_unk" as PolicyId); + // Force unknown ownership on the policy (NULL/'unknown' → skipReason + // unknown_owner in the shared selector) and re-queue it under the + // 'unknown' owner namespace (the seed entry carried the openclaw owner). + h.db.prepare<{ id: string }>( + `UPDATE policies SET owner_agent_kind='unknown' WHERE id=@id`, + ).run({ id: "po_unk" }); + h.repos.gainRepair.removeByPolicy("po_unk" as PolicyId); + h.repos.gainRepair.upsertPending({ + policyId: "po_unk" as PolicyId, + ownerAgentKind: "unknown", + ownerProfileId: "default", + ownerWorkspaceId: null, + reason: "inferred_evidence_updated", + inferenceVersion: GAIN_INFERENCE_VERSION, + now: NOW, + }); + // Pre-consume rescreen for the 'unknown' namespace so the evidence-union + // rebuild does not pre-block this entry (its openclaw-owned evidence is + // correctly out-of-namespace) — we are exercising the ENGINE's + // unknown_owner skip path, not the bootstrap reconcile. + h.repos.kv.set(gainRepairRescreenKey({ ownerAgentKind: "unknown", ownerProfileId: "default", ownerWorkspaceId: null }), { + generation: 0, + inferenceVersion: GAIN_INFERENCE_VERSION, + consumedAt: NOW, + }); + const cfg = baseConfig({ gainRepairBatchSize: 25, gainRepairMaxTotal: 10 }); + const result = runGainRepairTick( + deps(h, cfg, { owner: { ownerAgentKind: "unknown", ownerProfileId: "default", ownerWorkspaceId: null } }), + ); + expect(result.attempted).toBe(1); + expect(result.blocked).toBe(1); + const entry = h.repos.gainRepair.getByPolicy("po_unk" as PolicyId); + expect(entry?.state).toBe("blocked"); + expect(entry?.blockedReason).toBe("unknown_owner"); + const untouched = h.repos.policies.getById("po_unk" as PolicyId); + expect(untouched?.gain).toBe(policy?.gain); + expect(untouched?.gainVersion).toBe(1); + expect(untouched?.status).toBe("candidate"); + }); + + it("valid candidate promotes only when live thresholds qualify (raw first-v2 gain, support unchanged)", () => { + // High gainValue 0.6 → gain 0.1 ≥ 0.04, support 2 → promote. + seedPolicy({ id: "po_promote", status: "candidate", support: 2, gainVersion: 1, gainValue: 0.6 }); + // Low gainValue 0.4 → gain 0.0 < 0.04 → stays candidate. + seedPolicy({ id: "po_stay", status: "candidate", support: 2, gainVersion: 1, gainValue: 0.4 }); + const result = runGainRepairTick(deps(handle!, baseConfig({ gainRepairBatchSize: 25 }))); + expect(result.promoted).toBe(1); + expect(result.rescored).toBe(2); + const promoted = handle!.repos.policies.getById("po_promote" as PolicyId); + expect(promoted?.status).toBe("active"); + expect(promoted?.gainVersion).toBe(2); + expect(promoted?.support).toBe(2); // support unchanged + expect(promoted?.gain).toBeGreaterThanOrEqual(0.04); + const stay = handle!.repos.policies.getById("po_stay" as PolicyId); + expect(stay?.status).toBe("candidate"); + expect(stay?.gainVersion).toBe(2); + // Queue entries resolved. + expect(handle!.repos.gainRepair.getByPolicy("po_promote" as PolicyId)).toBeNull(); + expect(handle!.repos.gainRepair.getByPolicy("po_stay" as PolicyId)).toBeNull(); + }); + + it("valid active refreshes gain/version but never archives even below the archive threshold", () => { + // Active policy with negative computed gain (gainValue -0.1) — far below + // archiveGain -0.05 — must stay active. + seedPolicy({ + id: "po_active", + status: "active", + support: 3, + gain: 0.2, + gainVersion: 1, + gainValue: -0.1, + }); + const result = runGainRepairTick(deps(handle!, baseConfig({ gainRepairBatchSize: 25 }))); + expect(result.rescored).toBe(1); + const pol = handle!.repos.policies.getById("po_active" as PolicyId); + expect(pol?.status).toBe("active"); // NEVER archived + expect(pol?.gainVersion).toBe(2); + expect(pol?.support).toBe(3); + }); + + it("failed attempts (unexpected item failure) consume budget and continue; earlier commits preserved", () => { + // po_a fails via the injected recompute seam; po_b commits normally AFTER. + seedPolicy({ id: "po_a", status: "candidate", support: 2, gainVersion: 1 }); + seedPolicy({ id: "po_b", status: "candidate", support: 2, gainVersion: 1 }); + const wrapped = ((input: Parameters[0], d: Parameters[1]) => { + if (input.policy.id === "po_a") throw new Error("boom"); + return realRecompute(input, d); + }) as typeof realRecompute; + + const result = runGainRepairTick( + deps(handle!, baseConfig({ gainRepairBatchSize: 25 }), { recomputePolicyGainFn: wrapped }), + ); + expect(result.failed).toBe(1); + expect(result.rescored).toBe(1); + expect(result.attempted).toBe(2); + expect(result.budget.attempted).toBe(2); + // po_a reset to pending (retry later), journal marked failed. + const entryA = handle!.repos.gainRepair.getByPolicy("po_a" as PolicyId); + expect(entryA?.state).toBe("pending"); + // po_b committed normally. + const polB = handle!.repos.policies.getById("po_b" as PolicyId); + expect(polB?.gainVersion).toBe(2); + }); + + it("interrupted-claim reconcile resets claims without replay; retry is a NEW budgeted attempt", () => { + seedPolicy({ id: "po_x", status: "candidate", support: 2, gainVersion: 1 }); + // Simulate a crash after reservation: budget consumed, entry claimed, + // journal pending, but no policy write ever happened. + const cfg = baseConfig({ gainRepairBatchSize: 25, gainRepairMaxTotal: 10 }); + const reserved = reserveGainRepairAttempt(deps(handle!, cfg), "po_x" as PolicyId, "gr_crash1"); + expect(reserved.kind).toBe("reserved"); + if (reserved.kind !== "reserved") throw new Error("unreachable"); + const before = handle!.repos.policies.getById("po_x" as PolicyId); + expect(before?.gainVersion).toBe(1); // nothing applied + + // Next tick reconciles the interrupted claim then retries. + const result = runGainRepairTick(deps(handle!, cfg)); + expect(result.reconciled).toBeGreaterThanOrEqual(1); // the claim reset + expect(result.attempted).toBe(1); // fresh budgeted attempt + expect(result.budget.attempted).toBe(2); // reservation + retry both consumed + const after = handle!.repos.policies.getById("po_x" as PolicyId); + expect(after?.gainVersion).toBe(2); // repaired exactly once + expect(handle!.repos.gainRepair.getByPolicy("po_x" as PolicyId)).toBeNull(); + + // The interrupted journal row is marked failed; the retry is completed. + const journal = handle!.db + .prepare( + `SELECT id, result FROM gain_repair_journal WHERE policy_id='po_x' ORDER BY created_at`, + ) + .all(); + expect(journal).toHaveLength(2); + expect(journal[0]!.result).toBe("failed"); + expect(journal[1]!.result).toBe("completed"); + }); + + it("natural-touch reconciliation removes already-v2 rows WITHOUT recompute, budget or duplicate EMA", () => { + seedPolicy({ + id: "po_nat", + status: "candidate", + support: 3, + gain: 0.123, + gainVersion: 2, + gainValue: 0.9, + }); + const result = runGainRepairTick(deps(handle!, baseConfig({ gainRepairBatchSize: 25 }))); + expect(result.reconciled).toBe(1); + expect(result.attempted).toBe(0); + expect(result.budget.attempted).toBe(0); + const pol = handle!.repos.policies.getById("po_nat" as PolicyId); + expect(pol?.gain).toBe(0.123); // NO duplicate EMA + expect(pol?.gainVersion).toBe(2); + expect(handle!.repos.gainRepair.getByPolicy("po_nat" as PolicyId)).toBeNull(); + }); + + it("inference-refresh-marked entries MUST recompute and resolve on success (never natural-reconciled)", () => { + seedPolicy({ + id: "po_refresh", + status: "candidate", + support: 3, + gain: 0.2, + gainVersion: 2, + gainValue: 0.6, + reason: "inference_refresh", + }); + const result = runGainRepairTick(deps(handle!, baseConfig({ gainRepairBatchSize: 25 }))); + expect(result.attempted).toBe(1); + expect(result.reconciled).toBe(0); + expect(result.rescored).toBe(1); + const pol = handle!.repos.policies.getById("po_refresh" as PolicyId); + expect(pol?.gainVersion).toBe(2); + // Refresh recomputes with a reset EMA (raw gain), so it does NOT blend + // the superseded 0.2 — the persisted gain is the fresh calculation. + expect(pol?.gain).toBeGreaterThan(0.04); + expect(handle!.repos.gainRepair.getByPolicy("po_refresh" as PolicyId)).toBeNull(); + }); + + it("ordinary L2 updates/promotions never consume the repair budget", () => { + seedPolicy({ id: "po_l2", status: "candidate", support: 1, gainVersion: 1 }); + // Ordinary L2 would update the policy directly (updateStats) — no queue + // involvement, no budget. + handle!.repos.policies.updateStats("po_l2" as PolicyId, { + support: 3, + gain: 0.3, + gainVersion: 2, + status: "active", + updatedAt: NOW + 1, + }); + const budget = readGainRepairBudget(handle!.repos.kv, OWNER, null); + expect(budget.attempted).toBe(0); + expect(budget.initialized).toBe(false); + // The ordinary update also leaves the (stale) queue entry; the next tick + // reconciles it as naturally repaired WITHOUT budget. + const result = runGainRepairTick(deps(handle!, baseConfig({ gainRepairBatchSize: 25 }))); + expect(result.attempted).toBe(0); + expect(result.reconciled).toBe(1); + expect(handle!.repos.gainRepair.getByPolicy("po_l2" as PolicyId)).toBeNull(); + }); + + it("namespace separation: same profile, different workspace — ticks never cross namespaces", () => { + const ownerA: GainRepairOwner = { ...OWNER }; + const ownerB: GainRepairOwner = { ...OWNER, ownerWorkspaceId: "ws_b" }; + seedPolicy({ id: "po_a", owner: { ownerWorkspaceId: null } }); + seedPolicy({ id: "po_b", owner: { ownerWorkspaceId: "ws_b" } }); + + const cfg = baseConfig({ gainRepairBatchSize: 25, gainRepairMaxTotal: 5 }); + const resultA = runGainRepairTick(deps(handle!, cfg, { owner: ownerA })); + expect(resultA.attempted).toBe(1); + expect(handle!.repos.gainRepair.getByPolicy("po_b" as PolicyId)?.state).toBe("pending"); + + const resultB = runGainRepairTick(deps(handle!, cfg, { owner: ownerB })); + expect(resultB.attempted).toBe(1); + // Separate budget counters per exact namespace. + expect(readGainRepairBudget(handle!.repos.kv, ownerA, null).attempted).toBe(1); + expect(readGainRepairBudget(handle!.repos.kv, ownerB, null).attempted).toBe(1); + }); + + it("candidate-first, stable ID order", () => { + for (let i = 0; i < 4; i++) { + seedPolicy({ + id: `po_${i}`, + status: i % 2 === 0 ? "active" : "candidate", + support: i % 2 === 0 ? 3 : 0, + }); + } + const cfg = baseConfig({ gainRepairBatchSize: 25 }); + // Force all four to be selected: candidates po_1, po_3 then active po_0, po_2. + const journalPolicyIds = handle!.db + .prepare(`SELECT policy_id FROM gain_repair_journal ORDER BY created_at`) + .all() + .map((r) => r.policy_id); + // Nothing journaled yet (selection happens inside the tick). Verify order + // via the tick result + journal insert order instead. + const result = runGainRepairTick(deps(handle!, cfg)); + expect(result.attempted).toBe(4); + const order = handle!.db + .prepare(`SELECT policy_id FROM gain_repair_journal ORDER BY created_at`) + .all() + .map((r) => r.policy_id); + expect(order).toEqual(["po_1", "po_3", "po_0", "po_2"]); + }); +}); + +describe("memory/l2/gain-repair — reservation races + conflict", () => { + beforeEach(() => { + handle = makeTmpDb(); + preConsumeRescreen(handle); + }); + afterEach(() => { + handle?.cleanup(); + handle = null; + }); + + it("a second reservation for an already-claimed entry is skipped without consuming budget", () => { + seedPolicy({ id: "po_r", status: "candidate", support: 2, gainVersion: 1 }); + const cfg = baseConfig({ gainRepairBatchSize: 25, gainRepairMaxTotal: 5 }); + const first = reserveGainRepairAttempt(deps(handle!, cfg), "po_r" as PolicyId, "gr_1"); + expect(first.kind).toBe("reserved"); + // A racing writer claims the same entry (it is already claimed). + const second = reserveGainRepairAttempt(deps(handle!, cfg), "po_r" as PolicyId, "gr_2"); + expect(second.kind).toBe("not_pending"); + expect(readGainRepairBudget(handle!.repos.kv, OWNER, null).attempted).toBe(1); + }); + + it("reservation refuses when the absolute ceiling is exhausted (tick stops)", () => { + seedPolicy({ id: "po_r", status: "candidate", support: 2, gainVersion: 1 }); + handle!.repos.kv.set(gainRepairBudgetKey(OWNER), { attempted: 5, initializedAt: NOW }); + const cfg = baseConfig({ gainRepairBatchSize: 25, gainRepairMaxTotal: 5 }); + const reserved = reserveGainRepairAttempt(deps(handle!, cfg), "po_r" as PolicyId, "gr_1"); + expect(reserved.kind).toBe("budget_exhausted"); + expect(readGainRepairBudget(handle!.repos.kv, OWNER, null).attempted).toBe(5); + }); + + it("concurrent policy change → journal conflict, entry left pending, never overwritten", () => { + seedPolicy({ id: "po_c", status: "candidate", support: 2, gainVersion: 1 }); + const cfg = baseConfig({ gainRepairBatchSize: 25 }); + const reserved = reserveGainRepairAttempt(deps(handle!, cfg), "po_c" as PolicyId, "gr_1"); + if (reserved.kind !== "reserved") throw new Error("unreachable"); + // Ordinary L2 changes the policy between reservation and apply. + handle!.repos.policies.updateStats("po_c" as PolicyId, { + support: 4, + gain: 0.44, + gainVersion: 2, + status: "active", + updatedAt: NOW + 500, + }); + const outcome = applyGainRepairAttempt(deps(handle!, cfg), "po_c" as PolicyId, reserved); + expect(outcome.kind).toBe("conflicted"); + const pol = handle!.repos.policies.getById("po_c" as PolicyId); + expect(pol?.gain).toBe(0.44); // NOT overwritten + expect(pol?.support).toBe(4); + expect(pol?.status).toBe("active"); + expect(handle!.repos.gainRepair.getByPolicy("po_c" as PolicyId)?.state).toBe("pending"); + const journal = handle!.db + .prepare( + `SELECT result FROM gain_repair_journal WHERE policy_id='po_c' ORDER BY created_at DESC LIMIT 1`, + ) + .get(); + expect(journal?.result).toBe("conflicted"); + }); +}); + +describe("memory/l2/gain-repair — config-generation re-screen", () => { + beforeEach(() => { + handle = makeTmpDb(); + }); + afterEach(() => { + handle?.cleanup(); + handle = null; + }); + + it("consume-once per generation increase: requeues eligible blocked, never resets budget, never writes policy fields", () => { + // po_b is blocked (its evidence is currently unresolved). + seedPolicy({ id: "po_b", gainValue: null, blocked: true }); + const cfg = baseConfig({ gainRepairBatchSize: 25, gainRepairRescreenGeneration: 2 }); + const first = runGainRepairTick(deps(handle!, cfg)); + expect(first.rescreenConsumed).toBe(true); + // Still blocked (evidence still unresolved) — not requeued. + expect(handle!.repos.gainRepair.getByPolicy("po_b" as PolicyId)?.state).toBe("blocked"); + // No budget consumed by re-screen. + expect(readGainRepairBudget(handle!.repos.kv, OWNER, null).attempted).toBe(0); + + // Same generation again → NOT consumed twice. + const second = runGainRepairTick(deps(handle!, cfg)); + expect(second.rescreenConsumed).toBe(false); + + // Now the evidence becomes resolved (integrity correction), and a NEW + // generation is requested → requeued and repaired. + handle!.repos.traces.updateScore("tr_po_b" as TraceId, { + value: 0.6, + alpha: 0.5, + priority: 0, + gainValue: 0.6, + gainValueSource: "live_normalized", + }); + const cfg3 = baseConfig({ gainRepairBatchSize: 25, gainRepairRescreenGeneration: 3 }); + const third = runGainRepairTick(deps(handle!, cfg3)); + expect(third.rescreenConsumed).toBe(true); + expect(third.blocked).toBe(0); + expect(third.attempted).toBe(1); // requeued + attempted this tick + expect(handle!.repos.gainRepair.getByPolicy("po_b" as PolicyId)).toBeNull(); + // Policy fields were only written by the repair attempt, never by the + // re-screen itself; and the budget counter was never reset. + expect(readGainRepairBudget(handle!.repos.kv, OWNER, null).attempted).toBe(1); + }); + + it("a new GAIN_INFERENCE_VERSION triggers the versioned re-screen", () => { + seedPolicy({ id: "po_v", gainValue: 0.2, blocked: true }); + const cfg = baseConfig({ gainRepairBatchSize: 25, gainRepairRescreenGeneration: 0 }); + const first = runGainRepairTick(deps(handle!, cfg)); + expect(first.rescreenConsumed).toBe(true); // version bump vs stored (absent) + + // No further triggers. + const second = runGainRepairTick(deps(handle!, cfg)); + expect(second.rescreenConsumed).toBe(false); + + // Simulate a future GAIN_INFERENCE_VERSION. + const bumped = runGainRepairTick( + deps(handle!, cfg, { inferenceVersion: GAIN_INFERENCE_VERSION + 1 }), + ); + expect(bumped.rescreenConsumed).toBe(true); + }); + + it("re-screen never changes policy gain/status (queue-only)", () => { + seedPolicy({ id: "po_bs", gainValue: null, blocked: true, gain: 0.05 }); + const cfg = baseConfig({ gainRepairBatchSize: 25, gainRepairRescreenGeneration: 1 }); + const result = consumeGainRepairRescreen(deps(handle!, cfg)); + expect(result.consumed).toBe(true); + const pol = handle!.repos.policies.getById("po_bs" as PolicyId); + expect(pol?.gain).toBe(0.05); + expect(pol?.status).toBe("candidate"); + expect(pol?.gainVersion).toBe(1); + }); + + it("re-screen is a blocked-input repair, NOT a queue rebuild: completed policies are not re-seeded", () => { + seedPolicy({ id: "po_done", status: "candidate", support: 2, gainVersion: 1 }); + // Repair it — the queue entry resolves (removed). + runGainRepairTick(deps(handle!, baseConfig({ gainRepairBatchSize: 25 }))); + expect(handle!.repos.gainRepair.getByPolicy("po_done" as PolicyId)).toBeNull(); + + // A generation increase must requeue ELIGIBLE BLOCKED records only — it + // must not re-add the completed policy (that would re-process unchanged + // evidence and let the queue lie about pending work). + const attemptedBefore = readGainRepairBudget(handle!.repos.kv, OWNER, null).attempted; + const res = consumeGainRepairRescreen( + deps(handle!, baseConfig({ gainRepairRescreenGeneration: 3 })), + ); + expect(res.consumed).toBe(true); + expect(res.requeued).toBe(0); + expect(handle!.repos.gainRepair.getByPolicy("po_done" as PolicyId)).toBeNull(); + // Budget untouched by the re-screen (counter preserved, never reset). + expect(readGainRepairBudget(handle!.repos.kv, OWNER, null).attempted).toBe(attemptedBefore); + }); + + it("interrupted-claim reconcile runs BEFORE the re-screen: the orphaned journal row is closed, not left pending", () => { + seedPolicy({ id: "po_crash", status: "candidate", support: 2, gainVersion: 1 }); + // Simulate a crash after reservation: budget consumed, entry claimed, + // journal row still `pending`. + const cfg = baseConfig({ gainRepairBatchSize: 25, gainRepairMaxTotal: 10, gainRepairRescreenGeneration: 1 }); + const reserved = reserveGainRepairAttempt(deps(handle!, cfg), "po_crash" as PolicyId, "gr_crash_rs"); + expect(reserved.kind).toBe("reserved"); + + // The next tick reconciles the claim first (closing the orphaned journal + // row) even though a re-screen generation is also pending, then retries. + const result = runGainRepairTick(deps(handle!, cfg)); + expect(result.reconciled).toBeGreaterThanOrEqual(1); + const orphan = handle!.db + .prepare( + `SELECT COUNT(*) AS n FROM gain_repair_journal WHERE batch_id='gr_crash_rs' AND result='pending'`, + ) + .get(); + expect(orphan?.n).toBe(0); + // The retry completed in the same tick. + expect(handle!.repos.policies.getById("po_crash" as PolicyId)?.gainVersion).toBe(2); + }); + + it("unknown_owner blocked entries are neither un-stamped nor requeued by a re-screen", () => { + seedPolicy({ id: "po_unk2", status: "candidate", support: 2, gainVersion: 1 }); + const h = handle!; + h.db.prepare<{ id: string }>( + `UPDATE policies SET owner_agent_kind='unknown' WHERE id=@id`, + ).run({ id: "po_unk2" }); + h.repos.gainRepair.removeByPolicy("po_unk2" as PolicyId); + h.repos.gainRepair.upsertBlocked({ + policyId: "po_unk2" as PolicyId, + ownerAgentKind: "unknown", + ownerProfileId: "default", + ownerWorkspaceId: null, + reason: "inferred_evidence_updated", + blockedReason: "unknown_owner", + inferenceVersion: GAIN_INFERENCE_VERSION, + now: NOW, + }); + const traceBefore = h.repos.traces.getById("tr_po_unk2" as TraceId); + const res = consumeGainRepairRescreen(deps(h, baseConfig({ gainRepairRescreenGeneration: 5 }))); + expect(res.consumed).toBe(true); + expect(res.requeued).toBe(0); + const entry = h.repos.gainRepair.getByPolicy("po_unk2" as PolicyId); + expect(entry?.state).toBe("blocked"); + expect(entry?.blockedReason).toBe("unknown_owner"); + // Owner-integrity blocks are not evidence-integrity: the trace stamp is + // left untouched (still the resolved score it had before). + const traceAfter = h.repos.traces.getById("tr_po_unk2" as TraceId); + expect(traceAfter?.gainValue).toBe(traceBefore?.gainValue); + expect(traceAfter?.gainValueSource).toBe(traceBefore?.gainValueSource); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/memory/l2/l2.integration.test.ts b/apps/memos-local-plugin/tests/unit/memory/l2/l2.integration.test.ts index d9d062d1f..0a49464af 100644 --- a/apps/memos-local-plugin/tests/unit/memory/l2/l2.integration.test.ts +++ b/apps/memos-local-plugin/tests/unit/memory/l2/l2.integration.test.ts @@ -23,6 +23,7 @@ import { rootLogger } from "../../../../core/logger/index.js"; import type { EmbeddingVector, EpisodeId, + PolicyRow, SessionId, TraceRow, } from "../../../../core/types.js"; @@ -43,6 +44,12 @@ function cfg(): L2Config { minEpisodesForInduction: 2, inductionTraceCharCap: 2_000, gainEmaAlpha: 0.4, + gainV2Enabled: false, + minGainValue: 0.02, + gainRepairBatchSize: 0, + gainRepairIntervalMs: 900_000, + gainRepairMaxTotal: null, + gainRepairRescreenGeneration: 0, }; } @@ -61,6 +68,12 @@ function mkTrace(partial: TraceOverrides): TraceRow { id: partial.id as TraceRow["id"], episodeId: partial.episodeId as TraceRow["episodeId"], sessionId: "s_int" as TraceRow["sessionId"], + // Real ownership by default: unknown-owner policies are excluded from + // automatic mutation in EVERY mode, so the shared fixtures model owned + // traces/policies unless a test deliberately omits the owner. + ownerAgentKind: partial.ownerAgentKind ?? "openclaw", + ownerProfileId: partial.ownerProfileId ?? "default", + ownerWorkspaceId: partial.ownerWorkspaceId, ts: NOW as TraceRow["ts"], userText: partial.userText ?? "", agentText: partial.agentText ?? "", @@ -75,6 +88,8 @@ function mkTrace(partial: TraceOverrides): TraceRow { vecAction: partial.vecAction ?? null, turnId: 0 as never, schemaVersion: 1, + gainValue: partial.gainValue !== undefined ? partial.gainValue : null, + gainValueSource: partial.gainValueSource !== undefined ? partial.gainValueSource : null, }; } @@ -254,6 +269,8 @@ describe("memory/l2/integration", () => { procedure: "install the matching distro package, then retry pip", verification: "pip install succeeds", boundary: "non-container environments with libraries already present", + ownerAgentKind: "openclaw", + ownerProfileId: "default", support: 0, gain: 0, status: "active", @@ -343,6 +360,8 @@ describe("memory/l2/integration", () => { procedure: "以简洁、自然的文字回应,但不添加任何emoji或表情符号", verification: "回复不包含emoji", boundary: "用户明确要求使用emoji时不适用", + ownerAgentKind: "openclaw", + ownerProfileId: "default", support: 3, gain: 0.2, status: "active", @@ -433,6 +452,8 @@ describe("memory/l2/integration", () => { procedure: "1) 从原始数据中提取关键信息字段;2) 按逻辑分类组织信息(如天气按:天气状况/气温/湿度/风速/降水分类);3) 使用结构化格式呈现(emoji图标+粗体标签+数值,或分段文本);4) 添加简短的实用性总结或建议", verification: "", boundary: "", + ownerAgentKind: "openclaw", + ownerProfileId: "default", support: 3, gain: 0.2, status: "active", @@ -606,6 +627,8 @@ describe("memory/l2/integration", () => { procedure: "1) 明确告知用户所有尝试过的工具都已失效及原因(如'搜狗、360、百度全都被封了')2) 回退到自身知识库,提供最接近的已知信息 3) 明确标注信息的时间戳和时效性限制(如'根据已有知识...2023年底到任')4) 提醒用户当前时间与信息时间的差距,建议后续通过其他渠道确认", verification: "", boundary: "", + ownerAgentKind: "openclaw", + ownerProfileId: "default", support: 3, gain: 0.2, status: "active", @@ -769,6 +792,8 @@ describe("memory/l2/integration", () => { procedure: "inspect the error, adjust the input, retry once, then explain fallback", verification: "the retry resolves the error or the fallback is explicit", boundary: "do not retry when the error is permanent", + ownerAgentKind: "openclaw", + ownerProfileId: "default", support: 3, gain: 0.1, status: "active", @@ -815,4 +840,690 @@ describe("memory/l2/integration", () => { expect(updated.gain).toBeLessThan(0.1); expect(updated.status).toBe("active"); }); + + it("gates untouched-candidate promotion on v2 certification and pending inference refresh", async () => { + ensureEpisode(handle, "ep_gate", "s_int"); + const mkCandidate = (id: string, gainVersion: number) => ({ + id: id as never, + title: "gated candidate", + trigger: "gated trigger", + procedure: "gated procedure", + verification: "v", + boundary: "b", + ownerAgentKind: "openclaw", + ownerProfileId: "default", + support: 3, + gain: 0.5, + gainVersion, + status: "candidate" as const, + sourceEpisodeIds: ["ep_gate" as EpisodeId], + inducedBy: "unit-test", + decisionGuidance: { preference: [], antiPattern: [] }, + vec: vec([1, 0, 0]), + createdAt: NOW as never, + updatedAt: NOW as never, + }); + handle.repos.policies.insert(mkCandidate("po_gate_v1", 1)); + handle.repos.policies.insert(mkCandidate("po_gate_v2", 2)); + + const v2cfg = { ...cfg(), gainV2Enabled: true, minGainValue: 0.02 }; + const deps = { + db: handle.db, + repos: handle.repos, + llm: fakeLlm({ completeJson: {} }), + log: rootLogger.child({ channel: "core.memory.l2" }), + bus: createL2EventBus(), + config: v2cfg, + thresholds: { minSupport: 2, minGain: 0.1, archiveGain: -0.05 }, + }; + const run = () => + runL2( + { + episodeId: "ep_gate" as EpisodeId, + sessionId: "s_int" as SessionId, + traces: [], + trigger: "manual", + now: NOW, + }, + deps, + ); + + await run(); + expect(handle.repos.policies.getById("po_gate_v1" as never)!.status).toBe("candidate"); + expect(handle.repos.policies.getById("po_gate_v2" as never)!.status).toBe("active"); + + // A v2 candidate with a PENDING inference refresh must not be promoted on + // stale certification. + handle.repos.policies.insert(mkCandidate("po_gate_v2b", 2)); + handle.repos.gainRepair.upsertPending({ + policyId: "po_gate_v2b" as never, + ownerAgentKind: "unknown", + ownerProfileId: "default", + reason: "inference_refresh", + }); + await run(); + expect(handle.repos.policies.getById("po_gate_v2b" as never)!.status).toBe("candidate"); + }); + + it("still archives an active policy when the recomputed v2 gain falls below the archive threshold", async () => { + ensureEpisode(handle, "ep_arch", "s_int"); + // Old with-link carries a resolved but strongly negative gainValue; the + // current trace is admission-eligible (gainValue ≥ minGainValue) so it + // associates and touches the policy. The blended v2 gain must dip below + // archiveGain and archive the active policy under the ordinary rule. + const trOld = mkTrace({ + id: "tr_arch_old", + episodeId: "ep_arch", + value: 0.5, + gainValue: -0.6, + gainValueSource: "live_normalized" as const, + ts: (NOW - 1) as never, + vecSummary: vec([1, 0, 0]), + }); + const trNew = mkTrace({ + id: "tr_arch_new", + episodeId: "ep_arch", + value: 0.9, + gainValue: 0.1, + gainValueSource: "live_normalized" as const, + ts: NOW as never, + vecSummary: vec([1, 0, 0]), + }); + handle.repos.traces.insert(trOld); + handle.repos.traces.insert(trNew); + handle.repos.policies.insert({ + id: "po_arch" as never, + title: "archivable v2 policy", + trigger: "archivable trigger", + procedure: "archivable procedure", + verification: "v", + boundary: "b", + ownerAgentKind: "openclaw", + ownerProfileId: "default", + support: 2, + gain: 0.1, + gainVersion: 2, + status: "active", + sourceEpisodeIds: ["ep_arch" as EpisodeId], + inducedBy: "unit-test", + decisionGuidance: { preference: [], antiPattern: [] }, + vec: vec([1, 0, 0]), + createdAt: NOW as never, + updatedAt: NOW as never, + }); + handle.repos.tracePolicyLinks.link({ + traceId: trOld.id, + policyId: "po_arch" as never, + episodeId: trOld.episodeId, + now: NOW, + }); + + await runL2( + { + episodeId: "ep_arch" as EpisodeId, + sessionId: "s_int" as SessionId, + traces: [trNew], + trigger: "manual", + now: NOW + 1, + }, + { + db: handle.db, + repos: handle.repos, + llm: fakeLlm({ completeJson: {} }), + log: rootLogger.child({ channel: "core.memory.l2" }), + bus: createL2EventBus(), + config: { ...cfg(), gainV2Enabled: true, minGainValue: 0.02 }, + thresholds: { minSupport: 2, minGain: 0.1, archiveGain: -0.05 }, + }, + ); + + const updated = handle.repos.policies.getById("po_arch" as never)!; + expect(updated.status).toBe("archived"); + expect(updated.gain).toBeLessThan(-0.05); + expect(updated.gainVersion).toBe(2); + }); + + it("bumps support by new evidence only — never by resolved with-count or excluded links", async () => { + ensureEpisode(handle, "ep_sup", "s_int"); + // tr_a is a PERSISTED with-link whose gainValue is unresolved; tr_b is the + // current-run association. The recompute resolves tr_b only — support must + // bump by exactly 1 (newSupportIds), not by the with-set size or by the + // resolved-with count. + const trA = mkTrace({ + id: "tr_sup_a", + episodeId: "ep_sup", + value: 0.8, + gainValue: null, + gainValueSource: null, + ts: (NOW - 1) as never, + vecSummary: vec([1, 0, 0]), + }); + const trB = mkTrace({ + id: "tr_sup_b", + episodeId: "ep_sup", + value: 0.9, + gainValue: 0.6, + gainValueSource: "live_normalized" as const, + ts: NOW as never, + vecSummary: vec([1, 0, 0]), + }); + handle.repos.traces.insert(trA); + handle.repos.traces.insert(trB); + handle.repos.policies.insert({ + id: "po_sup" as never, + title: "support semantics", + trigger: "support trigger", + procedure: "support procedure", + verification: "v", + boundary: "b", + ownerAgentKind: "openclaw", + ownerProfileId: "default", + support: 5, + gain: 0.2, + gainVersion: 2, + status: "active", + sourceEpisodeIds: ["ep_sup" as EpisodeId], + inducedBy: "unit-test", + decisionGuidance: { preference: [], antiPattern: [] }, + vec: vec([1, 0, 0]), + createdAt: NOW as never, + updatedAt: NOW as never, + }); + handle.repos.tracePolicyLinks.link({ + traceId: trA.id, + policyId: "po_sup" as never, + episodeId: trA.episodeId, + now: NOW, + }); + + await runL2( + { + episodeId: "ep_sup" as EpisodeId, + sessionId: "s_int" as SessionId, + traces: [trA, trB], + trigger: "manual", + now: NOW + 2, + }, + { + db: handle.db, + repos: handle.repos, + llm: fakeLlm({ completeJson: {} }), + log: rootLogger.child({ channel: "core.memory.l2" }), + bus: createL2EventBus(), + config: { ...cfg(), gainV2Enabled: true, minGainValue: 0.02 }, + thresholds: { minSupport: 2, minGain: 0.1, archiveGain: -0.05 }, + }, + ); + + const updated = handle.repos.policies.getById("po_sup" as never)!; + // 5 + 1 (tr_b only) — the unresolved tr_a link neither inflates support + // nor was deleted (the association added tr_b as a new link). + expect(updated.support).toBe(6); + const links = handle.repos.tracePolicyLinks.getWithTraceIds("po_sup" as never); + expect(links).toContain("tr_sup_a"); + expect(links).toContain("tr_sup_b"); + }); + + it("carries only filtered eligible evidence through induction bookkeeping (mixed eligible+NULL bucket)", async () => { + ensureEpisode(handle, "ep_mix_a", "s_int"); + ensureEpisode(handle, "ep_mix_b", "s_int"); + const trOk = mkTrace({ + id: "tr_mix_ok", + episodeId: "ep_mix_a", + ownerAgentKind: "openclaw", + ownerProfileId: "default", + tags: ["docker"], + toolCalls: [ + { name: "pip.install", input: { pkg: "lxml" }, output: "Error: MODULE_NOT_FOUND xmlsec1" }, + ], + value: 0.8, + gainValue: 0.6, + gainValueSource: "live_normalized" as const, + vecSummary: vec([1, 0, 0]), + }); + const trNull = mkTrace({ + id: "tr_mix_null", + episodeId: "ep_mix_b", + ownerAgentKind: "openclaw", + ownerProfileId: "default", + tags: ["docker"], + toolCalls: [ + { name: "pip.install", input: { pkg: "psycopg2" }, output: "Error: MODULE_NOT_FOUND pg_config" }, + ], + value: 0.9, + gainValue: null, + gainValueSource: null, + vecSummary: vec([1, 0, 0]), + }); + handle.repos.traces.insert(trOk); + handle.repos.traces.insert(trNull); + // Seed the bucket directly (old pool rows created before gainValue existed). + const pool = makeCandidatePool({ db: handle.db, repos: handle.repos }); + const ttlMs = cfg().candidateTtlDays * 24 * 60 * 60 * 1000; + pool.addCandidate({ trace: trOk, ttlMs, now: NOW }); + pool.addCandidate({ trace: trNull, ttlMs, now: NOW }); + + const v2cfg = { ...cfg(), gainV2Enabled: true, minGainValue: 0.02, minEpisodesForInduction: 1 }; + const result = await runL2( + { + episodeId: "ep_mix_b" as EpisodeId, + sessionId: "s_int" as SessionId, + traces: [], + trigger: "manual", + now: NOW, + }, + { + db: handle.db, + repos: handle.repos, + llm: fakeLlm({ + completeJson: { + "l2.l2.induction.v2": { + title: "mixed bucket induction", + trigger: "mixed trigger", + procedure: "mixed procedure", + verification: "v", + boundary: "b", + rationale: "r", + caveats: [], + confidence: 0.8, + }, + }, + }), + log: rootLogger.child({ channel: "core.memory.l2" }), + bus: createL2EventBus(), + config: v2cfg, + thresholds: { minSupport: 2, minGain: 0.15, archiveGain: -0.05 }, + }, + ); + + expect(result.inductions).toHaveLength(1); + expect(result.inductions[0].skippedReason).toBeNull(); + // ONLY the eligible trace is reported as induction evidence. + expect(result.inductions[0].traceIds).toEqual(["tr_mix_ok"]); + const pid = result.inductions[0].policyId!; + const persisted = handle.repos.policies.getById(pid)!; + // support counts only the eligible evidence (1), not the NULL bucket member. + expect(persisted.support).toBe(1); + expect(handle.repos.tracePolicyLinks.getWithTraceIds(pid)).toEqual(["tr_mix_ok"]); + + // A bucket whose evidence is ENTIRELY NULL is skipped and reports NO ids. + const trOnlyNull = mkTrace({ + id: "tr_mix_only_null", + episodeId: "ep_mix_b", + ownerAgentKind: "openclaw", + ownerProfileId: "default", + tags: ["docker"], + toolCalls: [ + { name: "pip.install", input: { pkg: "Pillow" }, output: "Error: MODULE_NOT_FOUND jpeg" }, + ], + value: 0.9, + gainValue: null, + gainValueSource: null, + vecSummary: vec([1, 0, 0]), + }); + handle.repos.traces.insert(trOnlyNull); + pool.addCandidate({ trace: trOnlyNull, ttlMs, now: NOW }); + const result2 = await runL2( + { + episodeId: "ep_mix_b" as EpisodeId, + sessionId: "s_int" as SessionId, + traces: [], + trigger: "manual", + now: NOW, + }, + { + db: handle.db, + repos: handle.repos, + llm: fakeLlm({ completeJson: {} }), + log: rootLogger.child({ channel: "core.memory.l2" }), + bus: createL2EventBus(), + config: v2cfg, + thresholds: { minSupport: 2, minGain: 0.15, archiveGain: -0.05 }, + }, + ); + expect(result2.inductions.some((i) => i.skippedReason === "too_few_episodes" && i.traceIds.length === 0)).toBe(true); + }); + + it("does not auto-mutate an unknown-owner policy in v2 mode (ordinary run)", async () => { + ensureEpisode(handle, "ep_uo", "s_int"); + const tr = mkTrace({ + id: "tr_uo", + episodeId: "ep_uo", + value: 0.9, + gainValue: 0.6, + gainValueSource: "live_normalized" as const, + vecSummary: vec([1, 0, 0]), + }); + handle.repos.traces.insert(tr); + // NO owner fields → ownerAgentKind "unknown" → excluded from auto-mutation. + handle.repos.policies.insert({ + id: "po_uo" as never, + title: "unknown owner policy", + trigger: "uo trigger", + procedure: "uo procedure", + verification: "v", + boundary: "b", + support: 3, + gain: 0.2, + gainVersion: 2, + status: "candidate", + sourceEpisodeIds: ["ep_uo" as EpisodeId], + inducedBy: "unit-test", + decisionGuidance: { preference: [], antiPattern: [] }, + vec: vec([1, 0, 0]), + createdAt: NOW as never, + updatedAt: NOW as never, + }); + // A second unknown-owner candidate that is NOT touched this run: the + // untouched sweep must also refuse to promote it in v2 mode. + handle.repos.policies.insert({ + id: "po_uo_untouched" as never, + title: "unknown owner untouched", + trigger: "uo2 trigger", + procedure: "uo2 procedure", + verification: "v", + boundary: "b", + support: 3, + gain: 0.5, + gainVersion: 2, + status: "candidate", + sourceEpisodeIds: ["ep_uo" as EpisodeId], + inducedBy: "unit-test", + decisionGuidance: { preference: [], antiPattern: [] }, + vec: vec([1, 0, 0]), + createdAt: NOW as never, + updatedAt: NOW as never, + }); + + await runL2( + { + episodeId: "ep_uo" as EpisodeId, + sessionId: "s_int" as SessionId, + traces: [tr], + trigger: "manual", + now: NOW + 1, + }, + { + db: handle.db, + repos: handle.repos, + llm: fakeLlm({ completeJson: {} }), + log: rootLogger.child({ channel: "core.memory.l2" }), + bus: createL2EventBus(), + config: { ...cfg(), gainV2Enabled: true, minGainValue: 0.02 }, + thresholds: { minSupport: 2, minGain: 0.1, archiveGain: -0.05 }, + }, + ); + + const after = handle.repos.policies.getById("po_uo" as never)!; + // association touched po_uo but the helper rejected auto-mutation: no gain, + // no support, no status write. + expect(after.support).toBe(3); + expect(after.gain).toBe(0.2); + expect(after.status).toBe("candidate"); + expect(after.gainVersion).toBe(2); + // untouched sweep refused to promote the unknown-owner candidate + expect(handle.repos.policies.getById("po_uo_untouched" as never)!.status).toBe("candidate"); + }); + + it("does not promote an untouched candidate while inference_refresh is blocked", async () => { + ensureEpisode(handle, "ep_blk", "s_int"); + handle.repos.policies.insert({ + id: "po_blk" as never, + title: "blocked refresh candidate", + trigger: "blk trigger", + procedure: "blk procedure", + verification: "v", + boundary: "b", + ownerAgentKind: "openclaw", + ownerProfileId: "default", + support: 3, + gain: 0.5, + gainVersion: 2, + status: "candidate", + sourceEpisodeIds: ["ep_blk" as EpisodeId], + inducedBy: "unit-test", + decisionGuidance: { preference: [], antiPattern: [] }, + vec: vec([1, 0, 0]), + createdAt: NOW as never, + updatedAt: NOW as never, + }); + // Explicit inference invalidation that ended up BLOCKED (e.g. the refresh + // attempt found no resolved with-evidence). Promotion must still be + // refused: the invalidation is unresolved until a successful refresh. + handle.repos.gainRepair.upsertBlocked({ + policyId: "po_blk" as never, + ownerAgentKind: "openclaw", + ownerProfileId: "default", + reason: "inference_refresh", + blockedReason: "no_resolved_with", + }); + + await runL2( + { + episodeId: "ep_blk" as EpisodeId, + sessionId: "s_int" as SessionId, + traces: [], + trigger: "manual", + now: NOW, + }, + { + db: handle.db, + repos: handle.repos, + llm: fakeLlm({ completeJson: {} }), + log: rootLogger.child({ channel: "core.memory.l2" }), + bus: createL2EventBus(), + config: { ...cfg(), gainV2Enabled: true, minGainValue: 0.02 }, + thresholds: { minSupport: 2, minGain: 0.1, archiveGain: -0.05 }, + }, + ); + expect(handle.repos.policies.getById("po_blk" as never)!.status).toBe("candidate"); + }); + + it("rejects unknown-owner mutation in legacy mode too (touched: no support/gain/status/version write)", async () => { + ensureEpisode(handle, "ep_leg_uo", "s_int"); + const tr = mkTrace({ + id: "tr_leg_uo", + episodeId: "ep_leg_uo", + value: 0.9, + vecSummary: vec([1, 0, 0]), + }); + handle.repos.traces.insert(tr); + // NO owner fields → ownerAgentKind "unknown". gainV2Enabled is FALSE + // (shipping default): the mode-independent rule must still refuse the write. + handle.repos.policies.insert({ + id: "po_leg_uo" as never, + title: "legacy unknown owner", + trigger: "leguo trigger", + procedure: "leguo procedure", + verification: "v", + boundary: "b", + support: 2, + gain: 0.2, + gainVersion: 1, + status: "candidate", + sourceEpisodeIds: ["ep_leg_uo" as EpisodeId], + inducedBy: "unit-test", + decisionGuidance: { preference: [], antiPattern: [] }, + vec: vec([1, 0, 0]), + createdAt: NOW as never, + updatedAt: NOW as never, + }); + + await runL2( + { + episodeId: "ep_leg_uo" as EpisodeId, + sessionId: "s_int" as SessionId, + traces: [tr], + trigger: "manual", + now: NOW + 1, + }, + { + db: handle.db, + repos: handle.repos, + llm: fakeLlm({ completeJson: {} }), + log: rootLogger.child({ channel: "core.memory.l2" }), + bus: createL2EventBus(), + config: cfg(), // gainV2Enabled: false — legacy mode + thresholds: { minSupport: 2, minGain: 0.1, archiveGain: -0.05 }, + }, + ); + + const after = handle.repos.policies.getById("po_leg_uo" as never)!; + // association touched the policy, but unknown-owner auto-mutation is + // rejected in EVERY mode: no support, gain, status or version write. + expect(after.support).toBe(2); + expect(after.gain).toBe(0.2); + expect(after.status).toBe("candidate"); + expect(after.gainVersion).toBe(1); + }); + + it("does not promote an unknown-owner candidate via the untouched sweep in legacy mode", async () => { + ensureEpisode(handle, "ep_leg_sweep", "s_int"); + const mkCandidate = (id: string, owner: Partial | null) => ({ + id: id as never, + title: `${id} candidate`, + trigger: "sweep trigger", + procedure: "sweep procedure", + verification: "v", + boundary: "b", + ...(owner ?? {}), + support: 3, + gain: 0.5, + gainVersion: 1, + status: "candidate" as const, + sourceEpisodeIds: ["ep_leg_sweep" as EpisodeId], + inducedBy: "unit-test", + decisionGuidance: { preference: [], antiPattern: [] }, + vec: vec([1, 0, 0]), + createdAt: NOW as never, + updatedAt: NOW as never, + }); + handle.repos.policies.insert(mkCandidate("po_leg_sweep_uo", null)); + handle.repos.policies.insert(mkCandidate("po_leg_sweep_ok", { + ownerAgentKind: "openclaw", + ownerProfileId: "default", + })); + + await runL2( + { + episodeId: "ep_leg_sweep" as EpisodeId, + sessionId: "s_int" as SessionId, + traces: [], + trigger: "manual", + now: NOW, + }, + { + db: handle.db, + repos: handle.repos, + llm: fakeLlm({ completeJson: {} }), + log: rootLogger.child({ channel: "core.memory.l2" }), + bus: createL2EventBus(), + config: cfg(), // gainV2Enabled: false — legacy mode + thresholds: { minSupport: 2, minGain: 0.1, archiveGain: -0.05 }, + }, + ); + // Unknown-owner candidate never promoted by the sweep in legacy mode… + expect(handle.repos.policies.getById("po_leg_sweep_uo" as never)!.status).toBe("candidate"); + // …while a known-owner candidate still promotes exactly as before. + expect(handle.repos.policies.getById("po_leg_sweep_ok" as never)!.status).toBe("active"); + }); + + it("does not admit out-of-range gainValues: not associated, linked, reported or counted toward support", async () => { + ensureEpisode(handle, "ep_oor", "s_int"); + const trValid = mkTrace({ + id: "tr_oor_valid", + episodeId: "ep_oor", + tags: ["docker"], + toolCalls: [ + { name: "pip.install", input: { pkg: "lxml" }, output: "Error: MODULE_NOT_FOUND xmlsec1" }, + ], + value: 0.8, + gainValue: 0.6, + gainValueSource: "live_normalized" as const, + vecSummary: vec([1, 0, 0]), + }); + const trBad = mkTrace({ + id: "tr_oor_bad", + episodeId: "ep_oor", + tags: ["docker"], + toolCalls: [ + { name: "pip.install", input: { pkg: "psycopg2" }, output: "Error: MODULE_NOT_FOUND pg_config" }, + ], + value: 0.9, + gainValue: 1.5, // out of [-1, 1] — NOT valid resolved evidence + gainValueSource: "live_normalized" as const, + vecSummary: vec([1, 0, 0]), + }); + handle.repos.traces.insert(trValid); + handle.repos.traces.insert(trBad); + // Both traces sit in the same candidate bucket (old pooled evidence). + const pool = makeCandidatePool({ db: handle.db, repos: handle.repos }); + const ttlMs = cfg().candidateTtlDays * 24 * 60 * 60 * 1000; + pool.addCandidate({ trace: trValid, ttlMs, now: NOW }); + pool.addCandidate({ trace: trBad, ttlMs, now: NOW }); + + handle.repos.policies.insert({ + id: "po_oor" as never, + title: "out-of-range admission gate", + trigger: "oor trigger", + procedure: "oor procedure", + verification: "v", + boundary: "b", + ownerAgentKind: "openclaw", + ownerProfileId: "default", + support: 5, + gain: 0.2, + gainVersion: 2, + status: "active", + sourceEpisodeIds: ["ep_oor" as EpisodeId], + inducedBy: "unit-test", + decisionGuidance: { preference: [], antiPattern: [] }, + vec: vec([1, 0, 0]), + createdAt: NOW as never, + updatedAt: NOW as never, + }); + + const result = await runL2( + { + episodeId: "ep_oor" as EpisodeId, + sessionId: "s_int" as SessionId, + traces: [trValid, trBad], + trigger: "manual", + now: NOW + 1, + }, + { + db: handle.db, + repos: handle.repos, + llm: fakeLlm({ + completeJson: { + "l2.l2.induction.v2": { + title: "oor bucket", + trigger: "oor bucket trigger", + procedure: "oor bucket procedure", + verification: "v", + boundary: "b", + rationale: "r", + caveats: [], + confidence: 0.8, + }, + }, + }), + log: rootLogger.child({ channel: "core.memory.l2" }), + bus: createL2EventBus(), + config: { ...cfg(), gainV2Enabled: true, minGainValue: 0.02, minEpisodesForInduction: 1 }, + thresholds: { minSupport: 2, minGain: 0.1, archiveGain: -0.05 }, + }, + ); + + // the out-of-range trace never associated + expect(result.associations.map((a) => a.traceId)).toEqual(["tr_oor_valid"]); + // the bucket's duplicate path reports only eligible evidence + expect(result.inductions[0].traceIds).toEqual(["tr_oor_valid"]); + const updated = handle.repos.policies.getById("po_oor" as never)!; + // support +1 (valid trace only) — the invalid trace never counted + expect(updated.support).toBe(6); + const links = handle.repos.tracePolicyLinks.getWithTraceIds("po_oor" as never); + expect(links).toContain("tr_oor_valid"); + expect(links).not.toContain("tr_oor_bad"); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/memory/l2/recompute-gain.test.ts b/apps/memos-local-plugin/tests/unit/memory/l2/recompute-gain.test.ts new file mode 100644 index 000000000..c15640546 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/memory/l2/recompute-gain.test.ts @@ -0,0 +1,988 @@ +/** + * Unit tests for `core/memory/l2/recompute-gain.ts` — the shared + * evidence selection + gain recomputation helper (ordinary L2 / preview / + * timer repair) and the evidence-union queue reconciliation. + * + * RED-GREEN: every scenario below was written against the spec contract FIRST + * (failing), then the helper was implemented to satisfy it. + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + recomputePolicyGain, + reconcileGainRepairQueueFromEvidenceUnion, + selectAndComputeGain, + type GainEvidenceTrace, + type RecomputeGainRepos, + type RecomputeGainResult, +} from "../../../../core/memory/l2/recompute-gain.js"; +import { computeGain } from "../../../../core/memory/l2/gain.js"; +import { initTestLogger, memoryBuffer } from "../../../../core/logger/index.js"; +import type { + EpisodeId, + GainValueSource, + OwnedRow, + PolicyRow, + RuntimeNamespace, + SessionId, + TraceId, + TraceRow, +} from "../../../../core/types.js"; +import type { TmpDbHandle } from "../../../helpers/tmp-db.js"; +import { makeTmpDb } from "../../../helpers/tmp-db.js"; +import { ensureEpisode } from "./_helpers.js"; + +const NOW = 1_700_000_000_000; +const NS: RuntimeNamespace = { agentKind: "openclaw", profileId: "default" }; + +function evidence(id: string, overrides: Partial = {}): GainEvidenceTrace { + return { + id: id as TraceId, + episodeId: "ep_1" as EpisodeId, + ts: NOW, + value: 0.5, + gainValue: 0.5, + gainValueSource: "inferred_normalized", + ...overrides, + }; +} + +function policy(overrides: Partial = {}): PolicyRow { + return { + id: "po_1" as PolicyRow["id"], + title: "title", + trigger: "trigger", + procedure: "procedure", + verification: "verification", + boundary: "boundary", + support: 0, + gain: 0, + gainVersion: 1, + status: "candidate", + sourceEpisodeIds: [], + inducedBy: "unit-test", + decisionGuidance: { preference: [], antiPattern: [] }, + vec: null, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function traceRow( + id: string, + episodeId: string, + ts: number, + value: number, + gainValue: number | null, + overrides: Partial = {}, +): TraceRow { + return { + id: id as TraceId, + episodeId: episodeId as EpisodeId, + sessionId: "s_rec" as SessionId, + ts, + userText: "", + agentText: "", + toolCalls: [], + reflection: null, + value, + alpha: 0.5, + rHuman: 0.5, + priority: 0, + tags: [], + vecSummary: null, + vecAction: null, + turnId: 0, + schemaVersion: 1, + gainValue, + gainValueSource: gainValue != null ? "live_normalized" : null, + ...overrides, + }; +} + +function insertTrace( + handle: TmpDbHandle, + id: string, + episodeId: string, + ts: number, + value: number, + opts: { + gainValue?: number | null; + gainValueSource?: GainValueSource | null; + owner?: Partial; + } = {}, +): void { + ensureEpisode(handle, episodeId, "s_rec"); + handle.repos.traces.insert({ + ...(opts.owner ?? {}), + id: id as TraceId, + episodeId: episodeId as EpisodeId, + sessionId: "s_rec" as SessionId, + ts, + userText: "", + agentText: "", + toolCalls: [], + reflection: null, + value, + alpha: 0.5, + rHuman: 0.5, + priority: 0, + tags: [], + vecSummary: null, + vecAction: null, + turnId: 0, + schemaVersion: 1, + gainValue: opts.gainValue !== undefined ? opts.gainValue : value, + gainValueSource: opts.gainValueSource !== undefined ? opts.gainValueSource : "inferred_normalized", + }); +} + +function recomputeDeps(handle: TmpDbHandle): RecomputeGainRepos { + return { + episodes: handle.repos.episodes, + traces: handle.repos.traces, + tracePolicyLinks: handle.repos.tracePolicyLinks, + }; +} + +const V2_CFG = { + minSimilarity: 0.8, + candidateTtlDays: 30, + gamma: 0.9, + tauSoftmax: 0.5, + useLlm: true, + minTraceValue: 0.01, + minEpisodesForInduction: 1, + inductionTraceCharCap: 2_000, + gainEmaAlpha: 0.4, + gainV2Enabled: true, + minGainValue: 0.02, + gainRepairBatchSize: 0, + gainRepairIntervalMs: 900_000, + gainRepairMaxTotal: null, + gainRepairRescreenGeneration: 0, +}; + +const SCORE_CFG = { gainEmaAlpha: 0.4, tauSoftmax: 0.5 }; + +describe("memory/l2/recompute-gain — pure selection", () => { + it("deduplicates by ID and breaks timestamp ties by ID descending", () => { + const byId = new Map([ + ["tr_a", evidence("tr_a", { ts: NOW, gainValue: 0.6 })], + ["tr_b", evidence("tr_b", { ts: NOW, gainValue: 0.8 })], + ]); + const result = selectAndComputeGain({ + policy: policy({ support: 2, gainVersion: 2 }), + withIds: ["tr_a", "tr_b", "tr_a"], + poolIds: ["tr_a", "tr_b", "tr_a"], + tracesById: byId, + scoreMode: "gain", + config: SCORE_CFG, + }); + expect(result.poolIds).toEqual(["tr_a", "tr_b"]); + expect(result.selectedWithIds).toEqual(["tr_b", "tr_a"]); + }); + + it("sorts timestamp descending before ID descending", () => { + const byId = new Map([ + ["old", evidence("old", { ts: NOW - 10, gainValue: 0.9 })], + ["new", evidence("new", { ts: NOW + 10, gainValue: 0.7 })], + ["mid", evidence("mid", { ts: NOW, gainValue: 0.8 })], + ]); + const result = selectAndComputeGain({ + policy: policy({ support: 2, gainVersion: 2 }), + withIds: ["old", "new", "mid"], + poolIds: ["old", "new", "mid"], + tracesById: byId, + scoreMode: "gain", + config: SCORE_CFG, + }); + expect(result.selectedWithIds).toEqual(["new", "mid", "old"]); + }); + + it("computes with one resolved with-trace while NULL with/without traces are excluded and counted", () => { + const byId = new Map([ + ["w1", evidence("w1", { gainValue: 0.6 })], + ["w2", evidence("w2", { gainValue: null, gainValueSource: null })], + ["o1", evidence("o1", { gainValue: 0.4 })], + ["o2", evidence("o2", { gainValue: null, gainValueSource: null })], + ]); + const result = selectAndComputeGain({ + policy: policy({ support: 3, gainVersion: 2 }), + withIds: ["w1", "w2"], + poolIds: ["w1", "w2", "o1", "o2"], + tracesById: byId, + scoreMode: "gain", + config: SCORE_CFG, + }); + expect(result.skipReason).toBeNull(); + expect(result.selectedWithIds).toEqual(["w1"]); + expect(result.selectedWithoutIds).toEqual(["o1"]); + expect(result.excluded.unresolvedWith).toBe(1); + expect(result.excluded.unresolvedWithout).toBe(1); + expect(result.gainVersion).toBe(2); + }); + + it("skips when every with-trace is unresolved, preserving previous state", () => { + const byId = new Map([ + ["w1", evidence("w1", { gainValue: null, gainValueSource: null })], + ["o1", evidence("o1", { gainValue: 0.4 })], + ]); + const result = selectAndComputeGain({ + policy: policy({ support: 5, gain: 0.123, gainVersion: 2 }), + withIds: ["w1"], + poolIds: ["w1", "o1"], + tracesById: byId, + scoreMode: "gain", + config: SCORE_CFG, + }); + expect(result.skipReason).toBe("no_resolved_with"); + expect(result.selectedWithIds).toEqual([]); + expect(result.persistedGain).toBe(0.123); // previous state untouched + }); + + it("skips when there is no with-evidence at all", () => { + const result = selectAndComputeGain({ + policy: policy({ support: 1, gain: 0.5, gainVersion: 2 }), + withIds: [], + poolIds: ["o1"], + tracesById: new Map([["o1", evidence("o1", { gainValue: 0.4 })]]), + scoreMode: "gain", + config: SCORE_CFG, + }); + expect(result.skipReason).toBe("no_resolved_with"); + expect(result.excluded.unresolvedWith).toBe(0); + }); + + it("keeps resolved zero without-traces as valid evidence", () => { + const byId = new Map([ + ["w1", evidence("w1", { gainValue: 0.6 })], + ["o1", evidence("o1", { gainValue: 0 })], + ["o2", evidence("o2", { gainValue: -0.0 })], + ]); + const result = selectAndComputeGain({ + policy: policy({ support: 3, gainVersion: 2 }), + withIds: ["w1"], + poolIds: ["w1", "o1", "o2"], + tracesById: byId, + scoreMode: "gain", + config: SCORE_CFG, + }); + expect(result.skipReason).toBeNull(); + // both without-traces carry ts NOW → tie broken by ID descending + expect(result.selectedWithoutIds).toEqual(["o2", "o1"]); + expect(result.selectedWithoutIds).toHaveLength(2); + }); + + it("rejects dangling IDs and other-owner traces with separate counters", () => { + const byId = new Map([ + ["other", evidence("other", { ownerAgentKind: "openclaw", ownerProfileId: "other" })], + ["ok", evidence("ok", { gainValue: 0.5 })], + ]); + const result = selectAndComputeGain({ + policy: policy({ ownerAgentKind: "hermes", ownerProfileId: "p1" }), + withIds: ["dangling", "other", "ok"], + poolIds: ["dangling", "other", "ok"], + tracesById: byId, + scoreMode: "gain", + config: SCORE_CFG, + }); + expect(result.reported.danglingIds).toBe(1); + expect(result.reported.outOfNamespace).toBe(1); + expect(result.reported.invalidScores).toBe(0); + expect(result.selectedWithIds).toEqual(["ok"]); + }); + + it("reports non-finite / out-of-range scores as invalid without using them", () => { + const byId = new Map([ + ["bad1", evidence("bad1", { gainValue: 1.5 })], + ["bad2", evidence("bad2", { gainValue: Number.NaN })], + ["ok", evidence("ok", { gainValue: 0.5 })], + ]); + const result = selectAndComputeGain({ + policy: policy({}), + withIds: ["bad1", "bad2", "ok"], + poolIds: ["bad1", "bad2", "ok"], + tracesById: byId, + scoreMode: "gain", + config: SCORE_CFG, + }); + expect(result.reported.invalidScores).toBe(2); + expect(result.selectedWithIds).toEqual(["ok"]); + }); + + it("rejects unknown-owner policies from auto-mutation with a distinct skip reason", () => { + const byId = new Map([ + ["w1", evidence("w1", { gainValue: 0.6 })], + ]); + const p = policy({ support: 3, gain: 0.2, gainVersion: 2 }); // no owner fields → unknown + const rejected = selectAndComputeGain({ + policy: p, + withIds: ["w1"], + poolIds: ["w1"], + tracesById: byId, + scoreMode: "gain", + config: SCORE_CFG, + rejectUnknownOwner: true, + }); + expect(rejected.skipReason).toBe("unknown_owner"); + expect(rejected.persistedGain).toBe(0.2); // previous state preserved + expect(rejected.unknownOwner).toBe(true); + // the selection report is still produced (preview relies on it) + expect(rejected.selectedWithIds).toEqual(["w1"]); + + // preview-style callers (rejectUnknownOwner false) still compute + report + const computed = selectAndComputeGain({ + policy: p, + withIds: ["w1"], + poolIds: ["w1"], + tracesById: byId, + scoreMode: "gain", + config: SCORE_CFG, + rejectUnknownOwner: false, + }); + expect(computed.skipReason).toBeNull(); + expect(computed.gainVersion).toBe(2); + }); + + it("does not borrow unknown-owner shared traces from a real-owner policy", () => { + // NULL/unknown-owner traces are the shared space — allowed for any policy. + const byId = new Map([ + ["shared", evidence("shared", { ownerAgentKind: "unknown", ownerProfileId: "default" })], + ["same", evidence("same", { ownerAgentKind: "hermes", ownerProfileId: "p1" })], + ]); + const result = selectAndComputeGain({ + policy: policy({ ownerAgentKind: "hermes", ownerProfileId: "p1" }), + withIds: ["shared", "same"], + poolIds: ["shared", "same"], + tracesById: byId, + scoreMode: "gain", + config: SCORE_CFG, + }); + expect(result.reported.outOfNamespace).toBe(0); + expect(result.selectedWithIds.sort()).toEqual(["same", "shared"]); + }); +}); + +describe("memory/l2/recompute-gain — first-v2 EMA vs ordinary EMA", () => { + function oneWithResult(overrides: { support?: number; gainVersion?: number; gain?: number }): RecomputeGainResult { + const byId = new Map([ + ["w1", evidence("w1", { gainValue: 0.6 })], + ["o1", evidence("o1", { gainValue: 0.4 })], + ]); + return selectAndComputeGain({ + policy: policy({ support: overrides.support ?? 0, gainVersion: overrides.gainVersion ?? 1, gain: overrides.gain ?? 0.2 }), + withIds: ["w1"], + poolIds: ["w1", "o1"], + tracesById: byId, + scoreMode: "gain", + config: SCORE_CFG, + }); + } + + function rawGain(): number { + return computeGain( + { policyId: "po_1" as PolicyRow["id"], withTraces: [], withoutTraces: [] }, + { tauSoftmax: SCORE_CFG.tauSoftmax }, + ).gain + 0; // placeholder, replaced below + } + void rawGain; + + it("resets the EMA for the first v2 calculation (support == 0)", () => { + const result = oneWithResult({ support: 0 }); + expect(result.isFirst).toBe(true); + expect(result.persistedGain).toBeCloseTo(result.raw.gain, 9); + expect(result.gainVersion).toBe(2); + }); + + it("resets the EMA when the policy was not yet v2-certified (gain_version != 2)", () => { + const result = oneWithResult({ support: 7, gainVersion: 1 }); + expect(result.isFirst).toBe(true); + expect(result.persistedGain).toBeCloseTo(result.raw.gain, 9); + }); + + it("blends the EMA on later ordinary v2 updates (support > 0, gain_version == 2)", () => { + const result = oneWithResult({ support: 7, gainVersion: 2, gain: 0.2 }); + expect(result.isFirst).toBe(false); + expect(result.persistedGain).toBeCloseTo(0.4 * result.raw.gain + 0.6 * 0.2, 9); + }); + + it("keeps the legacy-mode EMA semantics on the disabled path", () => { + const byId = new Map([ + ["w1", evidence("w1", { gainValue: null, gainValueSource: null, value: 0.6 })], + ]); + const result = selectAndComputeGain({ + policy: policy({ support: 7, gainVersion: 2, gain: 0.2 }), + withIds: ["w1"], + poolIds: ["w1"], + tracesById: byId, + scoreMode: "value", // legacy: NULL gainValue is irrelevant; V is the score + config: SCORE_CFG, + }); + expect(result.skipReason).toBeNull(); + expect(result.gainVersion).toBe(1); + // legacy first = support === 0 only → not first here + expect(result.isFirst).toBe(false); + expect(result.persistedGain).toBeCloseTo(0.4 * result.raw.gain + 0.6 * 0.2, 9); + }); + + it("raw gain is exposed separately from the persisted EMA (preview contract)", () => { + const result = oneWithResult({ support: 7, gainVersion: 2, gain: 0.2 }); + expect(result.raw).toBeTypeOf("object"); + expect(result.raw.gain).toBeTypeOf("number"); + expect(result.persistedGain).toBeTypeOf("number"); + expect(result.raw.gain).not.toBeCloseTo(result.persistedGain, 9); + }); +}); + +describe("memory/l2/recompute-gain — I/O wrapper", () => { + let handle: TmpDbHandle; + beforeEach(() => { + handle = makeTmpDb(); + }); + afterEach(() => { + handle.cleanup(); + }); + + // v2-mode auto-mutation rejects unknown-owner policies, so these tests use + // policies with a real owner. + const OWNED: Partial = { ownerAgentKind: "openclaw", ownerProfileId: "default" }; + const ownedPolicy = (overrides: Partial = {}): PolicyRow => + policy({ ...OWNED, ...overrides }); + + it("includes an old directly-linked trace beyond its episode's newest 50", () => { + ensureEpisode(handle, "ep_1", "s_rec"); + handle.repos.policies.insert(ownedPolicy({ id: "po_1" as PolicyRow["id"], status: "active" })); + // 55 persisted members; tr_old is the OLDEST, so it is NOT among the + // episode's newest 50 — but the direct with-link must include it anyway. + const ids: string[] = ["tr_old"]; + insertTrace(handle, "tr_old", "ep_1", NOW, 0.7); + for (let i = 0; i < 54; i++) { + const id = `tr_n${String(i).padStart(2, "0")}`; + insertTrace(handle, id, "ep_1", NOW + i + 1, 0.5); + ids.push(id); + } + handle.repos.episodes.appendTrace("ep_1" as EpisodeId, ids); + handle.repos.tracePolicyLinks.link({ + traceId: "tr_old" as TraceId, + policyId: "po_1" as PolicyRow["id"], + episodeId: "ep_1" as EpisodeId, + now: NOW, + }); + + const result = recomputePolicyGain( + { policy: ownedPolicy({ id: "po_1" as PolicyRow["id"], status: "active" }), namespace: NS, config: V2_CFG }, + recomputeDeps(handle), + ); + expect(result.skipReason).toBeNull(); + expect(result.selectedWithIds).toContain("tr_old"); + // newest 50 of the episode joined the pool alongside the with-link + expect(result.poolIds.length).toBeGreaterThanOrEqual(51); + expect(result.selectedWithoutIds.length).toBeGreaterThan(0); + // links are never deleted by recomputation + expect(handle.repos.tracePolicyLinks.getWithTraceIds("po_1" as PolicyRow["id"])).toEqual(["tr_old"]); + }); + + it("includes current-run traces that are not yet persisted", () => { + handle.repos.policies.insert(ownedPolicy({ id: "po_1" as PolicyRow["id"] })); + const result = recomputePolicyGain( + { + policy: ownedPolicy({ id: "po_1" as PolicyRow["id"] }), + namespace: NS, + config: V2_CFG, + currentTraces: [traceRow("tr_new", "ep_1", NOW, 0.5, 0.6)], + withTraceIds: ["tr_new" as TraceId], + }, + recomputeDeps(handle), + ); + expect(result.skipReason).toBeNull(); + expect(result.selectedWithIds).toEqual(["tr_new"]); + expect(result.gainVersion).toBe(2); + expect(result.isFirst).toBe(true); + expect(result.provenance.liveNormalized).toBe(1); + }); + + it("computes when a resolved with-trace remains even if a persisted link is unresolved", () => { + ensureEpisode(handle, "ep_1", "s_rec"); + handle.repos.policies.insert(ownedPolicy({ id: "po_1" as PolicyRow["id"] })); + insertTrace(handle, "tr_old", "ep_1", NOW, 0.5, { gainValue: null, gainValueSource: null }); + handle.repos.tracePolicyLinks.link({ + traceId: "tr_old" as TraceId, + policyId: "po_1" as PolicyRow["id"], + episodeId: "ep_1" as EpisodeId, + now: NOW, + }); + const result = recomputePolicyGain( + { + policy: ownedPolicy({ id: "po_1" as PolicyRow["id"] }), + namespace: NS, + config: V2_CFG, + currentTraces: [traceRow("tr_new", "ep_1", NOW + 1, 0.5, 0.6)], + withTraceIds: ["tr_new" as TraceId], + }, + recomputeDeps(handle), + ); + expect(result.skipReason).toBeNull(); + expect(result.selectedWithIds).toEqual(["tr_new"]); + expect(result.excluded.unresolvedWith).toBe(1); + }); + + it("skips when all with-evidence is unresolved (enabled mode)", () => { + handle.repos.policies.insert(ownedPolicy({ id: "po_1" as PolicyRow["id"], gain: 0.11, gainVersion: 2 })); + const result = recomputePolicyGain( + { + policy: ownedPolicy({ id: "po_1" as PolicyRow["id"], gain: 0.11, gainVersion: 2 }), + namespace: NS, + config: V2_CFG, + currentTraces: [traceRow("tr_new", "ep_1", NOW, 0.8, null)], + withTraceIds: ["tr_new" as TraceId], + }, + recomputeDeps(handle), + ); + expect(result.skipReason).toBe("no_resolved_with"); + expect(result.persistedGain).toBe(0.11); + }); + + it("legacy disabled mode scores V and never treats NULL gainValue as unresolved", () => { + handle.repos.policies.insert(ownedPolicy({ id: "po_1" as PolicyRow["id"] })); + const result = recomputePolicyGain( + { + policy: ownedPolicy({ id: "po_1" as PolicyRow["id"] }), + namespace: NS, + config: { ...V2_CFG, gainV2Enabled: false }, + currentTraces: [traceRow("tr_new", "ep_1", NOW, 0.5, null)], + withTraceIds: ["tr_new" as TraceId], + }, + recomputeDeps(handle), + ); + expect(result.skipReason).toBeNull(); + expect(result.selectedWithIds).toEqual(["tr_new"]); + expect(result.gainVersion).toBe(1); + expect(result.excluded.unresolvedWith).toBe(0); + }); + + it("inference-refresh recompute resets the EMA even for a certified v2 policy", () => { + handle.repos.policies.insert(ownedPolicy({ id: "po_1" as PolicyRow["id"], support: 5, gain: 0.3, gainVersion: 2 })); + const result = recomputePolicyGain( + { + policy: ownedPolicy({ id: "po_1" as PolicyRow["id"], support: 5, gain: 0.3, gainVersion: 2 }), + namespace: NS, + config: V2_CFG, + mode: "inference_refresh", + currentTraces: [traceRow("tr_new", "ep_1", NOW, 0.5, 0.6)], + withTraceIds: ["tr_new" as TraceId], + }, + recomputeDeps(handle), + ); + expect(result.skipReason).toBeNull(); + expect(result.isFirst).toBe(true); + expect(result.persistedGain).toBeCloseTo(result.raw.gain, 9); + }); + + it("includes policy.sourceTraceIds as persisted with-evidence and derives linked episodes from them", () => { + ensureEpisode(handle, "ep_src", "s_rec"); + insertTrace(handle, "tr_src", "ep_src", NOW, 0.5, { gainValue: 0.6 }); + insertTrace(handle, "tr_ep_extra", "ep_src", NOW + 1, 0.4, { gainValue: 0.3 }); + handle.repos.episodes.appendTrace("ep_src" as EpisodeId, ["tr_src", "tr_ep_extra"]); + // Imported/feedback-derived policy: sourceTraceIds present, NO links. + handle.repos.policies.insert(ownedPolicy({ + id: "po_imp" as PolicyRow["id"], + status: "active", + sourceTraceIds: ["tr_src" as TraceId], + })); + + const result = recomputePolicyGain( + { + policy: ownedPolicy({ id: "po_imp" as PolicyRow["id"], status: "active", sourceTraceIds: ["tr_src" as TraceId] }), + namespace: NS, + config: V2_CFG, + }, + recomputeDeps(handle), + ); + expect(result.skipReason).toBeNull(); + expect(result.selectedWithIds).toContain("tr_src"); + // ep_src is derived from the SOURCE trace (no link row exists) → its + // newest-50 members joined the pool as without-evidence. + expect(result.poolIds).toContain("tr_ep_extra"); + expect(result.selectedWithoutIds).toContain("tr_ep_extra"); + }); + + it("rejects an unknown-owner policy in ordinary (auto-mutation) mode", () => { + handle.repos.policies.insert(policy({ id: "po_anon2" as PolicyRow["id"] })); + const result = recomputePolicyGain( + { + policy: policy({ id: "po_anon2" as PolicyRow["id"] }), + namespace: NS, + config: V2_CFG, + currentTraces: [traceRow("tr_new", "ep_1", NOW, 0.5, 0.6)], + withTraceIds: ["tr_new" as TraceId], + }, + recomputeDeps(handle), + ); + expect(result.unknownOwner).toBe(true); + expect(result.skipReason).toBe("unknown_owner"); + expect(result.persistedGain).toBe(0); + }); + + it("debug-logs excluded orphans per episode and stays silent without", () => { + initTestLogger(); + // ep_orph: S = [tr_in]; tr_orphan shares episode_id but was never + // folded into trace_ids_json (the pass leaves it unresolved). + ensureEpisode(handle, "ep_orph", "s_rec"); + insertTrace(handle, "tr_in", "ep_orph", NOW, 0.5, { gainValue: 0.6 }); + insertTrace(handle, "tr_orphan", "ep_orph", NOW, 0.4, { gainValue: 0.4 }); + handle.repos.episodes.appendTrace("ep_orph" as EpisodeId, ["tr_in"]); + // ep_clean: every table row is listed in S — no orphans. + ensureEpisode(handle, "ep_clean", "s_rec"); + insertTrace(handle, "tr_only", "ep_clean", NOW, 0.5, { gainValue: 0.6 }); + handle.repos.episodes.appendTrace("ep_clean" as EpisodeId, ["tr_only"]); + handle.repos.policies.insert(ownedPolicy({ id: "po_orph" as PolicyRow["id"], status: "active" })); + for (const [tid, eid] of [["tr_in", "ep_orph"], ["tr_only", "ep_clean"]] as const) { + handle.repos.tracePolicyLinks.link({ + traceId: tid as TraceId, + policyId: "po_orph" as PolicyRow["id"], + episodeId: eid as EpisodeId, + now: NOW, + }); + } + + const result = recomputePolicyGain( + { policy: ownedPolicy({ id: "po_orph" as PolicyRow["id"], status: "active" }), namespace: NS, config: V2_CFG }, + recomputeDeps(handle), + ); + // No behavior change: the orphan never enters the pool. + expect(result.skipReason).toBeNull(); + expect(result.poolIds).toContain("tr_in"); + expect(result.poolIds).toContain("tr_only"); + expect(result.poolIds).not.toContain("tr_orphan"); + + const orphanLogs = memoryBuffer() + .tail({ limit: 256 }) + .filter((r) => r.msg === "recompute_gain.orphans_excluded"); + expect(orphanLogs).toHaveLength(1); + expect(orphanLogs[0]!.data).toMatchObject({ + policyId: "po_orph", + episodeId: "ep_orph", + orphansExcluded: 1, + poolMembers: 1, + }); + // Silent for the orphan-free episode. + expect( + orphanLogs.some( + (r) => (r.data as Record | undefined)?.episodeId === "ep_clean", + ), + ).toBe(false); + }); + + it("truncates the per-episode newest-50 with ID-descending tie breaks", () => { + ensureEpisode(handle, "ep_tie", "s_rec"); + handle.repos.policies.insert(ownedPolicy({ id: "po_tie" as PolicyRow["id"], status: "active" })); + // 51 persisted members: tr_top (newest ts), then 50 tied at the same older + // ts. The newest-50 slice keeps tr_top + 49 of the tied 50; with ID-desc + // ties it must drop tr_t00 and keep tr_t49. + const ids: string[] = []; + insertTrace(handle, "tr_top", "ep_tie", NOW + 100, 0.5, { gainValue: 0.6 }); + ids.push("tr_top"); + for (let i = 0; i < 50; i++) { + const id = `tr_t${String(i).padStart(2, "0")}`; + insertTrace(handle, id, "ep_tie", NOW, 0.4, { gainValue: 0.3 }); + ids.push(id); + } + handle.repos.episodes.appendTrace("ep_tie" as EpisodeId, ids); + handle.repos.tracePolicyLinks.link({ + traceId: "tr_top" as TraceId, + policyId: "po_tie" as PolicyRow["id"], + episodeId: "ep_tie" as EpisodeId, + now: NOW, + }); + + const result = recomputePolicyGain( + { policy: ownedPolicy({ id: "po_tie" as PolicyRow["id"], status: "active" }), namespace: NS, config: V2_CFG }, + recomputeDeps(handle), + ); + expect(result.poolIds).toContain("tr_top"); + expect(result.poolIds).toContain("tr_t49"); + expect(result.poolIds).not.toContain("tr_t00"); + }); +}); + +describe("memory/l2/recompute-gain — queue reconciliation from the evidence union", () => { + let handle: TmpDbHandle; + beforeEach(() => { + handle = makeTmpDb(); + }); + afterEach(() => { + handle.cleanup(); + }); + + function reconcile( + owner: { ownerAgentKind: string; ownerProfileId: string; ownerWorkspaceId?: string | null } = { + ownerAgentKind: "openclaw", + ownerProfileId: "default", + }, + policies: typeof handle.repos.policies = handle.repos.policies, + ) { + return reconcileGainRepairQueueFromEvidenceUnion({ + db: handle.db, + kv: handle.repos.kv, + gainRepair: handle.repos.gainRepair, + policies, + traces: handle.repos.traces, + episodes: handle.repos.episodes, + tracePolicyLinks: handle.repos.tracePolicyLinks, + owner, + }); + } + + it("seeds pending when ≥1 resolved with-link survives and blocks zero-resolved policies directly", () => { + ensureEpisode(handle, "ep_1", "s_rec"); + insertTrace(handle, "tr_resolved", "ep_1", NOW, 0.5, { gainValue: 0.6 }); + insertTrace(handle, "tr_unresolved", "ep_1", NOW, 0.5, { gainValue: null, gainValueSource: null }); + const OWNER: Partial = { ownerAgentKind: "openclaw", ownerProfileId: "default" }; + handle.repos.policies.insert(policy({ id: "po_good" as PolicyRow["id"], status: "candidate", ...OWNER })); + handle.repos.policies.insert(policy({ id: "po_stuck" as PolicyRow["id"], status: "candidate", ...OWNER })); + handle.repos.tracePolicyLinks.link({ + traceId: "tr_resolved" as TraceId, + policyId: "po_good" as PolicyRow["id"], + episodeId: "ep_1" as EpisodeId, + now: NOW, + }); + handle.repos.tracePolicyLinks.link({ + traceId: "tr_unresolved" as TraceId, + policyId: "po_stuck" as PolicyRow["id"], + episodeId: "ep_1" as EpisodeId, + now: NOW, + }); + + const out = reconcile(); + expect(out.seeded).toBe(1); + expect(out.blocked).toBe(1); + + const good = handle.repos.gainRepair.getByPolicy("po_good" as PolicyRow["id"]); + expect(good?.state).toBe("pending"); + expect(good?.reason).toBe("inferred_evidence_updated"); + + const stuck = handle.repos.gainRepair.getByPolicy("po_stuck" as PolicyRow["id"]); + expect(stuck?.state).toBe("blocked"); + expect(stuck?.blockedReason).toBe("no_resolved_with"); + + // policy fields are untouched by the reconcile + const po = handle.repos.policies.getById("po_stuck" as PolicyRow["id"])!; + expect(po.support).toBe(0); + expect(po.status).toBe("candidate"); + expect(po.gainVersion).toBe(1); + }); + + it("never seeds unknown-owner policies (excluded from automatic mutation)", () => { + ensureEpisode(handle, "ep_1", "s_rec"); + insertTrace(handle, "tr_resolved", "ep_1", NOW, 0.5, { gainValue: 0.6 }); + // no owner fields → ownerAgentKind "unknown"; outside the repair scope + handle.repos.policies.insert(policy({ id: "po_anon" as PolicyRow["id"], status: "candidate" })); + handle.repos.tracePolicyLinks.link({ + traceId: "tr_resolved" as TraceId, + policyId: "po_anon" as PolicyRow["id"], + episodeId: "ep_1" as EpisodeId, + now: NOW, + }); + + const out = reconcile(); + expect(out.seeded).toBe(0); + expect(out.blocked).toBe(0); + expect(handle.repos.gainRepair.getByPolicy("po_anon" as PolicyRow["id"])).toBeNull(); + }); + + it("reconciles archived/missing queue rows away and ignores other owners", () => { + ensureEpisode(handle, "ep_1", "s_rec"); + insertTrace(handle, "tr_resolved", "ep_1", NOW, 0.5, { gainValue: 0.6 }); + const OWNER: Partial = { ownerAgentKind: "openclaw", ownerProfileId: "default" }; + handle.repos.policies.insert(policy({ id: "po_a" as PolicyRow["id"], status: "active", ...OWNER })); + handle.repos.policies.insert(policy({ id: "po_arch" as PolicyRow["id"], status: "archived", ...OWNER })); + handle.repos.policies.insert(policy({ + id: "po_other" as PolicyRow["id"], + status: "active", + ownerAgentKind: "hermes", + ownerProfileId: "p1", + })); + handle.repos.tracePolicyLinks.link({ + traceId: "tr_resolved" as TraceId, + policyId: "po_a" as PolicyRow["id"], + episodeId: "ep_1" as EpisodeId, + now: NOW, + }); + // A stale queue row for the archived policy must be reconciled away. + handle.repos.gainRepair.upsertPending({ + policyId: "po_arch" as PolicyRow["id"], + ownerAgentKind: "openclaw", + ownerProfileId: "default", + }); + + const out = reconcile(); + expect(out.reconciled).toBe(1); + expect(out.seeded).toBe(1); + expect(handle.repos.gainRepair.getByPolicy("po_arch" as PolicyRow["id"])).toBeNull(); + expect(handle.repos.gainRepair.getByPolicy("po_a" as PolicyRow["id"])?.state).toBe("pending"); + // hermes-owned policy is outside this owner's queue space + expect(handle.repos.gainRepair.getByPolicy("po_other" as PolicyRow["id"])).toBeNull(); + }); + + it("preserves an existing inference_refresh invalidation across the rebuild", () => { + ensureEpisode(handle, "ep_1", "s_rec"); + insertTrace(handle, "tr_resolved", "ep_1", NOW, 0.5, { gainValue: 0.6 }); + const OWNER: Partial = { ownerAgentKind: "openclaw", ownerProfileId: "default" }; + handle.repos.policies.insert(policy({ id: "po_refresh" as PolicyRow["id"], status: "candidate", ...OWNER })); + handle.repos.tracePolicyLinks.link({ + traceId: "tr_resolved" as TraceId, + policyId: "po_refresh" as PolicyRow["id"], + episodeId: "ep_1" as EpisodeId, + now: NOW, + }); + // Explicit inference-rule invalidation from a prior boot. + handle.repos.gainRepair.upsertPending({ + policyId: "po_refresh" as PolicyRow["id"], + ownerAgentKind: "openclaw", + ownerProfileId: "default", + reason: "inference_refresh", + }); + + const out = reconcile(); + expect(out.seeded).toBe(1); + const entry = handle.repos.gainRepair.getByPolicy("po_refresh" as PolicyRow["id"]); + expect(entry?.state).toBe("pending"); + // NOT downgraded to inferred_evidence_updated — the invalidation survives. + expect(entry?.reason).toBe("inference_refresh"); + }); + + it("scopes the reconcile to the exact workspace (same profile, different workspace)", () => { + ensureEpisode(handle, "ep_1", "s_rec"); + insertTrace(handle, "tr_resolved", "ep_1", NOW, 0.5, { gainValue: 0.6 }); + const OWNER_A: Partial = { + ownerAgentKind: "openclaw", + ownerProfileId: "default", + ownerWorkspaceId: "ws_a", + }; + const OWNER_B: Partial = { + ownerAgentKind: "openclaw", + ownerProfileId: "default", + ownerWorkspaceId: "ws_b", + }; + handle.repos.policies.insert(policy({ id: "po_wa" as PolicyRow["id"], status: "candidate", ...OWNER_A })); + handle.repos.policies.insert(policy({ id: "po_wb" as PolicyRow["id"], status: "candidate", ...OWNER_B })); + handle.repos.tracePolicyLinks.link({ + traceId: "tr_resolved" as TraceId, + policyId: "po_wa" as PolicyRow["id"], + episodeId: "ep_1" as EpisodeId, + now: NOW, + }); + handle.repos.tracePolicyLinks.link({ + traceId: "tr_resolved" as TraceId, + policyId: "po_wb" as PolicyRow["id"], + episodeId: "ep_1" as EpisodeId, + now: NOW, + }); + + const out = reconcile({ ownerAgentKind: "openclaw", ownerProfileId: "default", ownerWorkspaceId: "ws_a" }); + expect(out.seeded).toBe(1); + expect(handle.repos.gainRepair.getByPolicy("po_wa" as PolicyRow["id"])?.state).toBe("pending"); + // ws_b policy is outside the exact workspace → never touched + expect(handle.repos.gainRepair.getByPolicy("po_wb" as PolicyRow["id"])).toBeNull(); + + // Queue reconciliation ops are workspace-scoped too: a stale ws_b row for + // an archived policy must NOT be removed by the ws_a owner. + handle.repos.policies.insert(policy({ id: "po_arch_b" as PolicyRow["id"], status: "archived", ...OWNER_B })); + handle.repos.gainRepair.upsertPending({ + policyId: "po_arch_b" as PolicyRow["id"], + ownerAgentKind: "openclaw", + ownerProfileId: "default", + ownerWorkspaceId: "ws_b", + }); + reconcile({ ownerAgentKind: "openclaw", ownerProfileId: "default", ownerWorkspaceId: "ws_a" }); + expect(handle.repos.gainRepair.getByPolicy("po_arch_b" as PolicyRow["id"])).not.toBeNull(); + }); + + it("pushes the exact owner triple into policies.list (SQL-scoped reads)", () => { + ensureEpisode(handle, "ep_1", "s_rec"); + insertTrace(handle, "tr_resolved", "ep_1", NOW, 0.5, { gainValue: 0.6 }); + const OWNER_A: Partial = { + ownerAgentKind: "openclaw", + ownerProfileId: "default", + ownerWorkspaceId: "ws_a", + }; + handle.repos.policies.insert(policy({ id: "po_wa" as PolicyRow["id"], status: "candidate", ...OWNER_A })); + handle.repos.policies.insert(policy({ + id: "po_wb" as PolicyRow["id"], + status: "candidate", + ownerAgentKind: "openclaw", + ownerProfileId: "default", + ownerWorkspaceId: "ws_b", + })); + handle.repos.policies.insert(policy({ + id: "po_h" as PolicyRow["id"], + status: "candidate", + ownerAgentKind: "hermes", + ownerProfileId: "p1", + })); + for (const pid of ["po_wa", "po_wb", "po_h"]) { + handle.repos.tracePolicyLinks.link({ + traceId: "tr_resolved" as TraceId, + policyId: pid as PolicyRow["id"], + episodeId: "ep_1" as EpisodeId, + now: NOW, + }); + } + + // Query-level isolation: record the filters reaching policies.list. + type ListFilter = Parameters[0]; + const seen: ListFilter[] = []; + const scopedPolicies = { + ...handle.repos.policies, + list: (filter: ListFilter = {}) => { + seen.push(filter); + return handle.repos.policies.list(filter); + }, + }; + const out = reconcile( + { ownerAgentKind: "openclaw", ownerProfileId: "default", ownerWorkspaceId: "ws_a" }, + scopedPolicies, + ); + + // Both reads (candidate + active) carry the exact triple — workspace + // NULL-exact (IS semantics): same profile, different workspace never + // leaves the database. + expect(seen).toHaveLength(2); + for (const filter of seen) { + expect(filter).toMatchObject({ + ownerAgentKind: "openclaw", + ownerProfileId: "default", + ownerWorkspaceId: "ws_a", + }); + } + // Result-level isolation preserved: only the exact-workspace policy queues. + expect(out.seeded).toBe(1); + expect(handle.repos.gainRepair.getByPolicy("po_wa" as PolicyRow["id"])?.state).toBe("pending"); + expect(handle.repos.gainRepair.getByPolicy("po_wb" as PolicyRow["id"])).toBeNull(); + expect(handle.repos.gainRepair.getByPolicy("po_h" as PolicyRow["id"])).toBeNull(); + }); + + it("never treats invalid scores (non-finite / out-of-range) as resolved evidence", () => { + ensureEpisode(handle, "ep_1", "s_rec"); + insertTrace(handle, "tr_bad", "ep_1", NOW, 0.5, { gainValue: 1.5 }); + insertTrace(handle, "tr_nan", "ep_1", NOW, 0.5, { gainValue: Number.NaN }); + const OWNER: Partial = { ownerAgentKind: "openclaw", ownerProfileId: "default" }; + handle.repos.policies.insert(policy({ id: "po_bad" as PolicyRow["id"], status: "candidate", ...OWNER })); + handle.repos.tracePolicyLinks.link({ + traceId: "tr_bad" as TraceId, + policyId: "po_bad" as PolicyRow["id"], + episodeId: "ep_1" as EpisodeId, + now: NOW, + }); + + const out = reconcile(); + // 1.5 is outside [-1, 1] — same predicate the selector uses, so the + // policy must be seeded BLOCKED, never pending. + expect(out.seeded).toBe(0); + expect(out.blocked).toBe(1); + expect(handle.repos.gainRepair.getByPolicy("po_bad" as PolicyRow["id"])?.state).toBe("blocked"); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/memory/l2/subscriber.test.ts b/apps/memos-local-plugin/tests/unit/memory/l2/subscriber.test.ts index f5ac75e09..04dd24cec 100644 --- a/apps/memos-local-plugin/tests/unit/memory/l2/subscriber.test.ts +++ b/apps/memos-local-plugin/tests/unit/memory/l2/subscriber.test.ts @@ -37,6 +37,12 @@ function cfg(): L2Config { minEpisodesForInduction: 5, // keep induction off for this test inductionTraceCharCap: 2_000, gainEmaAlpha: 0.4, + gainV2Enabled: false, + minGainValue: 0.02, + gainRepairBatchSize: 0, + gainRepairIntervalMs: 900_000, + gainRepairMaxTotal: null, + gainRepairRescreenGeneration: 0, }; } diff --git a/apps/memos-local-plugin/tests/unit/pipeline/gain-repair-rpc.test.ts b/apps/memos-local-plugin/tests/unit/pipeline/gain-repair-rpc.test.ts new file mode 100644 index 000000000..2a8aa5175 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/pipeline/gain-repair-rpc.test.ts @@ -0,0 +1,975 @@ +/** + * `policies.gainPreview` + `policies.gainRollback` RPC tests. + * + * RED-GREEN: this suite was written FIRST against the spec/plan contract + * (read-only preview, policy-field CAS rollback) and run before the + * implementation existed (expected: import/method failures). The + * implementation (`core/memory/l2/gain-maintenance.ts` + contract/dispatcher/ + * core wiring) was then built to satisfy it. + * + * Strategy: boot a REAL core on a tmp DB (the gain-repair-timer pattern) so + * the tests exercise the actual memory-core wiring (live config slice, + * thresholds, exact-namespace owner), drive repair ticks through the real + * repair engine to produce journal rows, and cover dispatcher routing with a + * stub core. No Python, no Ops CLI, no deployment. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + createMemoryCore, + createPipeline, + type PipelineDeps, + type PipelineHandle, +} from "../../../core/pipeline/index.js"; +import type { + GainPreviewResult, + GainRollbackResult, + MemoryCore, +} from "../../../agent-contract/memory-core.js"; +import { RPC_METHODS } from "../../../agent-contract/jsonrpc.js"; +import { makeDispatcher } from "../../../bridge/methods.js"; +import { rootLogger } from "../../../core/logger/index.js"; +import { DEFAULT_CONFIG } from "../../../core/config/defaults.js"; +import { resolveHome } from "../../../core/config/paths.js"; +import { + GAIN_INFERENCE_VERSION, + GAIN_POST_CUTOVER_BOUNDARY_MS, +} from "../../../core/reward/gain-inference.js"; +import { + consumeGainRepairRescreen, + gainRepairBudgetKey, + gainRepairRescreenKey, + readGainRepairBudget, + reserveGainRepairAttempt, + runGainRepairTick, + configVersionOf, + type GainRepairAttemptDeps, + type GainRepairOwner, + type GainRepairTickResult, +} from "../../../core/memory/l2/gain-repair.js"; +import type { L2Config } from "../../../core/memory/l2/types.js"; +import type { + EpisodeId, + GainValueSource, + PolicyId, + PolicyRow, + SessionId, + TraceId, +} from "../../../core/types.js"; +import { makeTmpDb, type TmpDbHandle } from "../../helpers/tmp-db.js"; +import { fakeEmbedder } from "../../helpers/fake-embedder.js"; + +const NOW = 1_700_000_000_000; +const OWNER_A: GainRepairOwner = { + ownerAgentKind: "openclaw", + ownerProfileId: "default", + ownerWorkspaceId: null, +}; +const OWNER_B: GainRepairOwner = { + ownerAgentKind: "openclaw", + ownerProfileId: "other", + ownerWorkspaceId: null, +}; +const NS_A = { agentKind: "openclaw", profileId: "default" } as const; +const NS_B = { agentKind: "openclaw", profileId: "other" } as const; +const NS_UNKNOWN = { agentKind: "unknown", profileId: "default" } as const; +const THRESHOLDS = { minSupport: 2, minGain: 0.04, archiveGain: -0.05 }; + +function baseConfig(overrides: Partial = {}): L2Config { + return { + minSimilarity: 0.8, + candidateTtlDays: 30, + gamma: 0.9, + tauSoftmax: 0.5, + useLlm: true, + minTraceValue: 0.01, + minEpisodesForInduction: 1, + inductionTraceCharCap: 2_000, + gainEmaAlpha: 0.4, + gainV2Enabled: true, + minGainValue: 0.02, + gainRepairBatchSize: 25, + gainRepairIntervalMs: 900_000, + gainRepairMaxTotal: 25, + gainRepairRescreenGeneration: 0, + ...overrides, + }; +} + +function bootConfig(): typeof DEFAULT_CONFIG { + return { + ...DEFAULT_CONFIG, + algorithm: { + ...DEFAULT_CONFIG.algorithm, + l2Induction: { + ...DEFAULT_CONFIG.algorithm.l2Induction, + gainV2Enabled: true, + gainRepairBatchSize: 25, + gainRepairMaxTotal: 25, + gainRepairIntervalMs: 900_000, + gainRepairRescreenGeneration: 0, + }, + }, + }; +} + +let handle: TmpDbHandle | null = null; +let pipeline: PipelineHandle | null = null; +let core: MemoryCore | null = null; + +function buildDeps(h: TmpDbHandle, config: typeof DEFAULT_CONFIG): PipelineDeps { + return { + agent: "openclaw", + home: resolveHome("openclaw", "/tmp/memos-gr-rpc"), + config, + db: h.db, + repos: h.repos, + llm: null, + reflectLlm: null, + entityLlm: null, + l3Llm: null, + embedder: fakeEmbedder({ dimensions: 384 }), + log: rootLogger.child({ channel: "test.gain_repair_rpc" }), + namespace: { agentKind: "openclaw", profileId: "default" }, + now: () => NOW, + }; +} + +async function boot(config: typeof DEFAULT_CONFIG = bootConfig()): Promise { + handle = makeTmpDb(); + pipeline = createPipeline(buildDeps(handle, config)); + core = createMemoryCore(pipeline, resolveHome("openclaw", "/tmp/memos-gr-rpc"), "test", { + autoRecovery: false, + }); + await core.init(); +} + +afterEach(async () => { + if (core) { + try { + await core.shutdown(); + } catch { + /* ignore */ + } + core = null; + pipeline = null; + } + if (handle) { + handle.cleanup(); + handle = null; + } +}); + +interface SeedOpts { + id: string; + status?: "candidate" | "active"; + support?: number; + gain?: number; + gainVersion?: number; + title?: string; + evidenceGainValue?: number | null; + evidenceSource?: GainValueSource | null; + evidenceTs?: number; + owner?: GainRepairOwner; + queue?: "pending" | "blocked" | null; + queueReason?: "inferred_evidence_updated" | "inference_refresh" | null; +} + +/** Seed one policy + one evidence trace + link (+ queue entry), mirroring the engine tests. */ +function seedPolicy(opts: SeedOpts): void { + const h = handle!; + const owner = opts.owner ?? OWNER_A; + const episodeId = `ep_${opts.id}`; + const sessionId = owner.ownerProfileId === "other" ? "s_other" : "s_rec"; + const traceId = `tr_${opts.id}`; + if (!h.repos.sessions.getById(sessionId as SessionId)) { + h.repos.sessions.upsert({ + id: sessionId as SessionId, + agent: "openclaw", + startedAt: NOW, + lastSeenAt: NOW, + meta: {}, + }); + } + if (!h.repos.episodes.getById(episodeId as EpisodeId)) { + h.repos.episodes.insert({ + id: episodeId as EpisodeId, + sessionId: sessionId as SessionId, + startedAt: NOW, + endedAt: NOW, + status: "closed", + rTask: null, + traceIds: [], + meta: {}, + // Production-consistent ownership: the inference screen rejects + // episode/trace owner mismatches as mixed_ownership, so the episode + // carries the same owner as its traces. + ownerAgentKind: owner.ownerAgentKind, + ownerProfileId: owner.ownerProfileId, + ownerWorkspaceId: owner.ownerWorkspaceId ?? null, + }); + } + const gainValue = opts.evidenceGainValue === undefined ? 0.6 : opts.evidenceGainValue; + const source: GainValueSource | null = + opts.evidenceSource !== undefined + ? opts.evidenceSource + : gainValue == null + ? null + : "inferred_normalized"; + h.repos.traces.insert({ + id: traceId as TraceId, + episodeId: episodeId as EpisodeId, + sessionId: sessionId as SessionId, + ts: opts.evidenceTs ?? NOW, + userText: "", + agentText: "", + toolCalls: [], + reflection: null, + value: gainValue ?? 0.5, + alpha: 0.5, + rHuman: 0.5, + priority: 0, + tags: [], + vecSummary: null, + vecAction: null, + turnId: 0, + schemaVersion: 1, + gainValue, + gainValueSource: source, + gainInferenceVersion: GAIN_INFERENCE_VERSION, + ownerAgentKind: owner.ownerAgentKind, + ownerProfileId: owner.ownerProfileId, + ownerWorkspaceId: owner.ownerWorkspaceId ?? null, + }); + h.repos.episodes.appendTrace(episodeId as EpisodeId, [traceId]); + h.repos.policies.insert({ + id: opts.id as PolicyId, + title: opts.title ?? `title ${opts.id}`, + trigger: "tr", + procedure: "p", + verification: "v", + boundary: "b", + support: opts.support ?? 0, + gain: opts.gain ?? 0, + gainVersion: opts.gainVersion ?? 1, + status: opts.status ?? "candidate", + sourceEpisodeIds: [], + inducedBy: "unit", + decisionGuidance: { preference: [], antiPattern: [] }, + vec: null, + createdAt: NOW, + updatedAt: NOW, + sourceTraceIds: [traceId as TraceId], + ownerAgentKind: owner.ownerAgentKind, + ownerProfileId: owner.ownerProfileId, + ownerWorkspaceId: owner.ownerWorkspaceId ?? null, + } as PolicyRow); + h.repos.tracePolicyLinks.link({ + traceId: traceId as TraceId, + policyId: opts.id as PolicyId, + episodeId: episodeId as EpisodeId, + now: NOW, + }); + const queue = opts.queue === undefined ? "pending" : opts.queue; + if (queue === "pending") { + h.repos.gainRepair.upsertPending({ + policyId: opts.id as PolicyId, + ownerAgentKind: owner.ownerAgentKind, + ownerProfileId: owner.ownerProfileId, + ownerWorkspaceId: owner.ownerWorkspaceId ?? null, + reason: opts.queueReason ?? "inferred_evidence_updated", + inferenceVersion: GAIN_INFERENCE_VERSION, + now: NOW, + }); + } else if (queue === "blocked") { + h.repos.gainRepair.upsertBlocked({ + policyId: opts.id as PolicyId, + ownerAgentKind: owner.ownerAgentKind, + ownerProfileId: owner.ownerProfileId, + ownerWorkspaceId: owner.ownerWorkspaceId ?? null, + reason: opts.queueReason ?? "inferred_evidence_updated", + blockedReason: "no_resolved_with", + inferenceVersion: GAIN_INFERENCE_VERSION, + now: NOW, + }); + } +} + +function engineDeps( + h: TmpDbHandle, + config: L2Config, + owner: GainRepairOwner, + extra: Partial = {}, +): GainRepairAttemptDeps { + return { + db: h.db, + repos: h.repos, + config, + owner, + thresholds: THRESHOLDS, + log: rootLogger.child({ channel: "test.gain_repair_rpc" }), + now: () => NOW, + inferenceVersion: GAIN_INFERENCE_VERSION, + ...extra, + }; +} + +function preConsumeRescreen(h: TmpDbHandle, owner: GainRepairOwner): void { + h.repos.kv.set(gainRepairRescreenKey(owner), { + generation: 0, + inferenceVersion: GAIN_INFERENCE_VERSION, + consumedAt: NOW, + }); +} + +/** One real engine tick (pre-consumed rescreen so selection is purely queue-driven). */ +function tick( + h: TmpDbHandle, + owner: GainRepairOwner, + overrides: Partial = {}, +): GainRepairTickResult { + preConsumeRescreen(h, owner); + return runGainRepairTick(engineDeps(h, baseConfig(overrides), owner)); +} + +const SNAPSHOT_TABLES = [ + "policies", + "traces", + "episodes", + "trace_policy_links", + "gain_repair_queue", + "gain_repair_journal", + "kv", + "sessions", +]; + +function snapshot(h: TmpDbHandle): string { + return JSON.stringify( + SNAPSHOT_TABLES.map((t) => h.db.raw.prepare(`SELECT * FROM "${t}" ORDER BY rowid`).all()), + ); +} + +// ─── policies.gainPreview ──────────────────────────────────────────────────── + +describe("policies.gainPreview (read-only)", () => { + it("repeated preview leaves all tables byte-identical", async () => { + await boot(); + const h = handle!; + seedPolicy({ id: "po_pv1", support: 2 }); + seedPolicy({ id: "po_pv2", status: "active", support: 3, gain: 0.1, gainVersion: 2 }); + const before = snapshot(h); + const first = (await core!.previewGainRepair({ namespace: { ...NS_A } })) as GainPreviewResult; + const mid = snapshot(h); + const second = (await core!.previewGainRepair({ namespace: { ...NS_A } })) as GainPreviewResult; + const after = snapshot(h); + // ZERO writes: every table byte-identical across both previews. + expect(mid).toBe(before); + expect(after).toBe(before); + expect(second).toEqual(first); + expect(first.total).toBe(2); + }); + + it("ranks candidates by proposed gain desc, support desc, ID asc with stable pagination", async () => { + await boot(); + seedPolicy({ id: "po_tie_b", evidenceGainValue: 0.5, support: 3 }); + seedPolicy({ id: "po_tie_a", evidenceGainValue: 0.5, support: 3 }); + seedPolicy({ id: "po_mid", evidenceGainValue: 0.5, support: 5 }); + seedPolicy({ id: "po_hi", evidenceGainValue: 0.9, support: 0 }); + seedPolicy({ id: "po_act", status: "active", support: 5, evidenceGainValue: 0.9 }); + const full = (await core!.previewGainRepair({ + namespace: { ...NS_A }, + limit: 50, + })) as GainPreviewResult; + expect(full.total).toBe(5); + const order = full.policies.map((p) => p.policyId); + // Candidates first (proposed gain desc, then support desc, then ID asc), + // actives sort after every candidate. + expect(order).toEqual(["po_hi", "po_mid", "po_tie_a", "po_tie_b", "po_act"]); + const page1 = (await core!.previewGainRepair({ + namespace: { ...NS_A }, + limit: 2, + offset: 0, + })) as GainPreviewResult; + const page2 = (await core!.previewGainRepair({ + namespace: { ...NS_A }, + limit: 2, + offset: 2, + })) as GainPreviewResult; + const page3 = (await core!.previewGainRepair({ + namespace: { ...NS_A }, + limit: 2, + offset: 4, + })) as GainPreviewResult; + expect(page1.policies.map((p) => p.policyId)).toEqual(["po_hi", "po_mid"]); + expect(page2.policies.map((p) => p.policyId)).toEqual(["po_tie_a", "po_tie_b"]); + expect(page3.policies.map((p) => p.policyId)).toEqual(["po_act"]); + expect(page1.total).toBe(5); + // Deterministic across calls. + const again = (await core!.previewGainRepair({ + namespace: { ...NS_A }, + limit: 50, + })) as GainPreviewResult; + expect(again.policies.map((p) => p.policyId)).toEqual(order); + }); + + it("scopes exactly to the requested namespace — nothing leaks across owners", async () => { + await boot(); + seedPolicy({ id: "po_a1", owner: OWNER_A }); + seedPolicy({ id: "po_b1", owner: OWNER_B }); + const a = (await core!.previewGainRepair({ namespace: { ...NS_A } })) as GainPreviewResult; + const b = (await core!.previewGainRepair({ namespace: { ...NS_B } })) as GainPreviewResult; + expect(a.policies.map((p) => p.policyId)).toEqual(["po_a1"]); + expect(b.policies.map((p) => p.policyId)).toEqual(["po_b1"]); + expect(a.queue.pending).toBe(1); + expect(b.queue.pending).toBe(1); + }); + + it("rejects a missing namespace instead of guessing one", async () => { + await boot(); + await expect(core!.previewGainRepair({} as never)).rejects.toMatchObject({ + code: "invalid_argument", + }); + await expect(core!.rollbackGainRepair({ batchId: "gr_nope" } as never)).rejects.toMatchObject({ + code: "invalid_argument", + }); + }); + + it("reports proposed transitions, skip reasons, queue state and budget readback", async () => { + await boot(); + const h = handle!; + // Qualifying candidate (support 2, strong evidence) → promote proposal. + seedPolicy({ id: "po_qual", support: 2, evidenceGainValue: 0.8 }); + // Weak candidate → retained. + seedPolicy({ id: "po_weak", support: 0, evidenceGainValue: 0.01 }); + // Active → retained (repair never archives). + seedPolicy({ id: "po_keep", status: "active", support: 4, gain: 0.2, gainVersion: 2 }); + // Blocked queue entry surfaces its state. + seedPolicy({ id: "po_blk", queue: "blocked", evidenceGainValue: null }); + const res = (await core!.previewGainRepair({ namespace: { ...NS_A } })) as GainPreviewResult; + const byId = new Map(res.policies.map((p) => [p.policyId, p])); + expect(byId.get("po_qual")!.proposedTransition).toBe("promote_to_active"); + expect(byId.get("po_qual")!.skipReason).toBeNull(); + expect(byId.get("po_qual")!.newGainVersion).toBe(2); + expect(byId.get("po_qual")!.queue!.state).toBe("pending"); + expect(byId.get("po_weak")!.proposedTransition).toBe("retain_candidate"); + expect(byId.get("po_keep")!.proposedTransition).toBe("retain_active"); + const blk = byId.get("po_blk")!; + expect(blk.skipReason).toBe("no_resolved_with"); + expect(blk.proposedTransition).toBe("none"); + expect(blk.queue!.state).toBe("blocked"); + // Budget readback before any tick: uninitialized counter, live limit. + expect(res.budget).toEqual({ attempted: 0, limit: 25, remaining: 25, initialized: false }); + expect(res.inferenceVersion).toBe(GAIN_INFERENCE_VERSION); + // After one budgeted tick the same readback reflects the durable counter. + const tickRes = tick(h, OWNER_A); + expect(tickRes.attempted).toBeGreaterThan(0); + const after = (await core!.previewGainRepair({ namespace: { ...NS_A } })) as GainPreviewResult; + expect(after.budget.attempted).toBe(tickRes.attempted); + expect(after.budget.limit).toBe(25); + expect(after.budget.remaining).toBe(25 - tickRes.attempted); + }); + + it("reports unknown-owner policies as skips without mutating them", async () => { + await boot(); + const h = handle!; + seedPolicy({ + id: "po_uo", + owner: { ownerAgentKind: "unknown", ownerProfileId: "default", ownerWorkspaceId: null }, + }); + const before = snapshot(h); + const res = (await core!.previewGainRepair({ + namespace: { ...NS_UNKNOWN }, + })) as GainPreviewResult; + expect(res.policies.map((p) => p.policyId)).toEqual(["po_uo"]); + expect(res.policies[0]!.skipReason).toBe("unknown_owner"); + expect(res.policies[0]!.unknownOwner).toBe(true); + expect(snapshot(h)).toBe(before); + }); + + it("summarizes post-cutover legacy and unknown-chronology cohorts", async () => { + await boot(); + // One legacy trace per cohort episode; unlinked so they touch no policy entry. + seedPolicy({ + id: "po_leg_post", + evidenceGainValue: 0.4, + evidenceSource: "legacy_unscaled", + evidenceTs: GAIN_POST_CUTOVER_BOUNDARY_MS + 86_400_000, + queue: null, + }); + seedPolicy({ + id: "po_leg_pre", + evidenceGainValue: 0.4, + evidenceSource: "legacy_unscaled", + evidenceTs: GAIN_POST_CUTOVER_BOUNDARY_MS - 86_400_000, + queue: null, + }); + seedPolicy({ + id: "po_leg_unk", + evidenceGainValue: 0.4, + evidenceSource: "legacy_unscaled", + evidenceTs: 0, + queue: null, + }); + const res = (await core!.previewGainRepair({ namespace: { ...NS_A } })) as GainPreviewResult; + expect(res.legacy.groups).toBe(3); + expect(res.legacy.traces).toBe(3); + expect(res.legacy.postCutoverGroups).toBe(1); + expect(res.legacy.postCutoverTraces).toBe(1); + expect(res.legacy.unknownChronologyGroups).toBe(1); + expect(res.legacy.unknownChronologyTraces).toBe(1); + }); +}); + +// ─── policies.gainRollback ─────────────────────────────────────────────────── + +describe("policies.gainRollback (policy-field CAS)", () => { + it("restores gain/version/status on CAS match, preserves support/evidence, never refunds budget", async () => { + await boot(); + const h = handle!; + seedPolicy({ id: "po_rb", support: 3, gain: 0.05, evidenceGainValue: 0.8 }); + const tickRes = tick(h, OWNER_A); + expect(tickRes.attempted).toBe(1); + const batchId = tickRes.batchId; + const rows = h.repos.gainRepair.listJournalByBatch(batchId); + expect(rows).toHaveLength(1); + const row = rows[0]!; + expect(row.result).toBe("completed"); + expect(row.newStatus).toBe("active"); + const repaired = h.repos.policies.getById("po_rb" as PolicyId)!; + expect(repaired.status).toBe("active"); + expect(repaired.gainVersion).toBe(2); + const budgetBefore = readGainRepairBudget(h.repos.kv, OWNER_A, 25); + const tracesBefore = JSON.stringify( + h.db.raw.prepare(`SELECT * FROM "traces" ORDER BY rowid`).all(), + ); + const out = (await core!.rollbackGainRepair({ + namespace: { ...NS_A }, + batchId, + })) as GainRollbackResult; + expect(out.ok).toBe(true); + if (!out.ok) return; + expect(out.rolledBack).toEqual([{ journalId: row.id, policyId: "po_rb" }]); + const after = h.repos.policies.getById("po_rb" as PolicyId)!; + // Repair-owned fields restored to the journaled pre-repair values… + expect(after.gain).toBe(0.05); + expect(after.gainVersion).toBe(1); + expect(after.status).toBe("candidate"); + // …with a FRESH updated_at (never a historical timestamp)… + expect(after.updatedAt).toBeGreaterThan(repaired.updatedAt); + // …support preserved and no trace/link touched… + expect(after.support).toBe(3); + expect(JSON.stringify(h.db.raw.prepare(`SELECT * FROM "traces" ORDER BY rowid`).all())).toBe( + tracesBefore, + ); + // …budget never refunded… + const budgetAfter = readGainRepairBudget(h.repos.kv, OWNER_A, 25); + expect(budgetAfter.attempted).toBe(budgetBefore.attempted); + // …journal marked rolled_back and the queue entry parked blocked atomically. + expect(h.repos.gainRepair.listJournalByBatch(batchId)[0]!.result).toBe("rolled_back"); + const entry = h.repos.gainRepair.getByPolicy("po_rb" as PolicyId)!; + expect(entry.state).toBe("blocked"); + expect(entry.blockedReason).toBe("rolled_back"); + }); + + it.each([ + ["support", { support: 4 }], + ["gain", { gain: 0.99 }], + ["status", { status: "archived" }], + ["gain_version", { gainVersion: 1 }], + ["updated_at", { bumpUpdatedAt: true }], + ])( + "rejects the whole batch with zero writes on a newer %s write", + async (_field, patch) => { + await boot(); + const h = handle!; + seedPolicy({ id: "po_cf", support: 3, gain: 0.05, evidenceGainValue: 0.8 }); + seedPolicy({ id: "po_cf2", support: 3, gain: 0.05, evidenceGainValue: 0.8 }); + const tickRes = tick(h, OWNER_A); + expect(tickRes.attempted).toBe(2); + const repaired = h.repos.policies.getById("po_cf" as PolicyId)!; + // A newer writer touches exactly one CAS field of the first policy. + h.repos.policies.updateStats("po_cf" as PolicyId, { + support: (patch as { support?: number }).support ?? repaired.support, + gain: (patch as { gain?: number }).gain ?? repaired.gain, + gainVersion: (patch as { gainVersion?: number }).gainVersion ?? repaired.gainVersion!, + status: ((patch as { status?: string }).status ?? repaired.status) as PolicyRow["status"], + updatedAt: + (patch as { bumpUpdatedAt?: boolean }).bumpUpdatedAt === true + ? repaired.updatedAt + 5 + : repaired.updatedAt, + }); + const before = snapshot(h); + const budgetBefore = readGainRepairBudget(h.repos.kv, OWNER_A, 25); + const out = (await core!.rollbackGainRepair({ + namespace: { ...NS_A }, + batchId: tickRes.batchId, + })) as GainRollbackResult; + expect(out.ok).toBe(false); + if (out.ok) return; + expect(out.conflicts).toHaveLength(1); + expect(out.conflicts[0]!.policyId).toBe("po_cf"); + // ZERO writes: the untouched second policy is NOT rolled back either. + expect(snapshot(h)).toBe(before); + expect(readGainRepairBudget(h.repos.kv, OWNER_A, 25).attempted).toBe( + budgetBefore.attempted, + ); + expect(h.repos.policies.getById("po_cf2" as PolicyId)!.gainVersion).toBe(2); + }, + ); + + it("allows rollback when only trace/link evidence changed", async () => { + await boot(); + const h = handle!; + seedPolicy({ id: "po_ev", support: 3, gain: 0.05, evidenceGainValue: 0.8 }); + const tickRes = tick(h, OWNER_A); + const repaired = h.repos.policies.getById("po_ev" as PolicyId)!; + // Newer evidence arrives (trace + link) but no policy field is rewritten. + h.repos.traces.insert({ + id: "tr_ev_new" as TraceId, + episodeId: "ep_po_ev" as EpisodeId, + sessionId: "s_rec" as SessionId, + ts: NOW + 1, + userText: "", + agentText: "", + toolCalls: [], + reflection: null, + value: 0.7, + alpha: 0.5, + rHuman: 0.5, + priority: 0, + tags: [], + vecSummary: null, + vecAction: null, + turnId: 0, + schemaVersion: 1, + gainValue: 0.7, + gainValueSource: "live_normalized", + gainInferenceVersion: GAIN_INFERENCE_VERSION, + ownerAgentKind: OWNER_A.ownerAgentKind, + ownerProfileId: OWNER_A.ownerProfileId, + ownerWorkspaceId: null, + }); + h.repos.episodes.appendTrace("ep_po_ev" as EpisodeId, ["tr_ev_new"]); + h.repos.tracePolicyLinks.link({ + traceId: "tr_ev_new" as TraceId, + policyId: "po_ev" as PolicyId, + episodeId: "ep_po_ev" as EpisodeId, + now: NOW + 1, + }); + const out = (await core!.rollbackGainRepair({ + namespace: { ...NS_A }, + batchId: tickRes.batchId, + })) as GainRollbackResult; + expect(out.ok).toBe(true); + const after = h.repos.policies.getById("po_ev" as PolicyId)!; + expect(after.gain).toBe(0.05); + expect(after.status).toBe("candidate"); + // The newer evidence itself is never reverted. + expect(h.repos.traces.getGainRowsByIds(["tr_ev_new"]).length).toBe(1); + expect(repaired.support).toBe(after.support); + }); + + it("rejects cross-namespace rollback even when fields would match (nothing leaked)", async () => { + await boot(); + const h = handle!; + seedPolicy({ id: "po_ns", support: 3, gain: 0.05, evidenceGainValue: 0.8 }); + const tickRes = tick(h, OWNER_A); + const row = h.repos.gainRepair.listJournalByBatch(tickRes.batchId)[0]!; + const before = snapshot(h); + // Batch scope: a foreign namespace sees no such batch. + await expect( + core!.rollbackGainRepair({ namespace: { ...NS_B }, batchId: tickRes.batchId }), + ).rejects.toMatchObject({ code: "invalid_argument" }); + expect(snapshot(h)).toBe(before); + // Explicit IDs: rejected as forbidden WITHOUT echoing the policy id. + const out = (await core!.rollbackGainRepair({ + namespace: { ...NS_B }, + journalIds: [row.id], + })) as GainRollbackResult; + expect(out.ok).toBe(false); + if (out.ok) return; + expect(out.conflicts).toHaveLength(1); + expect(out.conflicts[0]!.journalId).toBe(row.id); + expect(out.conflicts[0]!.policyId).toBeNull(); + expect(snapshot(h)).toBe(before); + // The owning namespace can still roll back afterwards. + const ok = (await core!.rollbackGainRepair({ + namespace: { ...NS_A }, + batchId: tickRes.batchId, + })) as GainRollbackResult; + expect(ok.ok).toBe(true); + }); + + it("rejects a mixed-ownership batch as a whole without partial rollback", async () => { + await boot(); + const h = handle!; + seedPolicy({ id: "po_mix", support: 3, gain: 0.05, evidenceGainValue: 0.8 }); + const tickRes = tick(h, OWNER_A); + // Foreign completed row sharing the same batch: eligible on its own + // merits, so only the cross-namespace conflict can block the batch. + // (Seeded as a real B-owned policy: the FK requires it, and its + // untouched state afterwards proves nothing leaked across namespaces.) + seedPolicy({ id: "po_mix_foreign", owner: OWNER_B, support: 2, gain: 0.1, evidenceGainValue: 0.8 }); + h.repos.gainRepair.insertJournal({ + id: "jj_mix_foreign", + batchId: tickRes.batchId, + ownerAgentKind: OWNER_B.ownerAgentKind, + ownerProfileId: OWNER_B.ownerProfileId, + ownerWorkspaceId: null, + policyId: "po_mix_foreign" as PolicyId, + oldGain: 0, + newGain: 0.5, + oldGainVersion: 1, + newGainVersion: 2, + oldStatus: "candidate", + newStatus: "active", + oldSupport: 2, + newSupport: 2, + algorithmVersion: "gain-repair.v1", + configVersion: "test", + inferenceVersion: GAIN_INFERENCE_VERSION, + provenance: [], + excludedWithCount: 0, + excludedWithoutCount: 0, + result: "completed", + createdAt: NOW, + newUpdatedAt: NOW, + }); + const before = snapshot(h); + const out = (await core!.rollbackGainRepair({ + namespace: { ...NS_A }, + batchId: tickRes.batchId, + })) as GainRollbackResult; + expect(out.ok).toBe(false); + if (out.ok) return; + // The foreign row blocks the batch without leaking its policy linkage, + // and the own row is NOT partially applied. + const foreign = out.conflicts.find((c) => c.journalId === "jj_mix_foreign"); + expect(foreign).toMatchObject({ policyId: null }); + expect(snapshot(h)).toBe(before); + }); + + it("rolls back by explicit journal IDs and refuses double rollback", async () => { + await boot(); + const h = handle!; + seedPolicy({ id: "po_ids", support: 3, gain: 0.05, evidenceGainValue: 0.8 }); + const tickRes = tick(h, OWNER_A); + const row = h.repos.gainRepair.listJournalByBatch(tickRes.batchId)[0]!; + const first = (await core!.rollbackGainRepair({ + namespace: { ...NS_A }, + journalIds: [row.id], + })) as GainRollbackResult; + expect(first.ok).toBe(true); + const before = snapshot(h); + const second = (await core!.rollbackGainRepair({ + namespace: { ...NS_A }, + journalIds: [row.id], + })) as GainRollbackResult; + expect(second.ok).toBe(false); + if (second.ok) return; + expect(second.conflicts[0]!.reason).toBe("not_rollback_eligible"); + expect(snapshot(h)).toBe(before); + }); + + it("refuses non-completed and pre-timestamp journal rows", async () => { + await boot(); + const h = handle!; + seedPolicy({ id: "po_nc", queue: "blocked", evidenceGainValue: null }); + h.repos.gainRepair.insertJournal({ + id: "jj_blocked", + batchId: "gr_manual", + ownerAgentKind: OWNER_A.ownerAgentKind, + ownerProfileId: OWNER_A.ownerProfileId, + ownerWorkspaceId: null, + policyId: "po_nc" as PolicyId, + oldGain: 0, + newGain: null, + oldGainVersion: 1, + newGainVersion: null, + oldStatus: "candidate", + newStatus: null, + oldSupport: 0, + newSupport: null, + algorithmVersion: "gain-repair.v1", + configVersion: "test", + inferenceVersion: GAIN_INFERENCE_VERSION, + provenance: [], + excludedWithCount: 1, + excludedWithoutCount: 0, + result: "blocked", + createdAt: NOW, + newUpdatedAt: null, + }); + // A completed row journaled WITHOUT the post-write timestamp (NULL + // new_updated_at) is not CAS-safe and must be refused. + h.repos.gainRepair.insertJournal({ + id: "jj_legacy", + batchId: "gr_manual", + ownerAgentKind: OWNER_A.ownerAgentKind, + ownerProfileId: OWNER_A.ownerProfileId, + ownerWorkspaceId: null, + policyId: "po_nc" as PolicyId, + oldGain: 0, + newGain: 0.5, + oldGainVersion: 1, + newGainVersion: 2, + oldStatus: "candidate", + newStatus: "active", + oldSupport: 2, + newSupport: 2, + algorithmVersion: "gain-repair.v1", + configVersion: "test", + inferenceVersion: GAIN_INFERENCE_VERSION, + provenance: [], + excludedWithCount: 0, + excludedWithoutCount: 0, + result: "completed", + createdAt: NOW, + newUpdatedAt: null, + }); + const before = snapshot(h); + const out = (await core!.rollbackGainRepair({ + namespace: { ...NS_A }, + journalIds: ["jj_blocked", "jj_legacy"], + })) as GainRollbackResult; + expect(out.ok).toBe(false); + if (out.ok) return; + expect(out.conflicts.map((c) => c.journalId).sort()).toEqual(["jj_blocked", "jj_legacy"]); + expect(snapshot(h)).toBe(before); + }); + + it("resumes rolled-back entries through the config re-screen generation", async () => { + await boot(); + const h = handle!; + seedPolicy({ id: "po_rs", support: 3, gain: 0.05, evidenceGainValue: 0.8 }); + const tickRes = tick(h, OWNER_A); + const rolled = (await core!.rollbackGainRepair({ + namespace: { ...NS_A }, + batchId: tickRes.batchId, + })) as GainRollbackResult; + expect(rolled.ok).toBe(true); + const budgetBefore = readGainRepairBudget(h.repos.kv, OWNER_A, 25); + // Bumping the re-screen generation requeues the rolled-back entry as a + // NEW budgeted attempt (never a refund: the counter only advances). + const rescreen = consumeGainRepairRescreen( + engineDeps(h, baseConfig({ gainRepairRescreenGeneration: 1 }), OWNER_A), + ); + expect(rescreen.consumed).toBe(true); + expect(rescreen.requeued).toBe(1); + expect(h.repos.gainRepair.getByPolicy("po_rs" as PolicyId)!.state).toBe("pending"); + expect(readGainRepairBudget(h.repos.kv, OWNER_A, 25).attempted).toBe( + budgetBefore.attempted, + ); + }); +}); + +// ─── Drive-by polish (Gate 3 notes) ───────────────────────────────────────── + +describe("drive-by polish", () => { + it("configVersionOf fingerprints batch/maxTotal/interval/generation", () => { + const fp = configVersionOf(baseConfig()); + expect(fp).toContain("v2=1"); + expect(fp).toContain("ema=0.4"); + expect(fp).toContain("minGain=0.02"); + expect(fp).toContain("batch=25"); + expect(fp).toContain("maxTotal=25"); + expect(fp).toContain("interval=900000"); + expect(fp).toContain("gen=0"); + }); + + it("corrupt budget JSON fails closed: no reset, no attempts, counter untouched", async () => { + await boot(); + const h = handle!; + seedPolicy({ id: "po_corrupt", support: 3, evidenceGainValue: 0.8 }); + h.repos.kv.set(gainRepairBudgetKey(OWNER_A), { attempted: "25", initializedAt: NOW }); + const storedBefore = h.repos.kv.get(gainRepairBudgetKey(OWNER_A), null); + const res = tick(h, OWNER_A); + expect(res.attempted).toBe(0); + // The corrupt value is never overwritten with a fresh counter… + expect(h.repos.kv.get(gainRepairBudgetKey(OWNER_A), null)).toEqual(storedBefore); + // …the policy is untouched and no journal row was written… + expect(h.repos.policies.getById("po_corrupt" as PolicyId)!.gainVersion).toBe(1); + expect(h.repos.gainRepair.listJournalByBatch(res.batchId)).toHaveLength(0); + // …and the readback reports no remaining budget (fail closed). + const readback = readGainRepairBudget(h.repos.kv, OWNER_A, 25); + expect(readback.remaining).toBe(0); + expect(readback.initialized).toBe(true); + }); + + it("reserve refuses another namespace's queue entry without consuming budget", async () => { + await boot(); + const h = handle!; + seedPolicy({ id: "po_foreign", owner: OWNER_B }); + preConsumeRescreen(h, OWNER_A); + const before = h.repos.kv.get(gainRepairBudgetKey(OWNER_A), null); + const out = reserveGainRepairAttempt( + engineDeps(h, baseConfig(), OWNER_A), + "po_foreign" as PolicyId, + "gr_probe", + ); + expect(out.kind).toBe("not_pending"); + expect(h.repos.kv.get(gainRepairBudgetKey(OWNER_A), null)).toEqual(before); + expect(h.repos.gainRepair.getByPolicy("po_foreign" as PolicyId)!.state).toBe("pending"); + }); +}); + +// ─── Dispatcher routing ────────────────────────────────────────────────────── + +describe("gain maintenance dispatcher routing", () => { + const PREVIEW_RESULT = { total: 0 } as unknown as GainPreviewResult; + const ROLLBACK_RESULT = { + ok: true, + batchId: null, + rolledBack: [], + rolledBackAt: 0, + } as GainRollbackResult; + + function stubDispatch() { + const stub = { + previewGainRepair: vi.fn(async () => PREVIEW_RESULT), + rollbackGainRepair: vi.fn(async () => ROLLBACK_RESULT), + } as unknown as MemoryCore; + return { stub, dispatch: makeDispatcher(stub) }; + } + + it("routes policies.gainPreview with an exact namespace and pagination", async () => { + const { stub, dispatch } = stubDispatch(); + const out = await dispatch(RPC_METHODS.POLICIES_GAIN_PREVIEW, { + namespace: { agentKind: "openclaw", profileId: "default" }, + limit: 10, + offset: 5, + }); + expect(out).toBe(PREVIEW_RESULT); + expect(stub.previewGainRepair).toHaveBeenCalledWith({ + namespace: { agentKind: "openclaw", profileId: "default" }, + limit: 10, + offset: 5, + }); + }); + + it("rejects preview/rollback without an exact namespace", async () => { + const { dispatch } = stubDispatch(); + await expect(dispatch(RPC_METHODS.POLICIES_GAIN_PREVIEW, {})).rejects.toMatchObject({ + code: "invalid_argument", + }); + await expect( + dispatch(RPC_METHODS.POLICIES_GAIN_ROLLBACK, { batchId: "gr_1" }), + ).rejects.toMatchObject({ code: "invalid_argument" }); + }); + + it("routes policies.gainRollback by batch or IDs, never both/neither", async () => { + const { stub, dispatch } = stubDispatch(); + const ns = { namespace: { agentKind: "openclaw", profileId: "default" } }; + await dispatch(RPC_METHODS.POLICIES_GAIN_ROLLBACK, { ...ns, batchId: "gr_1" }); + expect(stub.rollbackGainRepair).toHaveBeenCalledWith({ ...ns, batchId: "gr_1" }); + await dispatch(RPC_METHODS.POLICIES_GAIN_ROLLBACK, { ...ns, journalIds: ["jj_1"] }); + expect(stub.rollbackGainRepair).toHaveBeenCalledWith({ ...ns, journalIds: ["jj_1"] }); + await expect(dispatch(RPC_METHODS.POLICIES_GAIN_ROLLBACK, ns)).rejects.toMatchObject({ + code: "invalid_argument", + }); + await expect( + dispatch(RPC_METHODS.POLICIES_GAIN_ROLLBACK, { ...ns, batchId: "gr_1", journalIds: [] }), + ).rejects.toMatchObject({ code: "invalid_argument" }); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/pipeline/gain-repair-task6-drill.test.ts b/apps/memos-local-plugin/tests/unit/pipeline/gain-repair-task6-drill.test.ts new file mode 100644 index 000000000..e72396119 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/pipeline/gain-repair-task6-drill.test.ts @@ -0,0 +1,614 @@ +/** + * Offline verification drill (read-only simulation + snapshot + * migration/inference/restart + both-modes + timer throughput). + * + * OFFLINE ONLY. Every test builds a GENERATED scratch DB under os.tmpdir() + * (via makeTmpDb / raw openDb) and never touches a live host DB — no real + * snapshot was available in this lane, so the drill runs on generated scratch + * data and says so. Nothing here writes outside its own tmp dir; nothing is a + * permanent product surface (no src/ changes, assertions only). + * + * Drill legs: + * (a) FRESH historical simulation refresh: trace_ids_json grouping (with a + * duplicated ID proving distinct-S), both inferred provenances + * (inferred_normalized + legacy_unscaled), NULL exclusion, exact-owner + * handling (foreign-owner episode untouched), orphan + malformed-JSON + * reporting. Totals are recorded fresh below — the old 305 projection + * is NOT reused. + * (b) Snapshot schema-migration timing + inference/restart drill incl. + * restart idempotence (second pass stamps nothing). + * (c) Both modes (gainV2Enabled on/off) behavior check on the repair tick. + * (d) Timer throughput/latency measured on the snapshot: 100 attempts/hour + * is nominal capacity (batch 25 per 900 s interval), NOT a guarantee — + * the drill records the measured wall rate, per-attempt latency and + * backlog behavior. + * (e) Unchanged V/priority everywhere; unchanged startup policy fields + * after the migration+inference path (repair ticks may only move + * gain/gain_version/status/updated_at — support and all content fields + * are preserved). + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + GAIN_INFERENCE_VERSION, + runGainInference, +} from "../../../core/reward/gain-inference.js"; +import { + gainRepairRescreenKey, + runGainRepairTick, + type GainRepairAttemptDeps, + type GainRepairOwner, +} from "../../../core/memory/l2/gain-repair.js"; +import { selectAndComputeGain } from "../../../core/memory/l2/recompute-gain.js"; +import type { GainEvidenceTrace } from "../../../core/memory/l2/recompute-gain.js"; +import type { L2Config } from "../../../core/memory/l2/types.js"; +import { + makeRepos, + openDb, + runMigrations, +} from "../../../core/storage/index.js"; +import { rootLogger } from "../../../core/logger/index.js"; +import type { + EpisodeId, + EpochMs, + PolicyId, + PolicyRow, + SessionId, + TraceId, + TraceRow, +} from "../../../core/types.js"; +import type { GainValueSource } from "../../../core/types.js"; +import { makeTmpDb, type TmpDbHandle } from "../../helpers/tmp-db.js"; + +const NOW = 1_700_000_000_000 as EpochMs; // 2023 — pre-cutover, so legacy groups are not post-cutover. +const OWNER: GainRepairOwner = { + ownerAgentKind: "openclaw", + ownerProfileId: "default", + ownerWorkspaceId: null, +}; +const THRESHOLDS = { minSupport: 2, minGain: 0.04, archiveGain: -0.05 }; + +function baseConfig(overrides: Partial = {}): L2Config { + return { + minSimilarity: 0.8, + candidateTtlDays: 30, + gamma: 0.9, + tauSoftmax: 0.5, + useLlm: true, + minTraceValue: 0.01, + minEpisodesForInduction: 1, + inductionTraceCharCap: 2_000, + gainEmaAlpha: 0.4, + gainV2Enabled: true, + minGainValue: 0.02, + gainRepairBatchSize: 25, + gainRepairIntervalMs: 900_000, + gainRepairMaxTotal: 25, + gainRepairRescreenGeneration: 0, + ...overrides, + }; +} + +// ─── Scratch seeding (generated data, tmp DBs only) ────────────────────────── + +function seedTrace( + h: TmpDbHandle, + id: string, + eid: string, + partial: Partial = {}, +): void { + h.repos.traces.insert({ + id: id as TraceId, + episodeId: eid as EpisodeId, + sessionId: "s1" as SessionId, + ts: (partial.ts ?? NOW) as EpochMs, + userText: partial.userText ?? "user text", + agentText: partial.agentText ?? "agent text", + toolCalls: [], + reflection: null, + value: partial.value ?? 0, + alpha: (partial.alpha ?? 0.5) as TraceRow["alpha"], + rHuman: partial.rHuman ?? null, + priority: partial.priority ?? 0, + tags: [], + vecSummary: null, + vecAction: null, + turnId: 0 as never, + schemaVersion: 1, + } as TraceRow); +} + +function seedEpisode(h: TmpDbHandle, eid: string, traceIds: string[]): void { + if (!h.repos.sessions.getById("s1" as never)) { + h.repos.sessions.upsert({ + id: "s1" as never, + agent: "openclaw", + startedAt: NOW, + lastSeenAt: NOW, + meta: {}, + }); + } + h.repos.episodes.insert({ + id: eid as unknown as EpisodeId, + sessionId: "s1" as never, + startedAt: NOW, + endedAt: NOW, + status: "closed", + rTask: null, + traceIds, + meta: {}, + } as never); +} + +/** Historical-simulation dataset: conserving + legacy + unresolved + orphan + foreign + malformed. */ +function seedSimulation(h: TmpDbHandle): void { + // Conserving group: N=2 nonzero, gain = clamp(0.5*2) = 1.0 → inferred_normalized. + // Duplicated "tc1" in S proves distinct-S grouping (2 stamped traces, not 3). + seedEpisode(h, "ep_c1", ["tc1", "tc2", "tc1"]); + seedTrace(h, "tc1", "ep_c1", { value: 0.5, rHuman: 1 }); + seedTrace(h, "tc2", "ep_c1", { value: 0.5, rHuman: 1 }); + // Orphan: same episode_id, never listed in S → unresolved, unstamped, reported. + seedTrace(h, "orph_c1", "ep_c1", { value: 0.9, rHuman: 1 }); + + // Non-conserving but integrity-ok: 0.525*2 = 1.05 (outside tolerance) → legacy_unscaled, gain = V. + seedEpisode(h, "ep_l1", ["tl1", "tl2"]); + seedTrace(h, "tl1", "ep_l1", { value: 0.525, rHuman: 1 }); + seedTrace(h, "tl2", "ep_l1", { value: 0.525, rHuman: 1 }); + + // NULL evidence → unresolved (NULL gain, stamped so restarts never rescan). + seedEpisode(h, "ep_u1", ["tu1", "tu2"]); + seedTrace(h, "tu1", "ep_u1", { value: 0.5, rHuman: 1 }); + seedTrace(h, "tu2", "ep_u1", { value: 0.5, rHuman: null }); + + // Foreign-owner episode: exact-owner handling must leave it entirely untouched. + seedEpisode(h, "ep_f1", ["tf1", "tf2"]); + seedTrace(h, "tf1", "ep_f1", { value: 0.5, rHuman: 1 }); + seedTrace(h, "tf2", "ep_f1", { value: 0.5, rHuman: 1 }); + h.db.exec( + `UPDATE episodes SET owner_agent_kind='hermes', owner_profile_id='other' WHERE id='ep_f1'`, + ); + + // Malformed trace_ids_json: invalid-JSON group, member stamped unresolved via the member branch. + // (Bypasses the episodes.json_valid CHECK the way a legacy/foreign writer could — same idiom as + // the gain-inference suite's setTraceIdsJsonRaw.) + seedEpisode(h, "ep_bad", ["tb1"]); + seedTrace(h, "tb1", "ep_bad", { value: 0.5, rHuman: 1 }); + h.db.raw.pragma("ignore_check_constraints = ON"); + try { + h.db.exec(`UPDATE episodes SET trace_ids_json='not-json{{{' WHERE id='ep_bad'`); + } finally { + h.db.raw.pragma("ignore_check_constraints = OFF"); + } +} + +function snapshotTraces(h: TmpDbHandle): Array<{ + id: string; + value: number; + alpha: number; + rHuman: number | null; + priority: number; +}> { + return h.db + .prepare( + `SELECT id, value, alpha, r_human, priority FROM traces ORDER BY id`, + ) + .all() + .map((r) => ({ id: r.id, value: r.value, alpha: r.alpha, rHuman: r.r_human, priority: r.priority })); +} + +function snapshotPolicies(h: TmpDbHandle): unknown[] { + return h.db + .prepare>(`SELECT * FROM policies ORDER BY id`) + .all(); +} + +function tickDeps(h: TmpDbHandle, config: L2Config, extra: Partial = {}): GainRepairAttemptDeps { + return { + db: h.db, + repos: h.repos, + config, + owner: OWNER, + thresholds: THRESHOLDS, + log: rootLogger.child({ channel: "test.task6-drill" }), + now: () => NOW, + inferenceVersion: GAIN_INFERENCE_VERSION, + ...extra, + }; +} + +function preConsumeRescreen(h: TmpDbHandle): void { + h.repos.kv.set(gainRepairRescreenKey(OWNER), { + generation: 0, + inferenceVersion: GAIN_INFERENCE_VERSION, + consumedAt: NOW, + }); +} + +/** Repair-queue policy with resolved inferred evidence + queue entry (engine-level, no pipeline). */ +function seedRepairPolicy(h: TmpDbHandle, id: string, gainValue = 0.6): void { + const episodeId = `ep_${id}`; + const traceId = `tr_${id}`; + if (!h.repos.sessions.getById("s_rec" as never)) { + h.repos.sessions.upsert({ + id: "s_rec" as never, + agent: "openclaw", + startedAt: NOW, + lastSeenAt: NOW, + meta: {}, + }); + } + h.repos.episodes.insert({ + id: episodeId as EpisodeId, + sessionId: "s_rec" as SessionId, + startedAt: NOW, + endedAt: NOW, + status: "closed", + rTask: null, + traceIds: [], + meta: {}, + } as never); + h.repos.traces.insert({ + id: traceId as TraceId, + episodeId: episodeId as EpisodeId, + sessionId: "s_rec" as SessionId, + ts: NOW, + userText: "", + agentText: "", + toolCalls: [], + reflection: null, + value: 0.5, + alpha: 0.5, + rHuman: 0.5, + priority: 0.3, + tags: [], + vecSummary: null, + vecAction: null, + turnId: 0, + schemaVersion: 1, + gainValue, + gainValueSource: "inferred_normalized" as GainValueSource, + gainInferenceVersion: GAIN_INFERENCE_VERSION, + ownerAgentKind: OWNER.ownerAgentKind, + ownerProfileId: OWNER.ownerProfileId, + ownerWorkspaceId: null, + } as TraceRow); + h.repos.episodes.appendTrace(episodeId as EpisodeId, [traceId]); + h.repos.policies.insert({ + id: id as PolicyId, + title: "drill title", + trigger: "drill trigger", + procedure: "drill procedure", + verification: "drill verification", + boundary: "drill boundary", + support: 0, + gain: 0, + gainVersion: 1, + status: "candidate", + sourceEpisodeIds: [], + inducedBy: "task6-drill", + decisionGuidance: { preference: [], antiPattern: [] }, + vec: null, + createdAt: NOW, + updatedAt: NOW, + sourceTraceIds: [traceId], + ownerAgentKind: OWNER.ownerAgentKind, + ownerProfileId: OWNER.ownerProfileId, + ownerWorkspaceId: null, + } as PolicyRow); + h.repos.tracePolicyLinks.link({ + traceId: traceId as TraceId, + policyId: id as PolicyId, + episodeId: episodeId as EpisodeId, + now: NOW, + }); + h.repos.gainRepair.upsertPending({ + policyId: id as PolicyId, + ownerAgentKind: OWNER.ownerAgentKind, + ownerProfileId: OWNER.ownerProfileId, + ownerWorkspaceId: null, + reason: "inferred_evidence_updated", + inferenceVersion: GAIN_INFERENCE_VERSION, + now: NOW, + }); +} + +// ─── Drill ──────────────────────────────────────────────────────────────────── + +describe("offline verification drill (generated scratch DBs only)", () => { + let handles: TmpDbHandle[] = []; + afterEach(() => { + for (const h of handles) h.cleanup(); + handles = []; + }); + function scratch(): TmpDbHandle { + const h = makeTmpDb({ agent: "openclaw" }); + handles.push(h); + return h; + } + + it("(a) FRESH historical simulation: trace_ids_json grouping, both provenances, NULL exclusion, exact-owner — fresh totals", () => { + const h = scratch(); + seedSimulation(h); + const tracesBefore = snapshotTraces(h); + + const t0 = Date.now(); + const report = runGainInference({ + db: h.db, + kv: h.repos.kv, + episodesRepo: h.repos.episodes, + tracesRepo: h.repos.traces, + owner: { ownerAgentKind: "openclaw", ownerProfileId: "default" }, + }); + const inferenceMs = Date.now() - t0; + + // FRESH totals (recorded here; the old 305 projection is NOT reused): + // groups: ep_c1 inferred(2 traces) + ep_l1 legacy(2) + ep_u1 unresolved(2) + // + ep_bad unresolved-malformed(1); ep_f1 excluded by exact owner. + expect(report.candidateGroups).toBe(4); + expect(report.inferredNormalized).toEqual({ groups: 1, traces: 2 }); + expect(report.legacyUnscaled).toEqual({ groups: 1, traces: 2 }); + expect(report.unresolved).toEqual({ groups: 2, traces: 3 }); + expect(report.orphansOutsideS).toBe(1); + expect(report.invalidJsonGroups).toBe(1); + expect(report.stampedTraces).toBe(7); + + // Spot-check resolved values. + const tc1 = h.repos.traces.getById("tc1" as TraceId)!; + expect(tc1.gainValueSource).toBe("inferred_normalized"); + expect(tc1.gainValue).toBeCloseTo(1, 12); + const tl1 = h.repos.traces.getById("tl1" as TraceId)!; + expect(tl1.gainValueSource).toBe("legacy_unscaled"); + expect(tl1.gainValue).toBeCloseTo(0.525, 12); + const tu2 = h.repos.traces.getById("tu2" as TraceId)!; + expect(tu2.gainValue).toBeNull(); + expect(tu2.gainValueSource).toBeNull(); + expect(tu2.gainInferenceVersion).toBe(GAIN_INFERENCE_VERSION); // stamped even when unresolved + const tb1 = h.repos.traces.getById("tb1" as TraceId)!; + expect(tb1.gainValue).toBeNull(); + expect(tb1.gainInferenceVersion).toBe(GAIN_INFERENCE_VERSION); + + // Exact-owner handling: foreign episode + orphan fully untouched. + const tf1 = h.repos.traces.getById("tf1" as TraceId)!; + expect(tf1.gainValue).toBeNull(); + expect(tf1.gainInferenceVersion).toBe(0); + const orph = h.repos.traces.getById("orph_c1" as TraceId)!; + expect(orph.gainValue).toBeNull(); + expect(orph.gainInferenceVersion).toBe(0); + + // (e) V/priority byte-identical after the read-only pass. + expect(snapshotTraces(h)).toEqual(tracesBefore); + + // eslint-disable-next-line no-console + console.log( + `[task6-drill] sim FRESH totals: candidates=${report.candidateGroups} ` + + `inferred=${report.inferredNormalized.groups}g/${report.inferredNormalized.traces}t ` + + `legacy=${report.legacyUnscaled.groups}g/${report.legacyUnscaled.traces}t ` + + `unresolved=${report.unresolved.groups}g/${report.unresolved.traces}t ` + + `orphans=${report.orphansOutsideS} invalidJson=${report.invalidJsonGroups} ` + + `stamped=${report.stampedTraces} inferenceMs=${inferenceMs}`, + ); + }); + + it("(a-ii) NULL exclusion runs BEFORE the final 50 cap (55 resolved + 5 NULL with-evidence)", () => { + const tracesById = new Map(); + const withIds: string[] = []; + const poolIds: string[] = []; + for (let i = 0; i < 55; i++) { + const id = `w_res_${i}`; + withIds.push(id); + poolIds.push(id); + tracesById.set(id, { + id: id as TraceId, + episodeId: "ep_x" as EpisodeId, + ts: (NOW + i) as EpochMs, + value: 0.5, + gainValue: 0.5, + gainValueSource: "inferred_normalized", + ownerAgentKind: "openclaw", + ownerProfileId: "default", + ownerWorkspaceId: null, + }); + } + for (let i = 0; i < 5; i++) { + const id = `w_null_${i}`; + withIds.push(id); + poolIds.push(id); + tracesById.set(id, { + id: id as TraceId, + episodeId: "ep_x" as EpisodeId, + ts: (NOW + 100 + i) as EpochMs, + value: 0.5, + gainValue: null, // NULL evidence — must be excluded, never scored + gainValueSource: null, + ownerAgentKind: "openclaw", + ownerProfileId: "default", + ownerWorkspaceId: null, + }); + } + const out = selectAndComputeGain({ + policy: { + id: "po_drill" as PolicyId, + support: 0, + gain: 0, + gainVersion: 1, + ownerAgentKind: "openclaw", + ownerProfileId: "default", + ownerWorkspaceId: null, + }, + withIds, + poolIds, + tracesById, + scoreMode: "gain", + config: { gainEmaAlpha: 0.4, tauSoftmax: 0.5 }, + }); + expect(out.skipReason).toBeNull(); + expect(out.excluded.unresolvedWith).toBe(5); // NULLs excluded first… + expect(out.excluded.withBeyondLimit).toBe(5); // …then the final 50 cap cuts 55 → 50 + expect(out.selectedWithIds).toHaveLength(50); + expect(out.selectedWithIds.some((id) => id.startsWith("w_null_"))).toBe(false); + }); + + it("(b) snapshot schema-migration timing + inference/restart idempotence; (e) startup policy fields unchanged", () => { + // Migration timing on a bare scratch DB (offline snapshot stand-in). + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "memos-task6-mig-")); + try { + const filepath = path.join(dir, "memos.db"); + const db = openDb({ filepath, agent: "openclaw" }); + try { + const t0 = Date.now(); + const result = runMigrations(db); + const migrationMs = Date.now() - t0; + expect(result.applied.length).toBeGreaterThan(0); + expect(result.applied.map((m) => m.version)).toContain(19); + // eslint-disable-next-line no-console + console.log( + `[task6-drill] migration timing: applied=${result.applied.length} migrationMs=${migrationMs}`, + ); + } finally { + db.close(); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + + // Restart drill: seed history + policies, snapshot, run, re-run. + const h = scratch(); + seedSimulation(h); + seedRepairPolicy(h, "po_drill_1"); + seedRepairPolicy(h, "po_drill_2"); + const policiesBefore = snapshotPolicies(h); + const tracesBefore = snapshotTraces(h); + + const run = () => + runGainInference({ + db: h.db, + kv: h.repos.kv, + episodesRepo: h.repos.episodes, + tracesRepo: h.repos.traces, + owner: { ownerAgentKind: "openclaw", ownerProfileId: "default" }, + }); + const first = run(); + expect(first.stampedTraces).toBeGreaterThan(0); + // (e) startup path mutates NO policy field. + expect(snapshotPolicies(h)).toEqual(policiesBefore); + expect(snapshotTraces(h)).toEqual(tracesBefore); + + // Restart idempotence: second pass stamps nothing, reprocesses nothing — + // per-run provenance counts are all zero, which IS the no-rescan proof. + const second = run(); + expect(second.stampedTraces).toBe(0); + expect(second.candidateGroups).toBe(0); + expect(second.inferredNormalized).toEqual({ groups: 0, traces: 0 }); + expect(second.legacyUnscaled).toEqual({ groups: 0, traces: 0 }); + expect(second.unresolved).toEqual({ groups: 0, traces: 0 }); + expect(snapshotPolicies(h)).toEqual(policiesBefore); + // eslint-disable-next-line no-console + console.log( + `[task6-drill] restart idempotence: firstStamped=${first.stampedTraces} secondStamped=${second.stampedTraces}`, + ); + }); + + it("(c) both modes: disabled tick attempts nothing; enabled tick drains (queue/budget intact otherwise)", () => { + const h = scratch(); + preConsumeRescreen(h); + seedRepairPolicy(h, "po_mode_1"); + seedRepairPolicy(h, "po_mode_2"); + + const pendingBefore = h.db + .prepare(`SELECT COUNT(*) AS n FROM gain_repair_queue WHERE state='pending'`) + .get()!.n; + expect(pendingBefore).toBe(2); + + // Disabled mode: zero attempts, queue untouched, no journal writes. + const off = runGainRepairTick(tickDeps(h, baseConfig({ gainV2Enabled: false }))); + expect(off.attempted).toBe(0); + expect( + h.db.prepare(`SELECT COUNT(*) AS n FROM gain_repair_queue`).get()!.n, + ).toBe(2); + expect( + h.db.prepare(`SELECT COUNT(*) AS n FROM gain_repair_journal`).get()!.n, + ).toBe(0); + + // Enabled mode: attempts flow; still read-only w.r.t. V/priority. + const tracesBefore = snapshotTraces(h); + const on = runGainRepairTick(tickDeps(h, baseConfig({ gainV2Enabled: true }))); + expect(on.attempted).toBe(2); + expect(snapshotTraces(h)).toEqual(tracesBefore); + // eslint-disable-next-line no-console + console.log(`[task6-drill] modes: disabledAttempted=${off.attempted} enabledAttempted=${on.attempted}`); + }); + + it("(d) timer throughput/latency on snapshot: 30 pending, batch 25/maxTotal 25 — measured rate vs nominal 100/hour", () => { + const h = scratch(); + preConsumeRescreen(h); + for (let i = 0; i < 30; i++) seedRepairPolicy(h, `po_t_${String(i).padStart(2, "0")}`); + const tracesBefore = snapshotTraces(h); + const policiesBefore = snapshotPolicies(h) as Array>; + + const t0 = Date.now(); + const first = runGainRepairTick( + tickDeps(h, baseConfig({ gainRepairBatchSize: 25, gainRepairMaxTotal: 25 })), + ); + const tickMs = Date.now() - t0; + expect(first.attempted).toBe(25); + const perAttemptMs = tickMs / 25; + const projectedPerHour = tickMs > 0 ? Math.round((25 / tickMs) * 3_600_000) : Number.POSITIVE_INFINITY; + // Nominal capacity is 100 attempts/hour (25 per 900 s tick): the engine is + // far faster than the schedule — the interval, not execution, paces drain. + // Backlog: 5 remain pending; the ceiling blocks the very next tick. + const t1 = Date.now(); + const second = runGainRepairTick( + tickDeps(h, baseConfig({ gainRepairBatchSize: 25, gainRepairMaxTotal: 25 })), + ); + const secondMs = Date.now() - t1; + expect(second.attempted).toBe(0); + const pendingAfter = h.db + .prepare(`SELECT COUNT(*) AS n FROM gain_repair_queue WHERE state='pending'`) + .get()!.n; + expect(pendingAfter).toBe(5); + + // (e) V/priority unchanged by the repair ticks… + expect(snapshotTraces(h)).toEqual(tracesBefore); + // …and policy writes are confined to the five CAS fields: every content / + // support / lineage field is byte-identical, only gain/gain_version / + // status / updated_at may move. + const policiesAfter = snapshotPolicies(h) as Array>; + const beforeById = new Map(policiesBefore.map((p) => [p["id"], p])); + for (const after of policiesAfter) { + const before = beforeById.get(after["id"])!; + for (const k of [ + "id", + "title", + "trigger", + "procedure", + "verification", + "boundary", + "support", + "induced_by", + "created_at", + "source_episode_ids", + "source_trace_ids", + ]) { + expect(after[k], `policy ${after["id"]} field ${k}`).toEqual(before[k]); + } + } + // Completed journal rows recorded the post-write timestamp (CAS-safe). + const nullTs = h.db + .prepare( + `SELECT COUNT(*) AS n FROM gain_repair_journal WHERE result='completed' AND new_updated_at IS NULL`, + ) + .get()!.n; + expect(nullTs).toBe(0); + // eslint-disable-next-line no-console + console.log( + `[task6-drill] timer: attempted=${first.attempted} tickMs=${tickMs} ` + + `perAttemptMs=${perAttemptMs.toFixed(2)} projectedPerHour=${projectedPerHour} ` + + `nominalPerHour=100 secondTickAttempted=${second.attempted} secondMs=${secondMs} ` + + `backlogPending=${pendingAfter}`, + ); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/pipeline/gain-repair-timer.test.ts b/apps/memos-local-plugin/tests/unit/pipeline/gain-repair-timer.test.ts new file mode 100644 index 000000000..e400b8db7 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/pipeline/gain-repair-timer.test.ts @@ -0,0 +1,366 @@ +/** + * repair timer tests (core/pipeline/memory-core.ts). + * + * Fake-timer driven: the independent 15-minute periodic tick must drain the + * repair queue with ZERO L2/reward traffic, stay single-flight (overlapping + * ticks are skipped, not queued), do nothing when disabled/paused, never emit + * `l2.failed` on repair errors, and clear + await in-flight work on shutdown. + * + * The gain-repair engine module is mocked (real implementation wrapped) so the + * test can observe invocation count and inject controlled ticks. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + createMemoryCore, + createPipeline, + type PipelineDeps, + type PipelineHandle, +} from "../../../core/pipeline/index.js"; +import type { MemoryCore } from "../../../agent-contract/memory-core.js"; +import { rootLogger } from "../../../core/logger/index.js"; +import { DEFAULT_CONFIG } from "../../../core/config/defaults.js"; +import { resolveHome } from "../../../core/config/paths.js"; +import { GAIN_INFERENCE_VERSION } from "../../../core/reward/gain-inference.js"; +import { gainRepairBudgetKey } from "../../../core/memory/l2/gain-repair.js"; +import type { PolicyId, TraceId, EpisodeId, SessionId } from "../../../core/types.js"; +import { makeTmpDb, type TmpDbHandle } from "../../helpers/tmp-db.js"; +import { fakeEmbedder } from "../../helpers/fake-embedder.js"; + +// Wrap the engine module so the timer's calls are observable AND the real +// engine still runs for the drain tests. `runGainRepairTick` becomes a mock. +vi.mock("../../../core/memory/l2/gain-repair.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runGainRepairTick: vi.fn(actual.runGainRepairTick), + }; +}); + +import { runGainRepairTick } from "../../../core/memory/l2/gain-repair.js"; +const mockedTick = vi.mocked(runGainRepairTick); + +const NOW = 1_700_000_000_000; +const INTERVAL_MS = 900_000; +const OWNER = { ownerAgentKind: "openclaw", ownerProfileId: "default", ownerWorkspaceId: null }; + +function repairEnabledConfig(): typeof DEFAULT_CONFIG { + return { + ...DEFAULT_CONFIG, + algorithm: { + ...DEFAULT_CONFIG.algorithm, + l2Induction: { + ...DEFAULT_CONFIG.algorithm.l2Induction, + gainV2Enabled: true, + gainRepairBatchSize: 25, + gainRepairMaxTotal: null, + gainRepairIntervalMs: INTERVAL_MS, + }, + }, + }; +} + +function buildDeps(h: TmpDbHandle, config: typeof DEFAULT_CONFIG): PipelineDeps { + return { + agent: "openclaw", + home: resolveHome("openclaw", "/tmp/memos-gr-timer"), + config, + db: h.db, + repos: h.repos, + llm: null, + reflectLlm: null, + entityLlm: null, + l3Llm: null, + embedder: fakeEmbedder({ dimensions: 384 }), + log: rootLogger.child({ channel: "test.gain-repair-timer" }), + namespace: { agentKind: "openclaw", profileId: "default" }, + now: () => NOW, + }; +} + +let db: TmpDbHandle | null = null; +let pipeline: PipelineHandle | null = null; +let core: MemoryCore | null = null; + +function seedPendingPolicy(h: TmpDbHandle, id: string, opts: { gainValue?: number | null } = {}): void { + const gainValue = opts.gainValue === undefined ? 0.6 : opts.gainValue; + const episodeId = `ep_${id}`; + const traceId = `tr_${id}`; + // `sessions.upsert` is INSERT OR REPLACE: re-running it would DELETE the + // session row and cascade-delete every episode (and its traces/links) of + // this test session. Only create it when missing. + if (!h.repos.sessions.getById("s_rec" as SessionId)) { + h.repos.sessions.upsert({ id: "s_rec", agent: "openclaw", startedAt: NOW, lastSeenAt: NOW, meta: {} }); + } + h.repos.episodes.insert({ + id: episodeId as EpisodeId, + sessionId: "s_rec" as SessionId, + startedAt: NOW, + endedAt: NOW, + status: "closed", + rTask: null, + traceIds: [], + meta: {}, + }); + h.repos.traces.insert({ + id: traceId as TraceId, + episodeId: episodeId as EpisodeId, + sessionId: "s_rec" as SessionId, + ts: NOW, + userText: "", + agentText: "", + toolCalls: [], + reflection: null, + value: gainValue ?? 0.5, + alpha: 0.5, + rHuman: 0.5, + priority: 0, + tags: [], + vecSummary: null, + vecAction: null, + turnId: 0, + schemaVersion: 1, + gainValue, + gainValueSource: gainValue == null ? null : "inferred_normalized", + gainInferenceVersion: GAIN_INFERENCE_VERSION, + }); + h.repos.episodes.appendTrace(episodeId as EpisodeId, [traceId]); + h.repos.policies.insert({ + id: id as PolicyId, + title: "t", + trigger: "tr", + procedure: "p", + verification: "v", + boundary: "b", + support: 0, + gain: 0, + gainVersion: 1, + status: "candidate", + sourceEpisodeIds: [], + inducedBy: "unit", + decisionGuidance: { preference: [], antiPattern: [] }, + vec: null, + createdAt: NOW, + updatedAt: NOW, + sourceTraceIds: [traceId], + ownerAgentKind: OWNER.ownerAgentKind, + ownerProfileId: OWNER.ownerProfileId, + ownerWorkspaceId: null, + }); + h.repos.tracePolicyLinks.link({ + traceId: traceId as TraceId, + policyId: id as PolicyId, + episodeId: episodeId as EpisodeId, + now: NOW, + }); + h.repos.gainRepair.upsertPending({ + policyId: id as PolicyId, + ownerAgentKind: OWNER.ownerAgentKind, + ownerProfileId: OWNER.ownerProfileId, + ownerWorkspaceId: null, + reason: "inferred_evidence_updated", + inferenceVersion: GAIN_INFERENCE_VERSION, + now: NOW, + }); +} + +async function boot(config: typeof DEFAULT_CONFIG): Promise { + pipeline = createPipeline(buildDeps(db!, config)); + core = createMemoryCore( + pipeline, + resolveHome("openclaw", "/tmp/memos-gr-timer"), + "test", + { autoRecovery: false }, + ); + await core.init(); +} + +beforeEach(() => { + db = makeTmpDb(); + mockedTick.mockClear(); + // Fake timers + Date, but keep setImmediate real: the pipeline's startup + // orphan reconcile (autoRecovery=false path) resolves its recovery promise + // inside a setImmediate callback — starving it would hang core.shutdown(). + vi.useFakeTimers({ + toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"], + }); +}); + +afterEach(async () => { + vi.useRealTimers(); + if (core) { + try { + await core.shutdown(); + } catch { + /* ignore */ + } + core = null; + } else if (pipeline) { + try { + await pipeline.shutdown("test.cleanup"); + } catch { + /* ignore */ + } + } + pipeline = null; + db?.cleanup(); + db = null; +}); + +describe("pipeline/gain-repair-timer", () => { + it("does NOT run repair immediately at init — first attempt is on the first enabled tick", async () => { + for (let i = 0; i < 3; i++) seedPendingPolicy(db!, `po_${i}`); + await boot(repairEnabledConfig()); + expect(mockedTick).not.toHaveBeenCalled(); + // Budget not even initialized before the first tick. + expect(db!.repos.kv.get(gainRepairBudgetKey(OWNER), null)).toBeNull(); + }); + + it("drains the queue with zero L2/reward traffic after one 15-minute tick", async () => { + for (let i = 0; i < 10; i++) seedPendingPolicy(db!, `po_${i}`); + await boot(repairEnabledConfig()); + const qRows = db!.db.prepare( + `SELECT policy_id, state, COALESCE(owner_agent_kind,'?') || '/' || COALESCE(owner_profile_id,'?') || '/' || COALESCE(owner_workspace_id,'?') AS owner FROM gain_repair_queue ORDER BY policy_id`, + ).all(); + // Sanity: every seeded entry is pending under the timer owner before the tick. + expect(qRows).toHaveLength(10); + expect(qRows.every((r) => r.state === "pending" && r.owner === "openclaw/default/?")).toBe(true); + await vi.advanceTimersByTimeAsync(INTERVAL_MS); + + // Real engine ran inside the timer. + expect(mockedTick).toHaveBeenCalledTimes(1); + expect(db!.repos.kv.get<{ attempted: number } | null>(gainRepairBudgetKey(OWNER), null)?.attempted).toBe(10); + for (let i = 0; i < 10; i++) { + expect(db!.repos.gainRepair.getByPolicy(`po_${i}` as PolicyId)).toBeNull(); + } + }); + + it("disabled (batch size 0) and v2-off configs make the tick do nothing", async () => { + for (let i = 0; i < 3; i++) seedPendingPolicy(db!, `po_${i}`); + const paused = repairEnabledConfig(); + paused.algorithm.l2Induction.gainRepairBatchSize = 0; + await boot(paused); + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 2); + expect(mockedTick).not.toHaveBeenCalled(); // memory-core gates before the engine + expect(db!.repos.kv.get(gainRepairBudgetKey(OWNER), null)).toBeNull(); + + await core!.shutdown(); + core = null; + const v2off = repairEnabledConfig(); + v2off.algorithm.l2Induction.gainV2Enabled = false; + await boot(v2off); + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 2); + expect(mockedTick).not.toHaveBeenCalled(); + }); + + it("overlapping ticks are skipped, never queued (single-flight per namespace)", async () => { + await boot(repairEnabledConfig()); + + let release: (() => void) | null = null; + const gate = new Promise((resolve) => { + release = resolve; + }); + const fakeResult = { + batchId: "gr_test", + attempted: 1, + rescored: 1, + promoted: 0, + blocked: 0, + conflicted: 0, + failed: 0, + reconciled: 0, + budget: { attempted: 1, limit: null, remaining: null }, + inferenceVersion: GAIN_INFERENCE_VERSION, + rescreenConsumed: false, + durationMs: 1, + }; + mockedTick.mockImplementationOnce(() => gate.then(() => fakeResult) as unknown as ReturnType); + + // First tick starts and stays in flight. + await vi.advanceTimersByTimeAsync(INTERVAL_MS); + expect(mockedTick).toHaveBeenCalledTimes(1); + + // An overlapping interval fires while the first tick is still running → + // the callback must SKIP it (no queued/accumulated tick). + await vi.advanceTimersByTimeAsync(INTERVAL_MS); + expect(mockedTick).toHaveBeenCalledTimes(1); + + // Release the in-flight tick; the next interval runs a fresh tick. + release!(); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(INTERVAL_MS); + expect(mockedTick).toHaveBeenCalledTimes(2); + }); + + it("timer errors never emit l2.failed and never write a policy_generate failure row", async () => { + await boot(repairEnabledConfig()); + const l2Failed: string[] = []; + pipeline!.buses.l2.onAny((evt) => { + if (evt.kind === "l2.failed") l2Failed.push(evt.kind); + }); + + mockedTick.mockImplementation(() => { + throw new Error("repair engine exploded"); + }); + + await vi.advanceTimersByTimeAsync(INTERVAL_MS); + expect(mockedTick).toHaveBeenCalledTimes(1); + expect(l2Failed).toEqual([]); + // The l2.failed subscriber is what writes policy_generate api_log rows; + // zero rows proves nothing was emitted. + expect(db!.repos.apiLogs.count({ toolName: "policy_generate" })).toBe(0); + + // The timer survives the error and keeps ticking. + mockedTick.mockClear(); + mockedTick.mockImplementation(() => { + throw new Error("again"); + }); + await vi.advanceTimersByTimeAsync(INTERVAL_MS); + expect(mockedTick).toHaveBeenCalledTimes(1); + }); + + it("shutdown clears the timer and waits for in-flight work before closing", async () => { + await boot(repairEnabledConfig()); + + let release: (() => void) | null = null; + const gate = new Promise((resolve) => { + release = resolve; + }); + const fakeResult = { + batchId: "gr_shutdown", + attempted: 0, + rescored: 0, + promoted: 0, + blocked: 0, + conflicted: 0, + failed: 0, + reconciled: 0, + budget: { attempted: 0, limit: null, remaining: null }, + inferenceVersion: GAIN_INFERENCE_VERSION, + rescreenConsumed: false, + durationMs: 1, + }; + mockedTick.mockImplementationOnce(() => gate.then(() => fakeResult) as unknown as ReturnType); + + await vi.advanceTimersByTimeAsync(INTERVAL_MS); + expect(mockedTick).toHaveBeenCalledTimes(1); + + // shutdown() must not complete while the tick is in flight. + let shutdownDone = false; + const shutdownPromise = core!.shutdown().then(() => { + shutdownDone = true; + }); + await vi.advanceTimersByTimeAsync(0); + expect(shutdownDone).toBe(false); + + release!(); + await shutdownPromise; + expect(shutdownDone).toBe(true); + + // Timer cleared — no further ticks. + mockedTick.mockClear(); + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 3); + expect(mockedTick).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/reward/gain-inference.test.ts b/apps/memos-local-plugin/tests/unit/reward/gain-inference.test.ts new file mode 100644 index 000000000..7e27781ec --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/reward/gain-inference.test.ts @@ -0,0 +1,956 @@ +/** + * Historical gain inference. + * + * S is the distinct set of IDs in episodes.trace_ids_json (the actual reward + * pass), never all episode_id rows. Orphan rows outside S stay unresolved and + * are reported separately. Missing listed members forbid deriving N from a + * partial set. Every screening attempt — including unresolved ones — stamps + * GAIN_INFERENCE_VERSION so a restart does not rescan stamped groups. + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + GAIN_INFERENCE_VERSION, + GAIN_POST_CUTOVER_BOUNDARY_MS, + GAIN_REPAIR_QUEUE_SEED_KEY, + reconcileGainRepairQueue, + screenGainGroup, + runGainInference, +} from "../../../core/reward/gain-inference.js"; +import type { EpisodeId, EpochMs, TraceRow } from "../../../core/types.js"; +import type { TmpDbHandle } from "../../helpers/tmp-db.js"; +import { makeTmpDb } from "../../helpers/tmp-db.js"; + +const NOW = 1_700_000_000_000 as EpochMs; +const OWNER = { ownerAgentKind: "unknown", ownerProfileId: "default", ownerWorkspaceId: null }; + +function member( + partial: Partial<{ + id: string; + episodeId: string; + value: number; + rHuman: number | null; + ts: number; + ownerAgentKind?: string; + ownerProfileId?: string; + ownerWorkspaceId?: string | null; + }>, +) { + return { + id: partial.id ?? "t", + episodeId: partial.episodeId ?? "ep", + value: partial.value ?? 0, + rHuman: partial.rHuman ?? null, + ts: partial.ts ?? NOW, + ownerAgentKind: partial.ownerAgentKind ?? "unknown", + ownerProfileId: partial.ownerProfileId ?? "default", + ownerWorkspaceId: partial.ownerWorkspaceId ?? null, + }; +} + +function seedTrace(handle: TmpDbHandle, id: string, eid: string, partial: Partial = {}): void { + const row: TraceRow = { + id: id as unknown as TraceRow["id"], + episodeId: eid as unknown as TraceRow["episodeId"], + sessionId: ("s1" as unknown) as TraceRow["sessionId"], + ts: (partial.ts ?? NOW) as EpochMs, + userText: partial.userText ?? "user text", + agentText: partial.agentText ?? "agent text", + toolCalls: [], + reflection: partial.reflection ?? null, + value: partial.value ?? 0, + alpha: (partial.alpha ?? 0) as TraceRow["alpha"], + rHuman: partial.rHuman ?? null, + priority: partial.priority ?? 0, + tags: [], + vecSummary: null, + vecAction: null, + turnId: 0 as never, + schemaVersion: 1, + ...(partial.ownerAgentKind ? { ownerAgentKind: partial.ownerAgentKind } : {}), + ...(partial.ownerProfileId ? { ownerProfileId: partial.ownerProfileId } : {}), + ...(partial.ownerWorkspaceId !== undefined + ? { ownerWorkspaceId: partial.ownerWorkspaceId } + : {}), + }; + handle.repos.traces.insert(row); +} + +function seedEpisode( + handle: TmpDbHandle, + eid: string, + traceIds: string[], + meta: Record = {}, + workspaceId?: string | null, +): void { + // upsert is INSERT OR REPLACE: re-upserting session s1 would DELETE it and + // cascade-delete every episode/trace that references it. Seed once. + if (!handle.repos.sessions.getById("s1" as never)) { + handle.repos.sessions.upsert({ + id: "s1" as never, + agent: "openclaw", + startedAt: NOW, + lastSeenAt: NOW, + meta: {}, + }); + } + handle.repos.episodes.insert({ + id: eid as unknown as EpisodeId, + sessionId: "s1" as never, + startedAt: NOW as EpochMs, + endedAt: NOW as EpochMs, + status: "closed", + rTask: null, + traceIds, + meta, + ...(workspaceId !== undefined ? { ownerWorkspaceId: workspaceId } : {}), + } as never); +} + +describe("screenGainGroup (pure)", () => { + it("requires a nonempty, valid, complete member set", () => { + expect(screenGainGroup({ episodeId: "ep", traceIds: [], members: [] }).status).toBe("unresolved"); + // Listed member missing from the fetched set → never derive N from a partial set. + const missing = screenGainGroup({ + episodeId: "ep", + traceIds: ["t1", "t2"], + members: [member({ id: "t1", value: 0.5, rHuman: 1 })], + }); + expect(missing.status).toBe("unresolved"); + expect(missing.reason).toMatch(/missing/); + }); + + it("rejects members outside the episode and mixed ownership", () => { + const outside = screenGainGroup({ + episodeId: "ep", + traceIds: ["t1"], + members: [member({ id: "t1", episodeId: "other_ep", value: 0.5, rHuman: 1 })], + }); + expect(outside.status).toBe("unresolved"); + + const mixed = screenGainGroup({ + episodeId: "ep", + traceIds: ["t1"], + members: [member({ id: "t1", value: 0.5, rHuman: 1, ownerAgentKind: "openclaw" })], + episodeOwnerAgentKind: "hermes", + }); + expect(mixed.status).toBe("unresolved"); + expect(mixed.reason).toMatch(/owner/); + }); + + it("rejects member workspace mismatch with NULL-exact semantics", () => { + // Episode in ws-a, member in ws-b → unresolved, never a numeric gain. + const mixed = screenGainGroup({ + episodeId: "ep", + traceIds: ["t1"], + members: [member({ id: "t1", value: 0.5, rHuman: 1, ownerWorkspaceId: "ws-b" })], + episodeOwnerWorkspaceId: "ws-a", + }); + expect(mixed.status).toBe("unresolved"); + expect(mixed.reason).toMatch(/owner/); + + // NULL workspace is exact, not a wildcard: a NULL member in a ws-a + // episode still mismatches. + const nullMember = screenGainGroup({ + episodeId: "ep", + traceIds: ["t1"], + members: [member({ id: "t1", value: 0.5, rHuman: 1, ownerWorkspaceId: null })], + episodeOwnerWorkspaceId: "ws-a", + }); + expect(nullMember.status).toBe("unresolved"); + expect(nullMember.reason).toMatch(/owner/); + + // ...and NULL matches only NULL (group screens normally). + const nullMatch = screenGainGroup({ + episodeId: "ep", + traceIds: ["t1"], + members: [member({ id: "t1", value: 0.9, rHuman: 0.9, ownerWorkspaceId: null })], + episodeOwnerWorkspaceId: null, + }); + expect(nullMatch.status).toBe("inferred_normalized"); + }); + + it("requires finite V and r_human within [-1, 1] on every member", () => { + expect( + screenGainGroup({ + episodeId: "ep", + traceIds: ["t1"], + members: [member({ id: "t1", value: 2, rHuman: 1 })], + }).status, + ).toBe("unresolved"); + expect( + screenGainGroup({ + episodeId: "ep", + traceIds: ["t1"], + members: [member({ id: "t1", value: 0.5, rHuman: null })], + }).status, + ).toBe("unresolved"); + expect( + screenGainGroup({ + episodeId: "ep", + traceIds: ["t1"], + members: [member({ id: "t1", value: 0.5, rHuman: Number.NaN })], + }).status, + ).toBe("unresolved"); + }); + + it("requires reward consistency within 1e-9", () => { + const mixed = screenGainGroup({ + episodeId: "ep", + traceIds: ["t1", "t2"], + members: [member({ id: "t1", value: 0.5, rHuman: 1 }), member({ id: "t2", value: 0.5, rHuman: 0.9 })], + }); + expect(mixed.status).toBe("unresolved"); + expect(mixed.reason).toMatch(/reward/i); + }); + + it("enforces sign: nonzero V shares R's sign; R=0 requires all V=0", () => { + expect( + screenGainGroup({ + episodeId: "ep", + traceIds: ["t1"], + members: [member({ id: "t1", value: 0.5, rHuman: -0.8 })], + }).status, + ).toBe("unresolved"); + expect( + screenGainGroup({ + episodeId: "ep", + traceIds: ["t1", "t2"], + members: [member({ id: "t1", value: 0.1, rHuman: 0 }), member({ id: "t2", value: 0, rHuman: 0 })], + }).status, + ).toBe("unresolved"); + // R=0 with all V=0 passes integrity. + expect( + screenGainGroup({ + episodeId: "ep", + traceIds: ["t1", "t2"], + members: [member({ id: "t1", value: 0, rHuman: 0 }), member({ id: "t2", value: 0, rHuman: 0 })], + }).status, + ).toBe("inferred_normalized"); + }); + + it("cross-checks meta.reward.traceIds exact-set equality when present", () => { + const mismatch = screenGainGroup({ + episodeId: "ep", + traceIds: ["t1", "t2"], + members: [member({ id: "t1", value: 0.5, rHuman: 1 }), member({ id: "t2", value: 0.5, rHuman: 1 })], + metaRewardTraceIds: ["t1", "t2", "ghost"], + }); + expect(mismatch.status).toBe("unresolved"); + expect(mismatch.reason).toMatch(/traceIds|trace_ids/i); + // Absent metadata permits inference (audit counts it). + const absent = screenGainGroup({ + episodeId: "ep", + traceIds: ["t1", "t2"], + members: [member({ id: "t1", value: 0.5, rHuman: 1 }), member({ id: "t2", value: 0.5, rHuman: 1 })], + metaRewardTraceIds: null, + }); + expect(absent.status).toBe("inferred_normalized"); + }); + + it("scalar/object/numeric-element meta.reward.traceIds never passes the cross-check", () => { + const base = { + episodeId: "ep", + traceIds: ["t1"], + members: [member({ id: "t1", value: 0.9, rHuman: 0.9 })], + }; + // A scalar equal to the member id must STILL be unresolved — never coerced + // into a one-element array that could pass the exact-set check. + expect(screenGainGroup({ ...base, metaRewardTraceIds: "t1" }).status).toBe("unresolved"); + expect(screenGainGroup({ ...base, metaRewardTraceIds: ["t1", 5] }).status).toBe("unresolved"); + expect(screenGainGroup({ ...base, metaRewardTraceIds: { 0: "t1" } }).status).toBe("unresolved"); + expect(screenGainGroup({ ...base, metaRewardTraceIds: [null] }).status).toBe("unresolved"); + }); + + it("conserving groups use contributor scaling (inferred_normalized)", () => { + const out = screenGainGroup({ + episodeId: "ep", + traceIds: ["t1", "t2", "t3"], + members: [ + member({ id: "t1", value: 0.5, rHuman: 1 }), + member({ id: "t2", value: 0.5, rHuman: 1 }), + member({ id: "t3", value: 0, rHuman: 1 }), + ], + }); + expect(out.status).toBe("inferred_normalized"); + expect(out.nonzeroCount).toBe(2); + // clamp(V * nonzeroCount) = clamp(0.5 * 2) = 1 + expect(out.gainByTraceId.get("t1")).toBeCloseTo(1, 12); + expect(out.gainByTraceId.get("t2")).toBeCloseTo(1, 12); + expect(out.gainByTraceId.get("t3")).toBe(0); + }); + + it("non-conserving groups with integrity use V unchanged (legacy_unscaled)", () => { + const out = screenGainGroup({ + episodeId: "ep", + traceIds: ["t1", "t2"], + members: [member({ id: "t1", value: 0.5, rHuman: 0.9 }), member({ id: "t2", value: 0.5, rHuman: 0.9 })], + }); + expect(out.status).toBe("legacy_unscaled"); + expect(out.gainByTraceId.get("t1")).toBeCloseTo(0.5, 12); + expect(out.gainByTraceId.get("t2")).toBeCloseTo(0.5, 12); + }); + + it("applies the 0.2% / 1% conservation tolerance", () => { + // |sum(V)-R| = 0.003 with R=1 → 0.003 <= max(0.002, 0.01) → inferred. + const within = screenGainGroup({ + episodeId: "ep", + traceIds: ["t1", "t2"], + members: [member({ id: "t1", value: 0.5015, rHuman: 1 }), member({ id: "t2", value: 0.5015, rHuman: 1 })], + }); + expect(within.status).toBe("inferred_normalized"); + // |sum(V)-R| = 0.05 with R=1 → 0.05 > 0.01 → legacy_unscaled. + const beyond = screenGainGroup({ + episodeId: "ep", + traceIds: ["t1", "t2"], + members: [member({ id: "t1", value: 0.525, rHuman: 1 }), member({ id: "t2", value: 0.525, rHuman: 1 })], + }); + expect(beyond.status).toBe("legacy_unscaled"); + }); +}); + +describe("runGainInference (storage)", () => { + let handle: TmpDbHandle; + beforeEach(() => { + handle = makeTmpDb(); + }); + afterEach(() => { + handle.cleanup(); + }); + + function run(overrides: Partial[0]> = {}) { + return runGainInference({ + db: handle.db, + kv: handle.repos.kv, + episodesRepo: handle.repos.episodes, + tracesRepo: handle.repos.traces, + owner: OWNER, + ...overrides, + }); + } + + it("uses S from trace_ids_json, leaving episode_id orphans unresolved and reported", () => { + seedEpisode(handle, "ep1", ["t1", "t2"]); + seedTrace(handle, "t1", "ep1", { value: 0.5, rHuman: 1 }); + seedTrace(handle, "t2", "ep1", { value: 0.5, rHuman: 1 }); + // Orphan: same episode_id, never listed in trace_ids_json. + seedTrace(handle, "orphan", "ep1", { value: 0.9, rHuman: 1 }); + + const report = run(); + expect(report.orphansOutsideS).toBe(1); + expect(report.inferredNormalized.groups).toBe(1); + + const orphan = handle.repos.traces.getById("orphan" as never)!; + expect(orphan.gainValueSource).toBeNull(); + expect(orphan.gainInferenceVersion).toBe(0); + const t1 = handle.repos.traces.getById("t1" as never)!; + expect(t1.gainValueSource).toBe("inferred_normalized"); + expect(t1.gainValue).toBeCloseTo(1, 12); + }); + + it("missing listed member forbids deriving N from a partial set", () => { + seedEpisode(handle, "ep1", ["t1", "ghost"]); + seedTrace(handle, "t1", "ep1", { value: 0.9, rHuman: 1 }); + + const report = run(); + expect(report.unresolved.groups).toBe(1); + const t1 = handle.repos.traces.getById("t1" as never)!; + expect(t1.gainValueSource).toBeNull(); + expect(t1.gainValue).toBeNull(); + // Even unresolved attempts get the stamp so restarts do not rescan. + expect(t1.gainInferenceVersion).toBe(GAIN_INFERENCE_VERSION); + }); + + it("is idempotent: restart does not rescan stamped unresolved groups", () => { + seedEpisode(handle, "ep1", ["t1", "ghost"]); + seedTrace(handle, "t1", "ep1", { value: 0.9, rHuman: 1 }); + + const first = run(); + expect(first.candidateGroups).toBe(1); + const second = run(); + expect(second.candidateGroups).toBe(0); + expect(second.stampedTraces).toBe(0); + }); + + it("handles empty/malformed sets without stamping", () => { + seedEpisode(handle, "ep_empty", []); + const report = run(); + expect(report.candidateGroups).toBe(0); + }); + + it("metadata traceIds mismatch leaves the group unresolved", () => { + seedEpisode(handle, "ep1", ["t1", "t2"], { + reward: { traceIds: ["t1", "t2", "stale"] }, + }); + seedTrace(handle, "t1", "ep1", { value: 0.5, rHuman: 1 }); + seedTrace(handle, "t2", "ep1", { value: 0.5, rHuman: 1 }); + + const report = run(); + expect(report.unresolved.groups).toBe(1); + const t1 = handle.repos.traces.getById("t1" as never)!; + expect(t1.gainValueSource).toBeNull(); + }); + + it("mixed ownership stays unresolved", () => { + seedEpisode(handle, "ep1", ["t1"]); + seedTrace(handle, "t1", "ep1", { value: 0.5, rHuman: 1, ownerAgentKind: "openclaw" }); + + const report = run(); + expect(report.unresolved.groups).toBe(1); + expect(handle.repos.traces.getById("t1" as never)!.gainValueSource).toBeNull(); + }); + + it("is workspace-exact: a tick never selects or stamps another workspace's episodes", () => { + seedEpisode(handle, "ep_a", ["ta"], {}, "ws-a"); + seedTrace(handle, "ta", "ep_a", { value: 0.9, rHuman: 0.9, ownerWorkspaceId: "ws-a" }); + seedEpisode(handle, "ep_b", ["tb"], {}, "ws-b"); + seedTrace(handle, "tb", "ep_b", { value: 0.9, rHuman: 0.9, ownerWorkspaceId: "ws-b" }); + + // A null-workspace tick selects neither namespaced group. + const none = run(); + expect(none.candidateGroups).toBe(0); + + // A ws-a tick screens only its own group; ws-b rows stay untouched. + const reportA = run({ + owner: { ownerAgentKind: "unknown", ownerProfileId: "default", ownerWorkspaceId: "ws-a" }, + }); + expect(reportA.candidateGroups).toBe(1); + expect(reportA.inferredNormalized.groups).toBe(1); + expect(handle.repos.traces.getById("ta" as never)!.gainInferenceVersion).toBe( + GAIN_INFERENCE_VERSION, + ); + const tb = handle.repos.traces.getById("tb" as never)!; + expect(tb.gainInferenceVersion).toBe(0); + expect(tb.gainValueSource).toBeNull(); + expect(tb.gainValue).toBeNull(); + + // A ws-b tick then screens only its own group. + const reportB = run({ + owner: { ownerAgentKind: "unknown", ownerProfileId: "default", ownerWorkspaceId: "ws-b" }, + }); + expect(reportB.candidateGroups).toBe(1); + expect(handle.repos.traces.getById("tb" as never)!.gainInferenceVersion).toBe( + GAIN_INFERENCE_VERSION, + ); + }); + + it("never overwrites live_normalized scores with historical inference", () => { + // Episode A: already live-scored (gain persisted with live provenance). + seedEpisode(handle, "ep_live", ["la"]); + seedTrace(handle, "la", "ep_live", { value: 0.3, rHuman: 0.9 }); + handle.repos.traces.updateScore("la" as never, { + value: 0.3, + alpha: 1, + rHuman: 0.9, + priority: 0.2, + gainValue: 0.6, + gainValueSource: "live_normalized", + }); + // Episode B: historical, unscreened. + seedEpisode(handle, "ep_hist", ["hb"]); + seedTrace(handle, "hb", "ep_hist", { value: 0.9, rHuman: 0.9 }); + + const report = run(); + const live = handle.repos.traces.getById("la" as never)!; + expect(live.gainValueSource).toBe("live_normalized"); + expect(live.gainValue).toBeCloseTo(0.6, 12); + expect(live.gainInferenceVersion).toBe(0); // live scoring is not inference + const hist = handle.repos.traces.getById("hb" as never)!; + expect(hist.gainValueSource).toBe("inferred_normalized"); + expect(hist.gainValue).toBeCloseTo(0.9, 12); + expect(report.inferredNormalized.groups).toBe(1); + }); + + it("revisits lower-stamp inferred rows on an inference-version bump", () => { + seedEpisode(handle, "ep1", ["t1", "t2"]); + seedTrace(handle, "t1", "ep1", { value: 0.5, rHuman: 1 }); + seedTrace(handle, "t2", "ep1", { value: 0.5, rHuman: 1 }); + + run({ inferenceVersion: 1 }); + const before = handle.repos.traces.getById("t1" as never)!; + expect(before.gainInferenceVersion).toBe(1); + + // Version 2 revisit: group re-screened and re-stamped at v2. + const bumped = run({ inferenceVersion: 2 }); + expect(bumped.candidateGroups).toBe(1); + expect(bumped.stampedTraces).toBe(2); + const after = handle.repos.traces.getById("t1" as never)!; + expect(after.gainInferenceVersion).toBe(2); + // A third run at v2 is a no-op again. + expect(run({ inferenceVersion: 2 }).candidateGroups).toBe(0); + }); + + it("reports legacy_unscaled groups by post-cutover chronology", () => { + // Non-conserving group whose newest member is after the cutover. + const afterCutover = GAIN_POST_CUTOVER_BOUNDARY_MS + 86_400_000; + seedEpisode(handle, "ep_legacy", ["l1", "l2"]); + seedTrace(handle, "l1", "ep_legacy", { value: 0.5, rHuman: 0.9, ts: afterCutover - 10_000 }); + seedTrace(handle, "l2", "ep_legacy", { value: 0.5, rHuman: 0.9, ts: afterCutover }); + + const report = run(); + expect(report.legacyUnscaled.groups).toBe(1); + expect(report.postCutoverLegacy.groups).toBe(1); + expect(report.unknownChronology.groups).toBe(0); + + // Unknown timestamp (0) → unknown chronology, still legacy_unscaled. + // Single member V=0.5 vs R=0.9: non-conserving with integrity → legacy. + seedEpisode(handle, "ep_legacy2", ["m1"]); + seedTrace(handle, "m1", "ep_legacy2", { value: 0.5, rHuman: 0.9, ts: 0 }); + const report2 = run(); + // Per-run report: run 1 already stamped ep_legacy, so this run only + // screens ep_legacy2. + expect(report2.legacyUnscaled.groups).toBe(1); + expect(report2.unknownChronology.groups).toBe(1); + }); + + it("audit counts groups inferred without meta.reward.traceIds", () => { + seedEpisode(handle, "ep1", ["t1"]); + seedTrace(handle, "t1", "ep1", { value: 0.9, rHuman: 0.9 }); + const report = run(); + expect(report.inferredNormalized.groups).toBe(1); + expect(report.auditMetaAbsent).toBe(1); + }); + + function setTraceIdsJsonRaw(eid: string, raw: string): void { + // Bypass the episodes.json_valid CHECK so we can exercise the defensive + // malformed-data paths the way a legacy/foreign writer could produce them. + handle.db.raw.pragma("ignore_check_constraints = ON"); + try { + handle.db + .prepare<{ raw: string; id: string }>(`UPDATE episodes SET trace_ids_json = @raw WHERE id = @id`) + .run({ raw, id: eid }); + } finally { + handle.db.raw.pragma("ignore_check_constraints = OFF"); + } + } + + it("malformed trace_ids_json never aborts the pass; members get unresolved attempt stamps", () => { + seedEpisode(handle, "ep1", ["t1"]); + seedTrace(handle, "t1", "ep1", { value: 0.5, rHuman: 0.9 }); + setTraceIdsJsonRaw("ep1", '{"broken'); + + const report = run(); + expect(report.invalidJsonGroups).toBe(1); + expect(report.unresolved.groups).toBe(1); + const t1 = handle.repos.traces.getById("t1" as never)!; + expect(t1.gainValueSource).toBeNull(); + expect(t1.gainValue).toBeNull(); + expect(t1.gainInferenceVersion).toBe(GAIN_INFERENCE_VERSION); + // Restart: stamped → no re-scan loop. + expect(run().candidateGroups).toBe(0); + }); + + it("object/scalar trace_ids_json enters screening and is stamped unresolved, not skipped", () => { + seedEpisode(handle, "ep_obj", ["t1"]); + seedTrace(handle, "t1", "ep_obj", { value: 0.5, rHuman: 0.9 }); + setTraceIdsJsonRaw("ep_obj", '{"member": "t1"}'); + + const report = run(); + expect(report.unresolved.groups).toBe(1); + const t1 = handle.repos.traces.getById("t1" as never)!; + expect(t1.gainValueSource).toBeNull(); + expect(t1.gainValue).toBeNull(); + expect(t1.gainInferenceVersion).toBe(GAIN_INFERENCE_VERSION); + expect(run().candidateGroups).toBe(0); + + // Scalar shape behaves identically. + seedEpisode(handle, "ep_scalar", ["s1"]); + seedTrace(handle, "s1", "ep_scalar", { value: 0.5, rHuman: 0.9 }); + setTraceIdsJsonRaw("ep_scalar", '"s1"'); + const report2 = run(); + expect(report2.unresolved.groups).toBe(1); + expect(handle.repos.traces.getById("s1" as never)!.gainInferenceVersion).toBe(GAIN_INFERENCE_VERSION); + }); + + it("object/scalar trace_ids_json whose values match NO member trace still enters screening (stamped, restart idempotent)", () => { + // Values that do not match any member trace ID must NOT let the episode + // fall between the array and non-array branches (json_array_length() + // returns 0 for valid non-arrays — a length-based classifier would + // wrongly route these to the array branch and skip them forever). + seedEpisode(handle, "ep_obj", ["t1"]); + seedTrace(handle, "t1", "ep_obj", { value: 0.5, rHuman: 0.9 }); + setTraceIdsJsonRaw("ep_obj", '{"member": "not_t1"}'); + + const report = run(); + expect(report.unresolved.groups).toBe(1); + const t1 = handle.repos.traces.getById("t1" as never)!; + expect(t1.gainValueSource).toBeNull(); + expect(t1.gainValue).toBeNull(); + expect(t1.gainInferenceVersion).toBe(GAIN_INFERENCE_VERSION); + // Restart: stamped → nothing selected again. + expect(run().candidateGroups).toBe(0); + + // Scalar whose value matches no member id. + seedEpisode(handle, "ep_scalar", ["s1"]); + seedTrace(handle, "s1", "ep_scalar", { value: 0.5, rHuman: 0.9 }); + setTraceIdsJsonRaw("ep_scalar", '"not_s1"'); + const report2 = run(); + expect(report2.unresolved.groups).toBe(1); + expect(handle.repos.traces.getById("s1" as never)!.gainInferenceVersion).toBe(GAIN_INFERENCE_VERSION); + expect(run().candidateGroups).toBe(0); + }); + + it("scalar meta.reward.traceIds stays unresolved with an attempt stamp — no numeric gain", () => { + seedEpisode(handle, "ep1", ["t1"], { reward: { traceIds: "t1" } }); + seedTrace(handle, "t1", "ep1", { value: 0.9, rHuman: 0.9 }); + + const report = run(); + expect(report.unresolved.groups).toBe(1); + const t1 = handle.repos.traces.getById("t1" as never)!; + expect(t1.gainValueSource).toBeNull(); + expect(t1.gainValue).toBeNull(); + expect(t1.gainInferenceVersion).toBe(GAIN_INFERENCE_VERSION); + }); + + it("oversized S stays within SQLite variable limits (chunked narrow reads)", () => { + const N = 1200; // exceeds SQLite's 999-variable cap for a single IN clause + const ids = Array.from({ length: N }, (_, i) => `big_${i}`); + seedEpisode(handle, "ep_big", ids); + for (const id of ids) { + seedTrace(handle, id, "ep_big", { value: 0.9, rHuman: 0.9 }); + } + + const report = run(); + expect(report.candidateGroups).toBe(1); + expect(report.legacyUnscaled.groups).toBe(1); + expect(report.stampedTraces).toBe(N); + const last = handle.repos.traces.getById(`big_${N - 1}` as never)!; + expect(last.gainValueSource).toBe("legacy_unscaled"); + expect(last.gainValue).toBeCloseTo(0.9, 12); + expect(last.gainInferenceVersion).toBe(GAIN_INFERENCE_VERSION); + }); + + it("caps per-boot groups and completes the backlog across simulated restarts without policy writes", () => { + // 12 conserving groups (2 traces each → inferred_normalized, gain ≈ 1). + for (let g = 0; g < 12; g++) { + const eid = `ep_cap_${g}`; + seedEpisode(handle, eid, [`t_cap_${g}_1`, `t_cap_${g}_2`]); + seedTrace(handle, `t_cap_${g}_1`, eid, { value: 0.5, rHuman: 1 }); + seedTrace(handle, `t_cap_${g}_2`, eid, { value: 0.5, rHuman: 1 }); + } + // A policy that the inference pass must never touch. + handle.repos.policies.insert({ + id: "pol_cap", + title: "t", + trigger: "tr", + procedure: "p", + verification: "v", + boundary: "b", + support: 2, + gain: 0.01, + status: "candidate", + sourceEpisodeIds: [], + sourceTraceIds: [], + inducedBy: "manual", + decisionGuidance: { preference: [], antiPattern: [] }, + createdAt: 1, + updatedAt: 1, + } as never); + const policiesBefore = JSON.stringify(handle.repos.policies.list()); + + // Each capped boot does bounded work and reports more backlog pending. + const first = run({ maxGroups: 5 }); + expect(first.truncated).toBe(true); + expect(first.candidateGroups).toBe(5); + const second = run({ maxGroups: 5 }); + expect(second.truncated).toBe(true); + expect(second.candidateGroups).toBe(5); + const third = run({ maxGroups: 5 }); + expect(third.truncated).toBe(false); + expect(third.candidateGroups).toBe(2); + + // The full backlog converted across restarts: every member stamped. + expect(first.stampedTraces + second.stampedTraces + third.stampedTraces).toBe(24); + for (let g = 0; g < 12; g++) { + for (const tid of [`t_cap_${g}_1`, `t_cap_${g}_2`]) { + const tr = handle.repos.traces.getById(tid as never)!; + expect(tr.gainValueSource).toBe("inferred_normalized"); + expect(tr.gainValue).toBeCloseTo(1, 12); + expect(tr.gainInferenceVersion).toBe(GAIN_INFERENCE_VERSION); + } + } + // One more boot finds nothing — resume terminates. + expect(run({ maxGroups: 5 }).candidateGroups).toBe(0); + // No policy writes during any capped pass. + expect(JSON.stringify(handle.repos.policies.list())).toBe(policiesBefore); + }); + + it("honours the wall-clock budget and resumes on the next call", () => { + for (let g = 0; g < 3; g++) { + const eid = `ep_bud_${g}`; + seedEpisode(handle, eid, [`t_bud_${g}`]); + seedTrace(handle, `t_bud_${g}`, eid, { value: 0.5, rHuman: 0.5 }); + } + // Clock: pass start at t=1000, already past the 30s budget on first check. + const times = [1000, 1000 + 60_000]; + let i = 0; + const first = run({ + timeBudgetMs: 30_000, + now: () => times[Math.min(i++, times.length - 1)]!, + }); + expect(first.truncated).toBe(true); + expect(first.candidateGroups).toBe(0); + expect(first.stampedTraces).toBe(0); + // Unbounded resume converts everything. + const second = run(); + expect(second.truncated).toBe(false); + expect(second.candidateGroups).toBe(3); + }); +}); + +describe("reconcileGainRepairQueue (durable startup seeding)", () => { + let handle: TmpDbHandle; + beforeEach(() => { + handle = makeTmpDb(); + }); + afterEach(() => { + handle.cleanup(); + }); + + function seedPolicy( + id: string, + opts: { status?: string; sourceTraceIds?: string[]; archived?: boolean } = {}, + ): void { + handle.repos.policies.insert({ + id: id as never, + title: "t", + trigger: "tr", + procedure: "p", + verification: "v", + boundary: "b", + support: 2, + gain: 0.01, + status: (opts.status ?? (opts.archived ? "archived" : "candidate")) as never, + sourceEpisodeIds: [], + sourceTraceIds: opts.sourceTraceIds ?? [], + inducedBy: "manual", + decisionGuidance: { preference: [], antiPattern: [] }, + createdAt: 1, + updatedAt: 1, + } as never); + } + + function seedPolicySession(): void { + handle.repos.sessions.upsert({ + id: "s1" as never, + agent: "openclaw", + startedAt: NOW, + lastSeenAt: NOW, + meta: {}, + }); + } + + /** Stamp traces at the current inference version (stored state). */ + function stampInference(groups: Array<{ eid: string; traceIds: string[] }>) { + for (const { eid, traceIds } of groups) { + seedEpisode(handle, eid, traceIds); + for (const tid of traceIds) { + seedTrace(handle, tid, eid, { value: 0.5, rHuman: 1 }); + } + } + return runGainInference({ + db: handle.db, + kv: handle.repos.kv, + episodesRepo: handle.repos.episodes, + tracesRepo: handle.repos.traces, + owner: OWNER, + }); + } + + function reconcile() { + return reconcileGainRepairQueue({ + db: handle.db, + kv: handle.repos.kv, + gainRepair: handle.repos.gainRepair, + tracesRepo: handle.repos.traces, + owner: OWNER, + }); + } + + it("derives the seed set from stored state for affected candidate/active policies, never archived", () => { + seedPolicySession(); + const report = stampInference([{ eid: "ep1", traceIds: ["t1", "t2"] }]); + expect(report.inferredNormalized.groups).toBe(1); + seedPolicy("pol_active", { status: "active", sourceTraceIds: ["t1", "t2"] }); + seedPolicy("pol_candidate", { sourceTraceIds: ["t1"] }); + seedPolicy("pol_archived", { archived: true, sourceTraceIds: ["t1"] }); + seedPolicy("pol_unrelated", { sourceTraceIds: ["t9"] }); + + const result = reconcile(); + expect(result.seeded).toBe(2); + expect(result.reconciled).toBe(0); + expect(result.alreadySeeded).toBe(false); + + const active = handle.repos.gainRepair.getByPolicy("pol_active" as never)!; + expect(active.state).toBe("pending"); + expect(active.reason).toBe("inferred_evidence_updated"); + expect(active.inferenceVersion).toBe(GAIN_INFERENCE_VERSION); + const candidate = handle.repos.gainRepair.getByPolicy("pol_candidate" as never)!; + expect(candidate.state).toBe("pending"); + // Archived policy is not a repair target. + expect(handle.repos.gainRepair.getByPolicy("pol_archived" as never)).toBeNull(); + expect(handle.repos.gainRepair.getByPolicy("pol_unrelated" as never)).toBeNull(); + }); + + it("malformed/non-array source_trace_ids_json never aborts reconciliation; bad rows are skipped", () => { + seedPolicySession(); + const report = stampInference([{ eid: "ep1", traceIds: ["t1", "t2"] }]); + expect(report.inferredNormalized.groups).toBe(1); + seedPolicy("pol_good", { sourceTraceIds: ["t1", "t2"] }); + seedPolicy("pol_bad_malformed", { sourceTraceIds: ["t1"] }); + seedPolicy("pol_bad_object", { sourceTraceIds: ["t1"] }); + // Bypass the policies.json_valid CHECK the way a legacy/foreign writer + // could — the guarded json_each must skip these rows, not throw. + handle.db.raw.pragma("ignore_check_constraints = ON"); + try { + const upd = handle.db.prepare<{ raw: string; id: string }>( + `UPDATE policies SET source_trace_ids_json = @raw WHERE id = @id`, + ); + upd.run({ raw: '{"broken', id: "pol_bad_malformed" }); + upd.run({ raw: '{"member": "t1"}', id: "pol_bad_object" }); + } finally { + handle.db.raw.pragma("ignore_check_constraints = OFF"); + } + + const result = reconcile(); + expect(result.seeded).toBe(1); + expect(handle.repos.gainRepair.getByPolicy("pol_good" as never)).not.toBeNull(); + expect(handle.repos.gainRepair.getByPolicy("pol_bad_malformed" as never)).toBeNull(); + expect(handle.repos.gainRepair.getByPolicy("pol_bad_object" as never)).toBeNull(); + }); + + it("does not reseed once the watermark is current", () => { + seedPolicySession(); + stampInference([{ eid: "ep1", traceIds: ["t1"] }]); + seedPolicy("pol_ok", { sourceTraceIds: ["t1"] }); + + const first = reconcile(); + expect(first.seeded).toBe(1); + expect(first.alreadySeeded).toBe(false); + + const second = reconcile(); + expect(second.seeded).toBe(0); + expect(second.alreadySeeded).toBe(true); + }); + + it("same-version newly stamped work is durably seeded after a crash before reconcile", () => { + seedPolicySession(); + // 1. Complete inference + queue seed at v1. + stampInference([{ eid: "ep1", traceIds: ["t1"] }]); + seedPolicy("pol_first", { sourceTraceIds: ["t1"] }); + const firstSeed = reconcile(); + expect(firstSeed.seeded).toBe(1); + expect(firstSeed.alreadySeeded).toBe(false); + + // 2. Add another unscreened group/policy. + seedEpisode(handle, "ep2", ["t2"]); + seedTrace(handle, "t2", "ep2", { value: 0.9, rHuman: 0.9 }); + seedPolicy("pol_second", { sourceTraceIds: ["t2"] }); + + // 3. Run inference at v1 again: t2 is stamped at the SAME version, and + // the seed watermark is durably invalidated in the stamp transaction. + const report = runGainInference({ + db: handle.db, + kv: handle.repos.kv, + episodesRepo: handle.repos.episodes, + tracesRepo: handle.repos.traces, + owner: OWNER, + }); + expect(report.inferredNormalized.groups).toBe(1); + expect(handle.repos.kv.get(GAIN_REPAIR_QUEUE_SEED_KEY, null)).toBeNull(); + + // 4. Simulate a crash before queue reconciliation (reconcile never runs). + // 5. Restart: reconcile recomputes from stored state → second policy seeded. + const restart = reconcile(); + expect(restart.alreadySeeded).toBe(false); + expect(restart.seeded).toBe(2); // pol_first re-derived (idempotent) + pol_second + expect(handle.repos.gainRepair.getByPolicy("pol_first" as never)).not.toBeNull(); + expect(handle.repos.gainRepair.getByPolicy("pol_second" as never)).not.toBeNull(); + expect(handle.repos.kv.get(GAIN_REPAIR_QUEUE_SEED_KEY, null)).not.toBeNull(); + }); + + it("a crash between inference commits and queue seeding is recovered on restart", () => { + seedPolicySession(); + // "Crash": inference commits its trace stamps, seeding never runs. + stampInference([{ eid: "ep1", traceIds: ["t1"] }]); + seedPolicy("pol_ok", { sourceTraceIds: ["t1"] }); + + // Restart: reconciliation recomputes the seed set from the database and + // the affected policy is still seeded — no permanently omitted work. + const result = reconcile(); + expect(result.seeded).toBe(1); + expect(result.alreadySeeded).toBe(false); + expect(handle.repos.gainRepair.getByPolicy("pol_ok" as never)).not.toBeNull(); + }); + + it("a failed seeding attempt rolls back and is retried on the next restart", () => { + seedPolicySession(); + stampInference([{ eid: "ep1", traceIds: ["t1"] }]); + seedPolicy("pol_ok", { sourceTraceIds: ["t1"] }); + + // Seeding attempt 1 fails mid-transaction (queue write error). The whole + // reconcile rolls back — including the watermark. + const failingRepo = { + ...handle.repos.gainRepair, + upsertPending: () => { + throw new Error("queue write failed"); + }, + }; + expect(() => + reconcileGainRepairQueue({ + db: handle.db, + kv: handle.repos.kv, + gainRepair: failingRepo as never, + tracesRepo: handle.repos.traces, + owner: OWNER, + }), + ).toThrow(/queue write failed/); + expect(handle.repos.kv.get(GAIN_REPAIR_QUEUE_SEED_KEY, null)).toBeNull(); + + // Restart with a healthy repo: affected policies are still seeded. + const result = reconcile(); + expect(result.seeded).toBe(1); + expect(handle.repos.gainRepair.getByPolicy("pol_ok" as never)).not.toBeNull(); + }); + + it("reconciles away queue entries whose policy is archived or deleted", () => { + seedPolicySession(); + stampInference([{ eid: "ep1", traceIds: ["t1"] }]); + seedPolicy("pol_ok", { sourceTraceIds: ["t1"] }); + seedPolicy("pol_archived", { archived: true }); + seedPolicy("pol_gone", {}); + for (const pid of ["pol_ok", "pol_archived", "pol_gone"]) { + handle.repos.gainRepair.upsertPending({ + policyId: pid as never, + ownerAgentKind: "unknown", + ownerProfileId: "default", + }); + } + // pol_gone is deleted outright (FK cascade drops its queue row). + handle.repos.policies.deleteById("pol_gone" as never); + + const result = reconcile(); + expect(result.seeded).toBe(1); + expect(result.reconciled).toBe(1); + expect(handle.repos.gainRepair.getByPolicy("pol_ok" as never)).not.toBeNull(); + expect(handle.repos.gainRepair.getByPolicy("pol_archived" as never)).toBeNull(); + expect(handle.repos.gainRepair.getByPolicy("pol_gone" as never)).toBeNull(); + }); + + it("no stamped traces → no seeding, but reconciliation still runs and marks seeded", () => { + seedPolicySession(); + seedPolicy("pol_archived", { archived: true }); + handle.repos.gainRepair.upsertPending({ + policyId: "pol_archived" as never, + ownerAgentKind: "unknown", + ownerProfileId: "default", + }); + const result = reconcile(); + expect(result.seeded).toBe(0); + expect(result.reconciled).toBe(1); + expect(result.alreadySeeded).toBe(false); + // A second reconcile is a no-op. + expect(reconcile().alreadySeeded).toBe(true); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/reward/gain-value.test.ts b/apps/memos-local-plugin/tests/unit/reward/gain-value.test.ts new file mode 100644 index 000000000..615b8803e --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/reward/gain-value.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { contributionGainValues } from "../../../core/reward/gain-value.js"; + +describe("contribution gain values", () => { + it("does not count zero credit toward the multiplier", () => { + expect(contributionGainValues([0.3, 0.3, 0])).toEqual([0.6, 0.6, 0]); + }); + it("clips without claiming clipped mean conservation", () => { + expect(contributionGainValues([0.8, 0.2])).toEqual([1, 0.4]); + }); + it("preserves negative and zero scores", () => { + expect(contributionGainValues([-0.3, -0.3, 0])).toEqual([-0.6, -0.6, 0]); + expect(contributionGainValues([0, 0])).toEqual([0, 0]); + }); + it("removes equal-credit normalization dilution", () => { + for (const value of contributionGainValues(Array(200).fill(0.003))) { + expect(value).toBeCloseTo(0.6, 12); + } + }); + it("rejects invalid credit rather than converting it to neutral", () => { + expect(() => contributionGainValues([NaN])).toThrow(RangeError); + expect(() => contributionGainValues([1.1])).toThrow(RangeError); + }); + it("contributor mean equals the input mean only before clipping", () => { + // Unclipped: mean(V·N) = mean(V)·N equals R·N / N ... i.e. the helper + // preserves the per-contributor mean when nothing clips. + const unclipped = contributionGainValues([0.3, 0.3, 0]); + const unclippedMean = unclipped.reduce((a, b) => a + b, 0) / unclipped.length; + expect(unclippedMean).toBeCloseTo(0.2 * 2, 12); // (0.3·2 + 0.3·2 + 0)/3 + // Clipped: mean conservation is lost by design — gainValue≠N·V for the + // clipped member, so the group mean no longer equals R. + const clipped = contributionGainValues([0.8, 0.2]); + const clippedMean = clipped.reduce((a, b) => a + b, 0) / clipped.length; + expect(clippedMean).not.toBeCloseTo(0.5 * 2, 12); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/reward/reward.integration.test.ts b/apps/memos-local-plugin/tests/unit/reward/reward.integration.test.ts index 568874020..cfecf5a8c 100644 --- a/apps/memos-local-plugin/tests/unit/reward/reward.integration.test.ts +++ b/apps/memos-local-plugin/tests/unit/reward/reward.integration.test.ts @@ -7,6 +7,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createRewardEventBus } from "../../../core/reward/events.js"; +import { contributionGainValues } from "../../../core/reward/gain-value.js"; +// Force the contribution-gain batch to throw so +// the persist loop's failure path is exercised. Delegates to the real helper +// unless the flag is set, so every other test in this file is unaffected. +const gainFailure = vi.hoisted(() => ({ fail: false })); +vi.mock("../../../core/reward/gain-value.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + contributionGainValues: (values: readonly number[]) => { + if (gainFailure.fail) throw new RangeError("injected gain batch failure"); + return actual.contributionGainValues(values); + }, + }; +});import { runGainInference } from "../../../core/reward/gain-inference.js"; import { createRewardRunner } from "../../../core/reward/reward.js"; import type { RewardConfig, @@ -62,7 +77,12 @@ function seedEpisode( sid: string, traceIds: string[], ): void { - seedSession(handle, sid); + // Seed the session once per sid: sessions.upsert is INSERT OR REPLACE, and + // re-upserting would DELETE the session and cascade-delete every episode/ + // trace that references it. + if (!handle.repos.sessions.getById(sid as unknown as SessionRow["id"])) { + seedSession(handle, sid); + } const row: EpisodeRow & { meta: Record } = { id: eid as unknown as EpisodeRow["id"], sessionId: sid as unknown as EpisodeRow["sessionId"], @@ -440,4 +460,265 @@ describe("reward/integration", () => { expect(res.feedbackCount).toBe(2); expect(res.rHuman).toBeGreaterThan(0); }); + + it("persists gainValue with live_normalized provenance atomically alongside V", async () => { + const sid = "s_int_gain"; + const eid = "ep_int_gain"; + seedEpisode(handle, eid, sid, ["tr_a", "tr_b", "tr_c"]); + seedTrace(handle, "tr_a", eid, sid, { alpha: 1, agentText: "clone repo" }); + seedTrace(handle, "tr_b", eid, sid, { alpha: 0, agentText: "docker build" }); + seedTrace(handle, "tr_c", eid, sid, { alpha: 0, agentText: "docker push" }); + seedFeedback(handle, "fb_gain", eid, { polarity: "positive" }); + + const runner = createRewardRunner({ + tracesRepo: handle.repos.traces, + episodesRepo: handle.repos.episodes, + feedbackRepo: handle.repos.feedback, + llm: fakeLlm({ + completeJson: { + "reward.reward.r_human.v3": { + goal_achievement: 0.9, + process_quality: 0.7, + user_satisfaction: 0.8, + label: "success", + reason: "image built + pushed", + }, + }, + }), + bus: createRewardEventBus(), + cfg: cfg(), + outcomeThresholds: { successThreshold: 0.5, failureThreshold: -0.15 }, + now: () => NOW, + }); + + const result = await runner.run({ + episodeId: eid as unknown as Parameters[0]["episodeId"], + feedback: [], + trigger: "implicit_fallback", + }); + + const expected = contributionGainValues(result.backprop.updates.map((u) => u.value)); + for (let i = 0; i < result.backprop.updates.length; i++) { + const u = result.backprop.updates[i]!; + const row = handle.repos.traces.getById(u.traceId)!; + // V and gainValue persisted together, with explicit live provenance. + expect(row.value).toBeCloseTo(u.value, 10); + expect(row.gainValue).toBeCloseTo(expected[i]!, 10); + expect(row.gainValueSource).toBe("live_normalized"); + // alpha/priority semantics preserved. + expect(row.alpha).toBeCloseTo(u.alpha, 10); + expect(row.priority).toBeCloseTo(u.priority, 10); + // The result also carries the gain so reward.updated subscribers see it. + expect(u.gainValue).toBeCloseTo(expected[i]!, 10); + expect(u.gainValueSource).toBe("live_normalized"); + } + }); + + it("repeat scoring refreshes V and gainValue together", async () => { + const sid = "s_int_rescore"; + const eid = "ep_int_rescore"; + seedEpisode(handle, eid, sid, ["tr_r"]); + seedTrace(handle, "tr_r", eid, sid, { alpha: 1 }); + seedFeedback(handle, "fb_r1", eid, { polarity: "positive", rationale: "good" }); + + // Scripted scorer: first call scores high, second call scores low. + let scoreCall = 0; + const llm = fakeLlm({ + completeJson: { + "reward.reward.r_human.v3": () => { + scoreCall += 1; + if (scoreCall === 1) { + return { + goal_achievement: 0.9, + process_quality: 0.7, + user_satisfaction: 0.8, + label: "success", + reason: "ok", + }; + } + return { + goal_achievement: 0.3, + process_quality: 0.2, + user_satisfaction: 0.25, + label: "success", + reason: "weaker outcome", + }; + }, + }, + }); + const runner = createRewardRunner({ + tracesRepo: handle.repos.traces, + episodesRepo: handle.repos.episodes, + feedbackRepo: handle.repos.feedback, + llm, + bus: createRewardEventBus(), + cfg: cfg(), + now: () => NOW, + }); + + await runner.run({ + episodeId: eid as unknown as Parameters[0]["episodeId"], + feedback: [], + trigger: "implicit_fallback", + }); + const first = handle.repos.traces.getById("tr_r" as unknown as TraceRow["id"])!; + expect(first.gainValueSource).toBe("live_normalized"); + + // Second pass with a different reward: V and gain both move. + const secondResult = await runner.run({ + episodeId: eid as unknown as Parameters[0]["episodeId"], + feedback: [], + trigger: "implicit_fallback", + }); + const second = handle.repos.traces.getById("tr_r" as unknown as TraceRow["id"])!; + expect(secondResult.rHuman).toBeLessThan(first.rHuman!); + expect(second.value).toBeCloseTo(secondResult.rHuman, 5); + // gainValue tracks the rescaled V for the same contributor set. + expect(second.gainValue).toBeCloseTo(secondResult.rHuman, 5); + expect(second.gainValueSource).toBe("live_normalized"); + expect(second.gainInferenceVersion).toBe(0); + }); + + it("historical inference cannot overwrite fresh live scores", async () => { + // Episode A: scored live (gain written with live provenance). + const sid = "s_int_inf"; + const eidLive = "ep_int_inf_live"; + const eidHist = "ep_int_inf_hist"; + seedEpisode(handle, eidLive, sid, ["tr_live"]); + seedTrace(handle, "tr_live", eidLive, sid, { alpha: 1 }); + seedFeedback(handle, "fb_inf", eidLive, { polarity: "positive" }); + + const runner = createRewardRunner({ + tracesRepo: handle.repos.traces, + episodesRepo: handle.repos.episodes, + feedbackRepo: handle.repos.feedback, + llm: fakeLlm({ + completeJson: { + "reward.reward.r_human.v3": { + goal_achievement: 0.9, + process_quality: 0.7, + user_satisfaction: 0.8, + label: "success", + reason: "ok", + }, + }, + }), + bus: createRewardEventBus(), + cfg: cfg(), + now: () => NOW, + }); + const liveResult = await runner.run({ + episodeId: eidLive as unknown as Parameters[0]["episodeId"], + feedback: [], + trigger: "implicit_fallback", + }); + + // Episode B: historical (pre-gain) group with the same owner. + // seedTrace ignores value/rHuman — set them explicitly like a past pass. + seedEpisode(handle, eidHist, sid, ["tr_hist"]); + seedTrace(handle, "tr_hist", eidHist, sid, { alpha: 1 }); + handle.repos.traces.updateScore("tr_hist" as unknown as TraceRow["id"], { + value: 0.9, + alpha: 1, + rHuman: 0.9, + priority: 0.2, + }); + + runGainInference({ + db: handle.db, + kv: handle.repos.kv, + episodesRepo: handle.repos.episodes, + tracesRepo: handle.repos.traces, + owner: { ownerAgentKind: "unknown", ownerProfileId: "default", ownerWorkspaceId: null }, + }); + + const live = handle.repos.traces.getById("tr_live" as unknown as TraceRow["id"])!; + expect(live.gainValueSource).toBe("live_normalized"); + expect(live.gainValue).toBeCloseTo(liveResult.backprop.updates[0]!.gainValue ?? -1, 10); + expect(live.gainInferenceVersion).toBe(0); + + const hist = handle.repos.traces.getById("tr_hist" as unknown as TraceRow["id"])!; + expect(hist.gainValueSource).toBe("inferred_normalized"); + expect(hist.gainValue).toBeCloseTo(0.9, 10); + expect(hist.gainInferenceVersion).toBe(1); + }); + + it("a failed gain batch omits gain keys so pre-existing live provenance survives", async () => { + const sid = "s_int_gainfail"; + const eid = "ep_int_gainfail"; + seedEpisode(handle, eid, sid, ["tr_p", "tr_q"]); + seedTrace(handle, "tr_p", eid, sid, { alpha: 1 }); + seedTrace(handle, "tr_q", eid, sid, { alpha: 1 }); + seedFeedback(handle, "fb_gf", eid, { polarity: "positive" }); + // Pre-existing live provenance from an earlier successful pass (values + // deliberately distinct from anything the new run would compute). + handle.repos.traces.updateScore("tr_p" as unknown as TraceRow["id"], { + value: 0.1, + alpha: 1, + rHuman: 0.5, + priority: 0.05, + gainValue: 0.42, + gainValueSource: "live_normalized", + }); + handle.repos.traces.updateScore("tr_q" as unknown as TraceRow["id"], { + value: 0.1, + alpha: 1, + rHuman: 0.5, + priority: 0.05, + gainValue: -0.17, + gainValueSource: "live_normalized", + }); + + const runner = createRewardRunner({ + tracesRepo: handle.repos.traces, + episodesRepo: handle.repos.episodes, + feedbackRepo: handle.repos.feedback, + llm: fakeLlm({ + completeJson: { + "reward.reward.r_human.v3": { + goal_achievement: 0.9, + process_quality: 0.7, + user_satisfaction: 0.8, + label: "success", + reason: "image built + pushed", + }, + }, + }), + bus: createRewardEventBus(), + cfg: cfg(), + now: () => NOW, + }); + + gainFailure.fail = true; + let result: Awaited>; + try { + result = await runner.run({ + episodeId: eid as unknown as Parameters[0]["episodeId"], + feedback: [], + trigger: "implicit_fallback", + }); + } finally { + gainFailure.fail = false; + } + + // The gain stage warned, but V/alpha still persisted normally. + expect(result.warnings.some((w) => w.stage === "persist.traces.gain")).toBe(true); + expect(result.backprop.updates).toHaveLength(2); + for (const u of result.backprop.updates) { + const row = handle.repos.traces.getById(u.traceId)!; + expect(row.value).toBeCloseTo(u.value, 10); + expect(row.alpha).toBeCloseTo(u.alpha, 10); + // Gain keys were omitted (not explicit NULLs): prior provenance is + // untouched on every trace in the failed batch. + expect(row.gainValueSource).toBe("live_normalized"); + expect(row.gainInferenceVersion).toBe(0); + // No gain attached to the result for reward.updated subscribers either. + expect(u.gainValue).toBeUndefined(); + expect(u.gainValueSource).toBeUndefined(); + } + expect(handle.repos.traces.getById("tr_p" as unknown as TraceRow["id"])!.gainValue) + .toBeCloseTo(0.42, 10); + expect(handle.repos.traces.getById("tr_q" as unknown as TraceRow["id"])!.gainValue) + .toBeCloseTo(-0.17, 10); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/server/http.test.ts b/apps/memos-local-plugin/tests/unit/server/http.test.ts index 8291e01bd..587f170ab 100644 --- a/apps/memos-local-plugin/tests/unit/server/http.test.ts +++ b/apps/memos-local-plugin/tests/unit/server/http.test.ts @@ -71,6 +71,10 @@ function stubCore(): MemoryCore { sharePolicy: vi.fn(async (id, share) => ({ id, share } as any)), updatePolicy: vi.fn(async (id, patch) => ({ id, ...patch } as any)), editPolicyGuidance: vi.fn(async (id) => ({ id } as any)), + previewGainRepair: vi.fn(async () => ({ policies: [], total: 0 } as any)), + rollbackGainRepair: vi.fn( + async () => ({ ok: true, batchId: null, rolledBack: [], rolledBackAt: 0 } as any), + ), getWorldModel: vi.fn(async () => null), listWorldModels: vi.fn(async () => []), countWorldModels: vi.fn(async () => 0), diff --git a/apps/memos-local-plugin/tests/unit/storage/gain-repair-migration.test.ts b/apps/memos-local-plugin/tests/unit/storage/gain-repair-migration.test.ts new file mode 100644 index 000000000..450e3d6f5 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/storage/gain-repair-migration.test.ts @@ -0,0 +1,251 @@ +/** + * migration 19 is strictly schema-only: it adds gain columns and + * the repair queue/journal tables but must never touch existing data (V, + * priority, every policy field, kv/budget state) and must not seed the queue + * or reset any budget. + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + defaultMigrationsDir, + discoverMigrations, + openDb, + runMigrations, + type StorageDb, +} from "../../../core/storage/index.js"; + +describe("storage/gain-repair-migration (19)", () => { + const cleanups: Array<() => void> = []; + + afterEach(() => { + while (cleanups.length) cleanups.pop()!(); + }); + + function tmpDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "memos-gain-19-")); + cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; + } + + /** + * A migrations dir holding every shipped migration EXCEPT 19, so the DB + * can be brought to a genuine pre-gain schema with real data in place. + */ + function pre907MigrationsDir(): string { + const src = defaultMigrationsDir(); + const dir = tmpDir(); + for (const name of fs.readdirSync(src)) { + if (!/^(\d{3})-/.test(name)) continue; + if (name === "019-policy-gain-value.sql") continue; + fs.copyFileSync(path.join(src, name), path.join(dir, name)); + } + return dir; + } + + function columnsOf(db: StorageDb, table: string): string[] { + return db + .prepare(`PRAGMA table_info(${table})`) + .all() + .map((r) => r.name); + } + + function seedData(db: StorageDb): { + traceValues: Array<{ id: string; value: number; alpha: number; r_human: number | null; priority: number }>; + policyFields: { support: number; gain: number; status: string; title: string }; + } { + db.exec(` + INSERT INTO sessions(id, agent, started_at, last_seen_at) VALUES ('s907','openclaw',1,1); + INSERT INTO episodes(id, session_id, started_at, status, trace_ids_json) + VALUES ('ep907','s907',1,'closed','["tr907_a","tr907_b"]'); + INSERT INTO traces(id, episode_id, session_id, ts, user_text, agent_text, turn_id, + value, alpha, r_human, priority) + VALUES ('tr907_a','ep907','s907',1,'u','a',0, 0.30, 0.5, 0.90, 0.3), + ('tr907_b','ep907','s907',2,'u','a',1, -0.15, 0.5, -0.45, 0.0); + INSERT INTO policies(id, title, trigger, procedure, verification, boundary, + support, gain, status, induced_by, created_at, updated_at) + VALUES ('pol907','title','trigger','procedure','verification','boundary', + 3, 0.05, 'active', 'manual', 1, 2); + INSERT INTO kv(key, value_json, updated_at) + VALUES ('gain_repair_budget.ep907', '{"attempted":7,"limit":25}', 1); + `); + const traceValues = db + .prepare( + `SELECT id, value, alpha, r_human, priority FROM traces ORDER BY id`, + ) + .all(); + const policy = db + .prepare( + `SELECT support, gain, status, title FROM policies WHERE id='pol907'`, + ) + .get()!; + return { traceValues, policyFields: policy }; + } + + it("reserves version 19 (no duplicates, 19 highest)", () => { + const versions = discoverMigrations(defaultMigrationsDir()).map((f) => f.version); + expect(versions).toContain(19); + expect(versions.filter((v) => v === 19)).toHaveLength(1); + expect(Math.max(...versions)).toBe(19); + }); + + it("adds gain columns/tables without touching V, priority, policy fields, kv or queue state", () => { + const dir = pre907MigrationsDir(); + const filepath = path.join(dir, "pre907.db"); + const db = openDb({ filepath, agent: "openclaw" }); + try { + runMigrations(db, dir); + const before = seedData(db); + const kvBefore = db + .prepare(`SELECT key, value_json FROM kv ORDER BY key`) + .all(); + + // Sanity: pre-19 schema really lacks the new columns/tables. + expect(columnsOf(db, "traces")).not.toContain("gain_value"); + expect(columnsOf(db, "policies")).not.toContain("gain_version"); + + const result = runMigrations(db); + expect(result.applied.map((m) => m.version)).toContain(19); + + // ── New columns exist with the right defaults ──────────────────────── + const traceCols = columnsOf(db, "traces"); + expect(traceCols).toContain("gain_value"); + expect(traceCols).toContain("gain_value_source"); + expect(traceCols).toContain("gain_inference_version"); + const policyCols = columnsOf(db, "policies"); + expect(policyCols).toContain("gain_version"); + + // NULL is unresolved, not neutral zero. + const traceDefaults = db + .prepare( + `SELECT gain_value, gain_value_source, gain_inference_version FROM traces WHERE id='tr907_a'`, + ) + .get()!; + expect(traceDefaults.gain_value).toBeNull(); + expect(traceDefaults.gain_value_source).toBeNull(); + expect(traceDefaults.gain_inference_version).toBe(0); + const policyDefault = db + .prepare( + `SELECT gain_version FROM policies WHERE id='pol907'`, + ) + .get()!; + expect(policyDefault.gain_version).toBe(1); + + // ── Queue / journal tables exist, namespaced, and empty ────────────── + const tables = db + .prepare( + `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name`, + ) + .all() + .map((r) => r.name); + expect(tables).toContain("gain_repair_queue"); + expect(tables).toContain("gain_repair_journal"); + expect(tables.some((t) => /budget/i.test(t))).toBe(false); // no budget table + for (const t of ["gain_repair_queue", "gain_repair_journal"]) { + const cols = columnsOf(db, t); + expect(cols).toContain("owner_agent_kind"); + expect(cols).toContain("owner_profile_id"); + expect(cols).toContain("owner_workspace_id"); + } + const queueCount = db + .prepare(`SELECT COUNT(*) AS n FROM gain_repair_queue`) + .get()!.n; + expect(queueCount).toBe(0); // schema only — no seeding in SQL + + // ── Existing data is byte-for-byte unchanged ───────────────────────── + const after = db + .prepare( + `SELECT id, value, alpha, r_human, priority FROM traces ORDER BY id`, + ) + .all(); + expect(after).toEqual(before.traceValues); + const policy = db + .prepare( + `SELECT support, gain, status, title FROM policies WHERE id='pol907'`, + ) + .get()!; + expect(policy).toEqual(before.policyFields); + const kvAfter = db + .prepare(`SELECT key, value_json FROM kv ORDER BY key`) + .all(); + expect(kvAfter).toEqual(kvBefore); // budget state untouched, no reset + } finally { + db.close(); + } + }); + + it("is idempotent: a second run applies nothing and keeps data intact", () => { + const dir = pre907MigrationsDir(); + const filepath = path.join(dir, "idem.db"); + const db = openDb({ filepath, agent: "openclaw" }); + try { + runMigrations(db, dir); + seedData(db); + runMigrations(db); + const second = runMigrations(db); + expect(second.applied).toHaveLength(0); + const rows = db + .prepare(`SELECT COUNT(*) AS n FROM gain_repair_queue`) + .get()!.n; + expect(rows).toBe(0); + } finally { + db.close(); + } + }); + + it("19 ships the nullable new_updated_at journal column; rows without it stay NULL and data is untouched", () => { + const dir = pre907MigrationsDir(); + const filepath = path.join(dir, "post907.db"); + const db = openDb({ filepath, agent: "openclaw" }); + try { + runMigrations(db, dir); + const before = seedData(db); + // Sanity: the pre-19 schema has no journal table at all (it is new in 19). + const preTables = db + .prepare( + `SELECT name FROM sqlite_master WHERE type='table' AND name='gain_repair_journal'`, + ) + .all(); + expect(preTables).toHaveLength(0); + // Apply the real 19 (folded post-write timestamp included). + const upgraded = runMigrations(db); + expect(upgraded.applied.map((m) => m.version)).toContain(19); + expect(columnsOf(db, "gain_repair_journal")).toContain("new_updated_at"); + // A journal row that never records the post-write timestamp… + db.exec(` + INSERT INTO gain_repair_journal(id, batch_id, owner_agent_kind, owner_profile_id, + policy_id, old_gain, new_gain, old_gain_version, new_gain_version, + old_status, new_status, old_support, new_support, result, created_at) + VALUES ('jj907','gr_907','openclaw','default','pol907', + 0.05, 0.5, 1, 2, 'candidate', 'active', 3, 3, 'completed', 2); + `); + // …stays NULL (NOT rollback-eligible: the post-write timestamp was + // never recorded), while a recorded timestamp round-trips. + const row = db + .prepare( + `SELECT new_updated_at, new_gain FROM gain_repair_journal WHERE id='jj907'`, + ) + .get()!; + expect(row.new_updated_at).toBeNull(); + expect(row.new_gain).toBe(0.5); + db.exec(`UPDATE gain_repair_journal SET new_updated_at = 99 WHERE id='jj907'`); + const stamped = db + .prepare( + `SELECT new_updated_at FROM gain_repair_journal WHERE id='jj907'`, + ) + .get()!; + expect(stamped.new_updated_at).toBe(99); + const policy = db + .prepare( + `SELECT support, gain, status, title FROM policies WHERE id='pol907'`, + ) + .get()!; + expect(policy).toEqual(before.policyFields); + } finally { + db.close(); + } + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/storage/owner-exact.test.ts b/apps/memos-local-plugin/tests/unit/storage/owner-exact.test.ts new file mode 100644 index 000000000..97e1d1893 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/storage/owner-exact.test.ts @@ -0,0 +1,81 @@ +/** + * Lock the semantics of the ONE shared + * exact-owner predicate (`isExactOwner` in `core/storage/repos/_helpers.ts`) + * reused by the evidence union reconcile and the preview/rollback paths. + * + * No semantics change from the two hand-written copies it replaced: + * NULL-tolerant `??` fallbacks (unknown/default/NULL) and NULL-exact + * workspace matching (Gate 2 — NULL never acts as a wildcard). + */ + +import { describe, expect, it } from "vitest"; + +import { isExactOwner } from "../../../core/storage/repos/_helpers.js"; + +describe("storage/repos — isExactOwner (shared exact-namespace predicate)", () => { + it("matches an identical triple", () => { + expect( + isExactOwner( + { ownerAgentKind: "openclaw", ownerProfileId: "default", ownerWorkspaceId: "ws_a" }, + { ownerAgentKind: "openclaw", ownerProfileId: "default", ownerWorkspaceId: "ws_a" }, + ), + ).toBe(true); + }); + + it("applies NULL-tolerant fallbacks (unknown/default/NULL)", () => { + expect( + isExactOwner( + {}, + { ownerAgentKind: "unknown", ownerProfileId: "default", ownerWorkspaceId: null }, + ), + ).toBe(true); + expect( + isExactOwner( + { ownerAgentKind: null, ownerProfileId: null, ownerWorkspaceId: null }, + { ownerAgentKind: "unknown", ownerProfileId: "default" }, + ), + ).toBe(true); + }); + + it("rejects kind/profile mismatches", () => { + const row = { ownerAgentKind: "openclaw", ownerProfileId: "default", ownerWorkspaceId: null }; + expect(isExactOwner(row, { ownerAgentKind: "hermes", ownerProfileId: "default" })).toBe(false); + expect(isExactOwner(row, { ownerAgentKind: "openclaw", ownerProfileId: "other" })).toBe(false); + }); + + it("is workspace-exact: NULL never matches a named workspace and vice versa", () => { + expect( + isExactOwner( + { ownerAgentKind: "openclaw", ownerProfileId: "default", ownerWorkspaceId: null }, + { ownerAgentKind: "openclaw", ownerProfileId: "default", ownerWorkspaceId: "ws_a" }, + ), + ).toBe(false); + expect( + isExactOwner( + { ownerAgentKind: "openclaw", ownerProfileId: "default", ownerWorkspaceId: "ws_a" }, + { ownerAgentKind: "openclaw", ownerProfileId: "default", ownerWorkspaceId: null }, + ), + ).toBe(false); + expect( + isExactOwner( + { ownerAgentKind: "openclaw", ownerProfileId: "default", ownerWorkspaceId: "ws_a" }, + { ownerAgentKind: "openclaw", ownerProfileId: "default", ownerWorkspaceId: "ws_b" }, + ), + ).toBe(false); + }); + + it("keeps empty strings as-is (??, not ||): '' never falls back to 'unknown'", () => { + expect( + isExactOwner( + { ownerAgentKind: "", ownerProfileId: "default", ownerWorkspaceId: null }, + { ownerAgentKind: "unknown", ownerProfileId: "default", ownerWorkspaceId: null }, + ), + ).toBe(false); + expect( + isExactOwner( + { ownerAgentKind: "", ownerProfileId: "default", ownerWorkspaceId: null }, + { ownerAgentKind: "", ownerProfileId: "default", ownerWorkspaceId: null }, + ), + ).toBe(true); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/storage/repos.test.ts b/apps/memos-local-plugin/tests/unit/storage/repos.test.ts index ea1904901..ae64eafb3 100644 --- a/apps/memos-local-plugin/tests/unit/storage/repos.test.ts +++ b/apps/memos-local-plugin/tests/unit/storage/repos.test.ts @@ -234,6 +234,7 @@ describe("storage/repos — happy paths", () => { repos.policies.updateStats("p_cand", { support: 3, gain: 0.2, + gainVersion: 1, status: "candidate", updatedAt: 2, }); diff --git a/apps/memos-local-plugin/tests/unit/storage/traces-count.test.ts b/apps/memos-local-plugin/tests/unit/storage/traces-count.test.ts index 888d691aa..ee97f1ab6 100644 --- a/apps/memos-local-plugin/tests/unit/storage/traces-count.test.ts +++ b/apps/memos-local-plugin/tests/unit/storage/traces-count.test.ts @@ -36,7 +36,10 @@ describe("traces count with > 500 items", () => { share_target TEXT, shared_at INTEGER, turn_id INTEGER NOT NULL DEFAULT 0, - schema_version INTEGER NOT NULL DEFAULT 1 + schema_version INTEGER NOT NULL DEFAULT 1, + gain_value REAL, + gain_value_source TEXT, + gain_inference_version INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX idx_traces_ts ON traces(ts); CREATE INDEX idx_traces_episode_turn ON traces(episode_id, turn_id, ts); diff --git a/apps/memos-local-plugin/tests/unit/storage/traces-listall.test.ts b/apps/memos-local-plugin/tests/unit/storage/traces-listall.test.ts index 7d0e3839f..f4a882f2b 100644 --- a/apps/memos-local-plugin/tests/unit/storage/traces-listall.test.ts +++ b/apps/memos-local-plugin/tests/unit/storage/traces-listall.test.ts @@ -52,7 +52,10 @@ describe("traces.listAllForEpisode — uncapped episode fetch (#2076)", () => { share_target TEXT, shared_at INTEGER, turn_id INTEGER NOT NULL DEFAULT 0, - schema_version INTEGER NOT NULL DEFAULT 1 + schema_version INTEGER NOT NULL DEFAULT 1, + gain_value REAL, + gain_value_source TEXT, + gain_inference_version INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX idx_traces_episode_ts ON traces(episode_id, ts); `); @@ -172,7 +175,10 @@ describe("traces.listDedupRowsForEpisode — narrow-projection dedup helper (#20 share_target TEXT, shared_at INTEGER, turn_id INTEGER NOT NULL DEFAULT 0, - schema_version INTEGER NOT NULL DEFAULT 1 + schema_version INTEGER NOT NULL DEFAULT 1, + gain_value REAL, + gain_value_source TEXT, + gain_inference_version INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX idx_traces_episode_ts_dedup ON traces(episode_id, ts); `);