> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gethaki.space/llms.txt
> Use this file to discover all available pages before exploring further.

# Context Assembler

> Filtres durs, score hybride, budget de tokens, expansion multi-hop — app/context/

Le Context Assembler (`app/context/__init__.py`) construit le
`ContextPacket` servi par `POST /v1/context` : un retrieval hybride sur
les faits d'un scope exact, assemblé sous budget de tokens, avec une trace
de décision persistée pour **chaque** fait considéré.

## Filtres durs, avant tout score

Appliqués **avant** le calcul du score, jamais après :

* `status = active` uniquement ;
* scope exact `(project_id, subject_id)` ;
* `valid_to IS NULL OR valid_to > now()` — encore valide métier ;
* un fait listé dans un `ConflictSet` **ouvert** n'est jamais servi : il
  est bloqué avec `reason_code = conflict_open`.

## Le score hybride

```text theme={null}
score = 0.6 × similarité cosinus (embedding pgvector)
      + 0.25 × rang plein-texte (ts_rank_cd, colonne GENERATED search_vector)
      + 0.15 × récence (décroissance exponentielle, constante de temps 30 jours)
```

<ResponseField name="0.6 — similarité cosinus" type="pgvector">
  `1 - cosine_distance(embedding, query_embedding)`, index **hnsw**.
</ResponseField>

<ResponseField name="0.25 — plein-texte" type="PostgreSQL">
  `ts_rank_cd(search_vector, websearch_to_tsquery('simple', query))`.
  `search_vector` est une colonne **générée** (migration 0004) : le
  tsvector est construit une fois à l'écriture, jamais reparsé à chaque
  requête. `websearch_to_tsquery` accepte du texte utilisateur arbitraire
  (contrairement à `to_tsquery`, qui lève sur une requête sans opérateurs
  `&`/`|`).
</ResponseField>

<ResponseField name="0.15 — récence" type="exponentielle">
  `exp(-Δt / τ)` avec `τ = 30 jours`, `Δt = now() - coalesce(valid_from, recorded_from)`.
</ResponseField>

Ces poids sont documentés dans le code mais **ne font pas partie du
contrat public** — ils peuvent changer entre deux versions sans être
considérés comme un breaking change d'API.

## Retrieval en deux phases

Scorer **tous** les faits actifs d'un scope coûte \~200 ms à 10 000 faits
(mesuré, sprint 3) — trop pour le chemin critique. Le retrieval se fait
donc en deux temps :

<Steps>
  <Step title="Phase 1 — génération de candidats via les index">
    Union de deux requêtes indexées, chacune limitée à
    `RETRIEVAL_TOP_K = 64` lignes : le top-K par distance cosinus (hnsw)
    **UNION** le top-K par rang plein-texte (GIN).
  </Step>

  <Step title="Phase 2 — score complet sur l'union seulement">
    Le score hybride complet n'est calculé que sur cette union (≤ 128
    lignes), puis plafonné à `CANDIDATE_LIMIT = 256`. Seules les colonnes
    nécessaires au packaging sont sélectionnées : décoder l'embedding
    384-dim de chaque ligne coûte plus cher que le scoring lui-même
    (mesuré).
  </Step>
</Steps>

<Warning>
  Compromis documenté : un fait qui n'est **ni** dans le top-K vectoriel
  **ni** dans le top-K plein-texte ne peut jamais être servi, même si la
  récence l'aurait fait remonter. Les faits au-delà de `CANDIDATE_LIMIT`
  ne sont pas non plus tracés.
</Warning>

## Budget de tokens

`budget_tokens` (défaut **900**, doit être un entier positif — sinon
`budget_exceeded`). L'estimation d'un texte : `max(1, len(texte) // 4)`.
Les faits sont empaquetés **par score décroissant** jusqu'à épuisement du
budget ; le reste est exclu avec `reason_code = over_budget`. Chaque
décision (`included` / `excluded` / `blocked`) est écrite dans
`context_traces`.

## Porte de rappel (M3) — le budget est un plafond, pas une cible

Par défaut, le budget de tokens est la seule limite : les faits sont
empaquetés de façon gloutonne jusqu'à le remplir, quelle que soit leur
pertinence réelle. Quand `HAKI_RECALL_MAX_DISTANCE` est fixé au-dessus de
`0` (désactivée par défaut — comportement précédent exact), un candidat
dont la distance cosinus à la requête dépasse ce seuil est exclu
(`reason_code = below_relevance_floor`) **avant** l'empaquetage, faits et
épisodes confondus, quel que soit le budget restant.

