Packages

fnord

0.9.11

AI code archaeology

Current section

Files

Jump to
fnord lib services memory_consolidation.ex
Raw

lib/services/memory_consolidation.ex

defmodule Services.MemoryConsolidation do
@moduledoc """
Short-lived GenServer that coordinates memory consolidation within a single
scope. Owns the precomputed similarity matrix and the set of remaining
memories, serving as the single-threaded source of truth for which memories
are still available.
Workers call `checkout/1` to get a focus memory and its live candidates,
then `complete/3` to report which memories were consumed. The pool filters
candidates against `remaining` at checkout time, so workers always get a
fresh view of what's available.
Started per-scope by `Memory.Consolidator` and stopped when the
consolidation pass is complete.
"""
use GenServer
# Cosine similarity floor. Candidates below this score are not sent to the
# agent at all -- they're too dissimilar to be worth evaluating.
@similarity_floor 0.25
# Maximum candidates to send per focus memory.
@max_candidates 10
# --------------------------------------------------------------------------
# State
# --------------------------------------------------------------------------
defstruct [
# MapSet of {scope, slug} keys for memories not yet checked out or consumed.
:remaining,
# MapSet of {scope, slug} keys currently checked out to workers.
:in_flight,
# %{ {scope, slug} => Memory.t() } for quick lookup by slug key.
:memories,
# %{ {scope, slug} => [{candidate_slug_key, score}] } -- precomputed top
# candidates per memory from the similarity matrix.
:similarity,
# Running report counters.
:report
]
# --------------------------------------------------------------------------
# Public API
# --------------------------------------------------------------------------
@doc "Start a worker pool for the given list of memories."
@spec start_link(list(Memory.t())) :: GenServer.on_start()
def start_link(memories) do
GenServer.start_link(__MODULE__, memories)
end
@doc """
Check out a focus memory and its live candidates. Returns one of:
- `{:ok, focus, candidates}` -- work to do
- `{:skip, focus}` -- focus had no live candidates, already counted as kept
- `:done` -- no more memories to process
"""
@spec checkout(GenServer.server()) ::
{:ok, Memory.t(), list(map())} | {:skip, Memory.t()} | :done
def checkout(server) do
GenServer.call(server, :checkout, :infinity)
end
@doc """
Report that a focus memory has been processed. `result` is one of:
- `{:ok, eaten_slugs}` -- focus kept, these candidates were consumed
- `{:delete, eaten_slugs}` -- focus itself was deleted, plus these candidates
- `{:error, eaten_slugs}` -- processing failed, candidates may still have been consumed
"""
@spec complete(GenServer.server(), Memory.t(), {atom(), list()}) :: :ok
def complete(server, focus, result) do
GenServer.call(server, {:complete, focus, result}, :infinity)
end
@doc "Get the final consolidation report."
@spec report(GenServer.server()) :: map()
def report(server) do
GenServer.call(server, :report)
end
# --------------------------------------------------------------------------
# GenServer callbacks
# --------------------------------------------------------------------------
@impl GenServer
def init(memories) do
memory_map = build_memory_map(memories)
similarity = build_similarity_matrix(memories, memory_map)
remaining = memory_map |> Map.keys() |> MapSet.new()
state = %__MODULE__{
remaining: remaining,
in_flight: MapSet.new(),
memories: memory_map,
similarity: similarity,
report: %{merged: 0, deleted: 0, kept: 0, errors: 0}
}
{:ok, state}
end
@impl GenServer
# Checkout: find the next remaining memory that isn't in-flight, filter its
# candidates against the current remaining set, and hand it to the worker.
def handle_call(:checkout, _from, state) do
case pick_next(state) do
nil ->
{:reply, :done, state}
{slug_key, focus} ->
candidates = live_candidates(slug_key, state)
state = %{state | in_flight: MapSet.put(state.in_flight, slug_key)}
if candidates == [] do
# No live candidates -- mark as kept immediately, no LLM call needed.
state = %{
state
| remaining: MapSet.delete(state.remaining, slug_key),
in_flight: MapSet.delete(state.in_flight, slug_key),
report: bump(state.report, :kept)
}
{:reply, {:skip, focus}, state}
else
{:reply, {:ok, focus, candidates}, state}
end
end
end
# Complete: worker finished processing a focus memory. Update remaining set
# and report counters based on what happened.
def handle_call({:complete, focus, result}, _from, state) do
focus_key = slug_key(focus)
{status, eaten_slugs} =
case result do
{:ok, eaten} -> {:kept, eaten}
{:delete, eaten} -> {:deleted, eaten}
{:error, eaten} -> {:error, eaten}
end
# Remove focus from in-flight and remaining.
state = %{
state
| in_flight: MapSet.delete(state.in_flight, focus_key),
remaining: MapSet.delete(state.remaining, focus_key)
}
# Remove eaten candidates from remaining.
state =
Enum.reduce(eaten_slugs, state, fn eaten_key, st ->
%{
st
| remaining: MapSet.delete(st.remaining, eaten_key),
in_flight: MapSet.delete(st.in_flight, eaten_key)
}
end)
# Update report counters.
report = state.report
report =
case status do
:kept -> bump(report, :kept)
:deleted -> bump(report, :deleted)
:error -> bump(report, :errors)
end
# Each eaten slug counts as a merge -- the focus absorbed it or it was
# explicitly deleted. The worker already applied the specific action on
# disk; from the coordinator's perspective they're all consumed.
report =
Enum.reduce(eaten_slugs, report, fn _, r -> bump(r, :merged) end)
{:reply, :ok, %{state | report: report}}
end
def handle_call(:report, _from, state) do
{:reply, state.report, state}
end
# --------------------------------------------------------------------------
# Similarity matrix
# --------------------------------------------------------------------------
# Build a map of slug_key => Memory struct for O(1) lookup.
defp build_memory_map(memories) do
Map.new(memories, fn mem -> {slug_key(mem), mem} end)
end
# Precompute the similarity matrix: for each memory, find its top candidates
# above the floor. This is O(n²) dot products but runs once at startup and
# avoids repeated computation during the consolidation pass.
defp build_similarity_matrix(memories, memory_map) do
Map.new(memory_map, fn {key, mem} ->
candidates =
case mem.embeddings do
nil ->
[]
needle ->
memories
|> Enum.reject(fn m ->
slug_key(m) == key or is_nil(m.embeddings) or me_memory?(m)
end)
|> Enum.map(fn m ->
score = AI.Util.cosine_similarity(needle, m.embeddings)
{slug_key(m), score}
end)
|> Enum.filter(fn {_, score} -> score >= @similarity_floor end)
|> Enum.sort_by(fn {_, score} -> score end, :desc)
|> Enum.take(@max_candidates)
end
{key, candidates}
end)
end
# --------------------------------------------------------------------------
# Checkout helpers
# --------------------------------------------------------------------------
# Pick the next memory from remaining that isn't currently in-flight.
defp pick_next(state) do
state.remaining
|> MapSet.difference(state.in_flight)
|> Enum.at(0)
|> case do
nil -> nil
key -> {key, Map.fetch!(state.memories, key)}
end
end
# Filter a memory's precomputed candidates against what's still remaining
# and not in-flight. Returns enriched candidate maps ready for the agent.
defp live_candidates(focus_key, state) do
state.similarity
|> Map.get(focus_key, [])
|> Enum.filter(fn {cand_key, _score} ->
MapSet.member?(state.remaining, cand_key) and
not MapSet.member?(state.in_flight, cand_key)
end)
|> Enum.map(fn {cand_key, score} ->
mem = Map.fetch!(state.memories, cand_key)
%{
memory: mem,
score: score,
tier: Memory.Consolidator.tier_label(score)
}
end)
end
# --------------------------------------------------------------------------
# Helpers
# --------------------------------------------------------------------------
defp slug_key(%Memory{scope: scope, slug: slug}) when is_binary(slug), do: {scope, slug}
defp slug_key(%Memory{scope: scope, title: title}),
do: {scope, Memory.title_to_slug(title)}
defp bump(report, key), do: Map.update!(report, key, &(&1 + 1))
# The "Me" identity memory must never appear as a candidate for merge or
# deletion. It can be a focus (so it absorbs others), but nothing eats it.
defp me_memory?(%Memory{scope: :global, title: "Me"}), do: true
defp me_memory?(_), do: false
end