fix(track-d 3): MemoryExtractionOrchestrator passes real priorFacts to extractor

Pre-D-3, `MemoryExtractionOrchestrator.process(_:)` hardcoded
`priorFacts: []` on every job. The mem0 extractor was therefore blind
to existing facts and could only emit ADD ops — UPDATE/supersede
never fired, so superseded values silently duplicated on every turn.

This commit:
- Adds `PriorFactsLookup` typealias and `priorFactsLookup` parameter on
  `MemoryExtractionOrchestrator.init`. Default is `emptyPriorFactsLookup`
  to preserve pre-D-3 behavior for tests that don't care about priors.
- Wires `process(_:)` to call `priorFactsLookup(job)`. Lookup throws
  degrade to `priorFacts = []` with a warning log — the drain task
  must never die because a single SQL read failed.
- Adds `MemoryStore.recentActiveFacts(limit:)` + matching SQL in
  `MemoryQueries.recentActiveFactsSQL`. Active-only (`valid_to IS NULL
  AND forgotten_at IS NULL`), most-recently-created first, server-side
  cap. D-02 forgotten facts excluded.
- AppDelegate's `installMemory` passes a closure that returns up to 50
  recent active facts. 50 is a deliberate cap — RESEARCH §5 caps the
  rendered priorFacts block at ~4 KB (~50 facts at typical S/P/O
  length). Closure returns [] when `memoryStore` is nil, preserving
  the pre-D-3 no-store path.

Tests:
- `testD3_priorFactsLookupDrivesUpdateOp` — stub provider that emits
  UPDATE when it sees a `[99]` prior fact id in the system prompt;
  asserts `applyOp` receives `.update(supersedes: 99, …)`. Pre-D-3
  this would have been `.add` because the orchestrator never told
  the model about the prior fact.
- `testD3_priorFactsLookupErrorDegradesToEmpty` — lookup throws,
  job still applies its ADD op (drain survives the failure).

T-06-05-03 invariant respected: warnings log turnId only, never
fact content.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Opus 4.7 committed 2026-05-04 14:22:42 -05:00
1 parent 707c45b355
commit 51750c161b
5 files changed
+207 -3

No files matched your search

