app/consolidator/) turns raw events into durable
facts. It runs in the background (triggered by POST /v1/consolidate,
see API reference) over pending
(pending or failed — replayable) consolidate jobs.
The pipeline, event by event
1
Extraction
For each event in the job, the configured LLM extractor
(
HAKI_LLM_PROVIDER=fake|openai) receives the event and the
subject’s currently active facts, so it can propose
action: "supersede" instead of piling up contradictions.2
Pydantic validation
Every raw candidate is validated via
ExtractedFact.model_validate(...). An invalid candidate is
rejected and logged, never fatal — it never crashes the batch
(result["rejected"] += 1).3
Embedding
Valid candidates are embedded in a single batch call
(
embedder.embed(texts)), text = f"{predicate} {json(value)}". The
events themselves are also embedded once (episodic memory, sprint
10), a derived, re-computable value — the only write tolerated on an
event after insert.4
Adjudicating against the existing fact
See the dedicated section below: finding the active fact to compare
the candidate against.
5
Applying the outcome
Dedup, supersession, or conflict creation — see “The four outcomes”
below.
Finding the fact to adjudicate against: exact then semantic
_resolve_existing_fact looks for the subject’s active fact to compare
the candidate against:
- Exact predicate match (fast path — the common case, when extraction is lexically consistent);
- if none, a semantic fallback: the active fact whose embedding
is closest to the candidate’s (cosine distance), accepted only if the
distance is ≤ 0.28 (
SEMANTIC_MATCH_MAX_DISTANCE).
The four outcomes of a candidate
1
Duplicate or reinforcement
The same
(subject, predicate, canonical value) already exists among
non-deleted facts → no new row. A replayed event (already in
source_event_ids) is a plain duplicate — this is what makes
reprocessing a job idempotent. A new event re-asserting the
same value on the active fact instead reinforces it:
reinforcement_count and last_reinforced_at are updated, the new
event id is added to source_event_ids.2
Supersede
action="supersede" with an existing fact found: the old fact moves
active → superseded (valid_to = occurred_at of the event that
triggers the change), the new one becomes active with
supersedes_id pointing at the old one. Keys the candidate does
not re-state are carried forward from the old fact
(value = {**existing.value, **candidate.value}) — a status-only
update ("researching" → "completed") must not silently drop a field
like target that the new message does not repeat.3
Conflict
action="create" but an active fact already exists for this
predicate with a different value: both facts enter an open
ConflictSet (created or extended), the new one stays candidate —
see Conflicts.4
Create
No existing fact for this predicate: the candidate moves straight to
candidate → active.Scope always comes from the event
Thesubject_id (and the entire scope) of a created fact always comes
from the source event, never from the candidate returned by the LLM —
even though the extraction schema still carries a subject_id field
(kept for backward compatibility with existing providers). This is the
“the model never chooses scopes” security invariant, applied here at
write time: a candidate whose subject_id drifted (the LLM names a
person instead of reusing the event’s subject) silently created a fact
under an orphan subject that no /v1/context call could ever reach again
— a real, confirmed data-loss bug found while auditing sprint-10
evaluation results at scale.
One active fact per subject+predicate, guaranteed
The whole write phase (duplicate check, semantic resolution, reinforcement, creation) for one(project_id, subject_id) is serialized by a
transaction-scoped Postgres advisory lock (pg_advisory_xact_lock) before
any of these decisions are made — two consolidations of the same subject
running concurrently can never both observe “no duplicate” and both
insert. A partial unique index on facts (project_id, subject_id, predicate) WHERE status = 'active' backs this up at the database level:
even a future write path that forgets the lock cannot create two active
facts with the same exact predicate for one subject — it fails loudly
instead.
Reinforcement never merges a candidate whose value actually changed:
measured against the real local embedder, a genuine value update (e.g.
bike_count 3 → 4, cosine distance 0.03) sits closer than several
legitimate same-value rephrasings (up to 0.19) — no distance threshold
can separate the two. Reinforcement therefore requires exact canonical
value equality; anything else still opens a conflict, as before.
Resilience
- A provider or database failure fails the job
(
status=failed, error inpayload), never the source events, which stay intact and replayable on the next run. - Every job runs inside a savepoint (
session.begin_nested()): a failure only rolls back that job, not the others in the same run. - On a 429 (rate limit) from the LLM provider, the batch stops immediately rather than continuing to fail job after job: measured in a real run on Groq’s free tier (a 6,000 tokens/min budget exhausted at job #3, jobs #4-19 failing instantly and wasting the caller’s retry/ backoff entirely).
API reference — Consolidation
POST /v1/consolidate: synchronous, dev/ops trigger.