<Warning>
  Le plancher porte uniquement sur l'**axe sémantique** (distance
  cosinus), jamais sur le score hybride : la similarité est le seul terme
  borné et calibrable par embedder. Le bon seuil dépend de
  `HAKI_EMBED_PROVIDER` — calibrer avec `scripts/check_recall_floor.py`
  avant d'activer dans un environnement ; ne jamais coder en dur une
  valeur mesurée pour un autre embedder.
</Warning>

Un appel entièrement vidé par la porte (des candidats existaient, aucun
n'a franchi) renvoie `empty_reason: "no_relevant_memory"` avec `status`
toujours `"ok"` — pas un échec, un honnête « rien d'assez pertinent ».
Ce n'est délibérément **pas** un warning : un warning forcerait
`status = "degraded"`, et les SDK rendent déjà un paquet vide comme une
chaîne vide (aucun bloc `<haki_memory>`) — injecter un bloc « aucune
mémoire pertinente » serait lui-même un distracteur. Les lignes du
multi-hop ne sont jamais soumises à la porte : leur raison d'être est
justement de rapatrier des preuves sémantiquement éloignées de la requête
d'origine.

`GET /v1/stats/overview` expose `injection_rate` (nom canonique de ce que
`hit_rate` a toujours mesuré : la part des appels contexte ayant servi au
moins un fait) — la métrique à surveiller pendant la calibration.

## Expansion multi-hop (sprint 10)

Après l'empaquetage principal, s'il reste du budget, une **seconde**
passe plein-texte (sans nouvel appel d'embedding) cherche des faits liés
par des **entités partagées** avec les faits déjà retenus — utile quand
deux faits sont reliés par un nom commun mais pas par une proximité
sémantique avec la requête d'origine.

* Détection d'entités **par règle** (pas de NER, pas de LLM) : tokens
  capitalisés (regex `[A-ZÀ-Ý][a-zà-ÿ]{2,}`), classés par fréquence, en
  excluant les mots de début de phrase courants (`the`, `le`, `and`,
  `et`…) et les mots déjà présents dans la requête.
* Bornée : au plus `MULTI_HOP_MAX_ENTITIES = 2` entités, au plus
  `MULTI_HOP_MAX_PER_ENTITY = 5` faits par entité, **un seul saut**, jamais
  récursif.
* Marquée `reason_code = multi_hop_expansion` dans la trace.

## Mémoire épisodique

Après les faits, les `EPISODE_TOP_K = 8` **événements sources** les plus
proches (cosinus sur `events.embedding`, hnsw) du même scope sont
empaquetés dans **le même budget** — faits d'abord, épisodes avec ce qui
reste. C'est ce qui répond aux questions « qu'est-ce qui s'est passé /
quand ? » : l'extracteur ne garde que les faits durables, les épisodes
gardent les événements datés.

## Le `ContextPacket`

```json theme={null}
{
  "facts": [
    {
      "id": "f8a1...",
      "predicate": "invoice_language",
      "value": {"language": "fr"},
      "confidence": 0.9,
      "valid_from": "2026-08-01T10:00:00+00:00",
      "source_event_ids": ["7c21..."]
    }
  ],
  "episodes": [
    {
      "event_id": "7c21...",
      "kind": "conversation.message",
      "occurred_at": "2026-08-01T10:00:00+00:00",
      "excerpt": "conversation.message {\"content\": \"Je pr\\u00e9f\\u00e8re...\"}"
    }
  ],
  "warnings": [],
  "status": "ok",
  "empty_reason": null
}
```

Un `warning` `open_conflict: N fact(s) hidden pending conflict resolution`
apparaît dès qu'au moins un fait est bloqué par un conflit ouvert ; un
warning `volatility_expired: N fact(s)...` apparaît de la même façon
quand le filtre de volatilité cache des faits périmés (voir
[Faits et cycle de vie](/fr/concepts/facts-lifecycle)). `empty_reason`
vaut `"no_relevant_memory"` uniquement quand la porte de rappel
(ci-dessus) a vidé un résultat autrement non vide.

<Card title="Référence API — Context" icon="brain-circuit" href="/fr/api-reference/context">
  `POST /v1/context` et `GET /v1/inspect/{trace_id}` : schémas exacts.
</Card>