+10
View File
@@ -1026,6 +1026,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// drops the op instead of throwing — the drain loop survives
// so a future store reconstruction (e.g., installMemory retry)
// can replace this orchestrator if needed.
// Track-D D-3: priorFactsLookup returns up to 50 recent active
// facts so the extractor can emit UPDATE ops (mem0 supersede
// pattern) instead of always ADDing. When `store` is nil the
// closure returns []; pre-D-3 behavior preserved on the no-store
// path. 50 is a deliberate cap — RESEARCH §5 caps the rendered
// priorFacts block at ~4 KB (~50 facts at typical S/P/O length).
let logger = self.systemLogger
let memoryOrch = MemoryExtractionOrchestrator(
extractor: extractor,
@@ -1037,6 +1043,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// and the turnId hash, never the subject/object content.
logger?.debug("memory applyOp dropped (no store): turnId=\(turnId)")
}
},
priorFactsLookup: { [weak self] _ in
guard let store = await self?.memoryStore else { return [] }
return try await store.recentActiveFacts(limit: 50)
}
)
await memoryOrch.start()
@@ -32,19 +32,43 @@ public actor MemoryExtractionOrchestrator {
/// producer.
public typealias ApplyOp = @Sendable (MemoryOp, Int64) async throws -> Void
/// Track-D D-3: priorFacts feed for the mem0 extractor.
///
/// Pre-D-3, `process(_:)` hardcoded `priorFacts: []` — every fact got
/// ADDed, none ever got UPDATEd, defeating mem0's whole supersede
/// design. The closure now resolves prior active facts for each job;
/// production passes a closure that returns `store.recentActiveFacts(
/// limit: 50)` (capped server-side in SQL). Tests pass static `[Fact]`
/// arrays.
///
/// The job is passed in case future implementations want subject- or
/// session-scoped shortlisting (Plan 07-03 deferred work). Today the
/// simplest impl ignores the job and returns the recent slice.
public typealias PriorFactsLookup = @Sendable (ExtractionJob) async throws -> [Fact]
/// Default priorFactsLookup — empty list. Preserves pre-D-3 behavior
/// for callers that don't need supersede semantics (most tests).
public static let emptyPriorFactsLookup: PriorFactsLookup = { _ in [] }
private let channel: BoundedAsyncChannel<ExtractionJob>
private let extractor: MemoryExtractor
private let applyOp: ApplyOp
private let priorFactsLookup: PriorFactsLookup
private let logger: Logger
private var drainTask: Task<Void, Never>?
public init(extractor: MemoryExtractor, applyOp: @escaping ApplyOp) {
public init(
extractor: MemoryExtractor,
applyOp: @escaping ApplyOp,
priorFactsLookup: @escaping PriorFactsLookup = MemoryExtractionOrchestrator.emptyPriorFactsLookup
) {
self.channel = BoundedAsyncChannel<ExtractionJob>(
capacity: Self.channelCapacity,
policy: .dropOldest
)
self.extractor = extractor
self.applyOp = applyOp
self.priorFactsLookup = priorFactsLookup
self.logger = Logger(label: "memory.orchestrator")
}
@@ -79,8 +103,17 @@ public actor MemoryExtractionOrchestrator {
// Best-effort. Errors are logged and never propagate — extraction
// must never affect the producer's turnEnd path.
do {
// Plan 07-03 will replace [] with FTS5-shortlisted prior facts.
let priorFacts: [Fact] = []
// Track-D D-3: real prior facts so the extractor can emit UPDATE
// ops (mem0 supersede pattern) instead of always ADDing. Lookup
// failure degrades to empty list — extraction continues with
// no prior context (pre-D-3 behavior) rather than dying.
let priorFacts: [Fact]
do {
priorFacts = try await priorFactsLookup(job)
} catch {
logger.warning("priorFactsLookup failed for turnId=\(job.turnId.rawValue): \(error) — proceeding with empty priorFacts")
priorFacts = []
}
let ops = try await extractor.extract(
userText: job.userText,
assistantText: job.assistantText,
@@ -92,4 +92,18 @@ public enum MemoryQueries {
AND forgotten_at IS NULL
ORDER BY created_at DESC;
"""
/// Track-D D-3: recent active facts feed for the mem0 extractor's
/// priorFacts context. Used by `MemoryExtractionOrchestrator.process`
/// to give the model UPDATE-vs-ADD information without doing per-job
/// FTS5 shortlisting (Plan 07-03 deferred work). Bindings: limit.
public static let recentActiveFactsSQL: String = """
SELECT id, subject, predicate, object, source_turn_id, valid_from, valid_to,
superseded_by, forgotten_at, created_at
FROM facts
WHERE valid_to IS NULL
AND forgotten_at IS NULL
ORDER BY created_at DESC
LIMIT ?;
"""
}
@@ -298,6 +298,31 @@ public actor MemoryStore {
}
}
/// Track-D D-3: recent active facts (most-recently-created first), capped
/// at `limit`. Source for the mem0 extractor's priorFacts context so the
/// model can decide UPDATE-vs-ADD against an existing fact rather than
/// silently duplicating it on every turn. D-02 forgotten facts excluded.
/// Cap is enforced server-side in SQL — never returns more than `limit`
/// rows even with a misbehaving caller.
public func recentActiveFacts(limit: Int) throws -> [Fact] {
try conn.query(MemoryQueries.recentActiveFactsSQL, bindings: [
.int(Int64(max(0, limit))),
]) { stmt in
Fact(
id: stmt.columnInt(at: 0),
subject: stmt.columnText(at: 1) ?? "",
predicate: stmt.columnText(at: 2) ?? "",
object: stmt.columnText(at: 3) ?? "",
sourceTurnId: stmt.columnIsNull(at: 4) ? nil : stmt.columnInt(at: 4),
validFrom: stmt.columnInt(at: 5),
validTo: stmt.columnIsNull(at: 6) ? nil : stmt.columnInt(at: 6),
supersededBy: stmt.columnIsNull(at: 7) ? nil : stmt.columnInt(at: 7),
forgottenAt: stmt.columnIsNull(at: 8) ? nil : stmt.columnInt(at: 8),
createdAt: stmt.columnInt(at: 9)
)
}
}
/// Active facts (valid_to IS NULL AND forgotten_at IS NULL) for a given
/// subject. D-02 forgotten facts excluded.
public func queryActiveFacts(matching subject: String) throws -> [Fact] {
@@ -179,6 +179,128 @@ final class MemoryExtractionOrchestratorTests: XCTestCase {
await orchestrator.shutdown()
}
// MARK: - Track-D D-3 (priorFacts wiring)
/// D-3: when `priorFactsLookup` returns a non-empty list, the extractor
/// receives those priors and the model is free to emit UPDATE (instead
/// of always ADDing). This test drives the path with a stub provider
/// that, when it sees prior facts in the system prompt, returns an
/// UPDATE op referencing the prior fact's id. Pre-D-3 this was
/// impossible — the orchestrator hardcoded `priorFacts: []` so the
/// model never knew prior facts existed and therefore never UPDATEd.
func testD3_priorFactsLookupDrivesUpdateOp() async throws {
// Stub provider that emits an UPDATE op with supersedes_fact_id=99
// when it sees "[99]" in the system prompt (the priorFacts renderer
// formats each prior fact as "[id] subject predicate object").
// Otherwise it ADDs.
final class PriorAwareProvider: LLMProvider, @unchecked Sendable {
func stream(
messages: [LLMMessage],
tools: [ToolSchema],
toolChoice: ToolChoice,
model: ModelID,
maxOutputTokens: Int,
cacheHints: CacheHints?
) -> AsyncThrowingStream<LLMEvent, Error> {
let systemText = messages
.filter { $0.role == .system }
.flatMap { $0.content }
.compactMap { block -> String? in
if case let .text(s) = block { return s }
return nil
}
.joined(separator: "\n")
let sawPriors = systemText.contains("[99]")
return AsyncThrowingStream { cont in
Task {
let ops: [[String: Any]]
if sawPriors {
ops = [[
"op": "UPDATE",
"supersedes_fact_id": 99,
"subject": "Sarah",
"predicate": "works_at",
"object": "Initech",
]]
} else {
ops = [[
"op": "ADD",
"subject": "Sarah",
"predicate": "works_at",
"object": "Initech",
]]
}
let json = try! JSONSerialization.data(withJSONObject: ["ops": ops])
cont.yield(.toolUseRequested(
ToolUseRequest(id: "t-d3", name: "apply_memory_ops", argsJSON: json)
))
cont.yield(.messageStop)
cont.finish()
}
}
}
}
let prior = Fact(
id: 99, subject: "Sarah", predicate: "works_at", object: "Acme",
sourceTurnId: 1, validFrom: 1, createdAt: 1
)
let extractor = MemoryExtractor(provider: PriorAwareProvider())
let spy = SpyApplyOp()
let orchestrator = MemoryExtractionOrchestrator(
extractor: extractor,
applyOp: { op, turnId in spy.record(op, sourceTurnId: turnId) },
priorFactsLookup: { _ in [prior] }
)
await orchestrator.start()
await orchestrator.enqueue(ExtractionJob(
turnId: TurnID(rawValue: "turn-d3"),
userText: "Sarah now works at Initech.",
assistantText: "Got it."
))
try await Task.sleep(nanoseconds: 250_000_000)
XCTAssertEqual(spy.calls.count, 1, "applyOp must be invoked exactly once")
guard case let .update(supersedes, s, p, o, _) = spy.calls.first?.op else {
return XCTFail("D-3: priorFacts presence must enable UPDATE op; got \(String(describing: spy.calls.first?.op))")
}
XCTAssertEqual(supersedes, 99, "UPDATE must reference the prior fact's id")
XCTAssertEqual(s, "Sarah")
XCTAssertEqual(p, "works_at")
XCTAssertEqual(o, "Initech")
await orchestrator.shutdown()
}
/// D-3: when `priorFactsLookup` throws, the orchestrator degrades to
/// empty priors and still processes the job — the lookup failure must
/// never be a death sentence for the drain task.
func testD3_priorFactsLookupErrorDegradesToEmpty() async throws {
struct LookupError: Error {}
let provider = TimedMockProvider()
provider.argsJSON = try JSONSerialization.data(withJSONObject: ["ops": [
["op": "ADD", "subject": "X", "predicate": "Y", "object": "Z"]
]])
let extractor = MemoryExtractor(provider: provider)
let spy = SpyApplyOp()
let orchestrator = MemoryExtractionOrchestrator(
extractor: extractor,
applyOp: { op, turnId in spy.record(op, sourceTurnId: turnId) },
priorFactsLookup: { _ in throw LookupError() }
)
await orchestrator.start()
await orchestrator.enqueue(ExtractionJob(
turnId: TurnID(rawValue: "turn-d3-err"),
userText: "u", assistantText: "a"
))
try await Task.sleep(nanoseconds: 250_000_000)
XCTAssertEqual(spy.calls.count, 1,
"D-3: lookup errors must degrade to empty priors, never abort the job")
await orchestrator.shutdown()
}
/// Process applies returned ops to the store via the callback.
func testProcessAppliesOpsToStore() async throws {
let provider = TimedMockProvider()