diff --git a/Memory/src/algorithm/plugin-algorithms.ts b/Memory/src/algorithm/plugin-algorithms.ts index 54c89a1dd..d61973b0d 100644 --- a/Memory/src/algorithm/plugin-algorithms.ts +++ b/Memory/src/algorithm/plugin-algorithms.ts @@ -4426,6 +4426,13 @@ export function signatureFromTraceParts( return signatureFromTraceLike(tags, toolCalls, reflection); } +export function isBucketableSignature(signature: string): boolean { + return signature + .split("|") + .map((part) => part.trim()) + .some((part) => part.length > 0 && part !== "_"); +} + export function l2CandidateSignatureHash(signature: string): string { let hash = 5381; for (let index = 0; index < signature.length; index += 1) { diff --git a/Memory/src/service/evolution/policy-induction.ts b/Memory/src/service/evolution/policy-induction.ts index 6040f1665..909d577cb 100644 --- a/Memory/src/service/evolution/policy-induction.ts +++ b/Memory/src/service/evolution/policy-induction.ts @@ -2,6 +2,7 @@ import { L2_INDUCTION_PROMPT, buildPolicyDraft, detectDominantLanguage, + isBucketableSignature, l2CandidateIdFor, languageSteeringLine, packL2InductionTraces, @@ -192,6 +193,14 @@ export class PolicyInductionEngine { ); for (const signature of signatures) { + if (!isBucketableSignature(signature)) { + logEvolutionDecision(job, "l2_induction", "gate_not_met", { + sourceMemoryId: source.id, + reason: "non_discriminative_signature", + signature + }); + continue; + } const bucket = uniq( pendingCandidates .filter((candidate) => candidate.candidateKey === signature) @@ -796,6 +805,7 @@ export class PolicyInductionEngine { signature: string, at: string ): void { + if (!isBucketableSignature(signature)) return; const id = l2CandidateIdFor(signature, trace.id); this.deps.repos.runtime.upsertCandidatePoolTrace({ id, @@ -823,7 +833,8 @@ export class PolicyInductionEngine { trace.memory.properties.internal_info.evidence_status !== "provisional" && trace.memory.properties.internal_info.evidence_status !== "disputed" && trace.value >= this.deps.config.algorithm.l2Induction.minTraceValue && - Boolean(trace.vecSummary ?? trace.vecAction); + Boolean(trace.vecSummary ?? trace.vecAction) && + isBucketableSignature(signatureFromTrace(trace)); } private markCandidatePoolPromoted( diff --git a/Memory/tests/algorithm/plugin-algorithms.test.ts b/Memory/tests/algorithm/plugin-algorithms.test.ts index 1d6552ab2..03b94cf1c 100644 --- a/Memory/tests/algorithm/plugin-algorithms.test.ts +++ b/Memory/tests/algorithm/plugin-algorithms.test.ts @@ -28,6 +28,7 @@ import { retrievePluginMemories, retrievalLayersForMode, retrievalLayersForProfile, + isBucketableSignature, signatureFromTraceParts, traceMetaFromMemory, type PolicyMemoryMeta, @@ -1037,6 +1038,18 @@ describe("plugin algorithm parity helpers", () => { }], "reflection saw EXIT_2")).toBe("shell|_|shell|EXIT_2"); }); + it("keeps empty no-tool signatures unbucketable and leaves tagged no-tool signatures bucketable", () => { + expect(signatureFromTraceParts([], [], "")).toBe("_|_|_|_"); + expect(signatureFromTraceParts([], [], "你好,今天天气怎么样")).toBe("_|_|_|_"); + expect(isBucketableSignature("_|_|_|_")).toBe(false); + expect(isBucketableSignature("")).toBe(false); + expect(isBucketableSignature("_|_|_")).toBe(false); + expect(isBucketableSignature("error|python|_|_")).toBe(true); + expect(isBucketableSignature("compact|summary|_|_")).toBe(true); + expect(isBucketableSignature("python|pytest|pytest|EXIT_1")).toBe(true); + expect(signatureFromTraceParts(["error", "python"], [], "")).toBe("error|python|_|_"); + }); + it("uses the plugin skill lifecycle prior when resolving trials", () => { expect(skillEtaAfterTrial({ currentEta: 0.4, diff --git a/Memory/tests/service/evolution/policy-induction.test.ts b/Memory/tests/service/evolution/policy-induction.test.ts index 8ef7ba6e9..1e8d9022c 100644 --- a/Memory/tests/service/evolution/policy-induction.test.ts +++ b/Memory/tests/service/evolution/policy-induction.test.ts @@ -1218,6 +1218,112 @@ describe("MemoryService / evolution / policy induction", () => { db.close(); }); + + it("does not bucket fully wildcard L1 signatures into the L2 candidate pool", async () => { + const { db, service } = createTestService({ + skillLlm: createCapturingL2Llm([]), + config: { + ...DEFAULT_MEMMY_CONFIG, + algorithm: { + ...DEFAULT_MEMMY_CONFIG.algorithm, + l2Induction: { + ...DEFAULT_MEMMY_CONFIG.algorithm.l2Induction, + minEpisodesForInduction: 1, + minGain: -1 + } + } + } + }); + const session = service.openSession({ + namespace: { + source: "codex", + profileId: "jiang", + userId: "wildcard-l1-user" + }, + workspaceId: "wildcard-l1-workspace" + }); + const first = service.completeTurn("turn-wildcard-email", { + sessionId: session.sessionId, + episodeId: "episode-wildcard-email", + query: "帮我写一封感谢客户的邮件", + answer: "感谢您一直以来的支持,后续我们会继续跟进。" + }); + const second = service.completeTurn("turn-wildcard-report", { + sessionId: session.sessionId, + episodeId: "episode-wildcard-report", + query: "把这三点整理成一段给领导的汇报", + answer: "本周已完成客户回访、方案修订和交付排期。" + }); + const preference = service.completeTurn("turn-wildcard-preference", { + sessionId: session.sessionId, + episodeId: "episode-wildcard-preference", + query: "我喜欢吃苹果", + answer: "记下了,你喜欢吃苹果。" + }); + expect(first.l1MemoryId).toBeTruthy(); + expect(second.l1MemoryId).toBeTruthy(); + + for (const turn of [first, second]) { + makeTraceEligibleForL2(db, turn.l1MemoryId); + setTraceSignatureAndVectorForTest(db, turn.l1MemoryId, "_|_|_|_", [1, 0, 0]); + await addPositiveFeedbackForTurn(service, session.sessionId, turn); + } + service.closeSession(session.sessionId); + await runWorkerRounds(service, 8, 50); + + expect(traceSignatureForTest(db, first.l1MemoryId)).toBe("_|_|_|_"); + expect(traceSignatureForTest(db, second.l1MemoryId)).toBe("_|_|_|_"); + expect(db.db.prepare(`SELECT status FROM memories WHERE id = ?`).get(first.l1MemoryId)) + .toEqual({ status: "activated" }); + const userMemories = db.db.prepare( + `SELECT content FROM user_memories WHERE status = 'active' ORDER BY created_at` + ).all() as Array<{ content: string }>; + expect(userMemories.map((memory) => memory.content)).toEqual(["我喜欢吃苹果"]); + expect(preference.userMemoryIds.length).toBeGreaterThan(0); + expect(db.db.prepare( + `SELECT COUNT(*) AS count FROM l2_candidate_pool WHERE candidate_key = '_|_|_|_'` + ).get()).toEqual({ count: 0 }); + expect(db.db.prepare( + `SELECT COUNT(*) AS count FROM memories WHERE memory_layer = 'L2'` + ).get()).toEqual({ count: 0 }); + + const at = new Date().toISOString(); + db.db.prepare( + `INSERT INTO l2_candidate_pool ( + id, user_id, session_id, source_memory_id, candidate_key, + candidate_value, score, status, evidence_json, created_at, updated_at, expires_at + ) VALUES (?, ?, ?, ?, '_|_|_|_', 'stale wildcard', 1, 'pending', '[]', ?, ?, ?)` + ).run( + l2CandidateIdFor("_|_|_|_", first.l1MemoryId), + "wildcard-l1-user", + session.sessionId, + first.l1MemoryId, + at, + at, + new Date(Date.parse(at) + 86_400_000).toISOString() + ); + db.db.prepare( + `INSERT INTO evolution_jobs ( + id, job_type, status, user_id, session_id, episode_id, target_memory_id, + payload_json, attempts, max_attempts, created_at, updated_at + ) VALUES (?, 'l2_induction', 'queued', ?, ?, ?, ?, ?, 0, 3, ?, ?)` + ).run( + "job_wildcard_l2", + "wildcard-l1-user", + session.sessionId, + first.episodeId, + first.l1MemoryId, + JSON.stringify({ sourceMemoryId: first.l1MemoryId }), + at, + at + ); + await runWorkerRounds(service, 4, 20); + + expect(db.db.prepare( + `SELECT COUNT(*) AS count FROM memories WHERE memory_layer = 'L2'` + ).get()).toEqual({ count: 0 }); + db.close(); + }); }); function createBc08SummaryLlm(): LlmClient {