# Script de comptage — usage et interprétation

`scripts/count_density.py` mesure les occurrences réelles des entités de la carte dans le contenu. Il existe parce que les LLM comptent mal : sur un texte de 2000 mots, une estimation à l'œil se trompe régulièrement d'un facteur deux, et toute la couche C2 en dépend.

Le partage du travail : le modèle décide **quoi** compter (variantes de surface, faux positifs à écarter), le script décide **combien**.

## Appel

```bash
python3 scripts/count_density.py \
  --content page.md \
  --map carte.json \
  --out mesures.json
```

Options : `--max-oos N` borne les candidats hors scope (défaut 25), `--quiet` supprime le résumé sur stderr. Sans `--out`, le JSON part sur stdout.

Python 3.9+, aucune dépendance externe.

## Format de la carte

```json
{
  "entities": {
    "passage-ranking": {
      "label": "passage ranking",
      "target_density": 0.8,
      "variants": ["passage ranking", "passage-ranking", "ranking de passage", "PR"],
      "exclude": ["PR au sens public relations"]
    }
  },
  "ngrams": {
    "passage-ancre": {
      "label": "passage ancré",
      "target_count": 3,
      "variants": ["passage ancré", "passages ancrés"]
    }
  }
}
```

- `entities` — couche C2, jugée sur une **densité** en pourcentage du nombre de mots.
- `ngrams` — couche C3, jugée sur un **nombre d'occurrences** absolu, parce que la carte prescrit une fréquence attendue et non un pourcentage.
- `variants` — toutes les formes de surface : flexions, pluriels, casse, tirets, acronymes, synonymes stricts. C'est le travail sémantique du modèle, et la qualité du comptage en dépend entièrement : une entité présente uniquement sous une forme non listée sera comptée « Absente ».
- `exclude` — expressions qui masquent une zone du texte avant comptage. C'est ainsi qu'on écarte un homographe : sur `PR`, l'exclusion `"PR au sens public relations"` empêche de compter l'occurrence qui parle de relations presse.

### Le piège des variantes d'un seul mot

Une variante composée d'un seul mot courant produit des faux positifs en série, et les frontières de mots n'y changent rien puisque le mot existe bel et bien dans le texte :

| Variante | Ce qu'elle capte en réalité |
|---|---|
| `Plus` pour *Shopify Plus* | « 5x **plus** rapide », « de **plus** en **plus** » |
| `run` pour *budget de run* | « **run** sophisticated campaigns » (verbe anglais) |
| `Markets` pour *Shopify Markets* | « react to market trends » |
| `React` pour *compétence React* | « **react** to » (verbe anglais) |

Règle : une variante d'un seul mot ne se justifie que si ce mot est propre au domaine (`Hydrogen`, `Liquid`, `netlinking`). Sinon, ne garder que les formes composées — `Shopify Plus`, `plan Plus`, `marchand Plus` — ou ajouter l'expression parasite dans `exclude`.

### Le piège du contenu multilingue

Un bloc en anglais dans une page française fait deux dégâts distincts. Il produit des faux positifs, parce que beaucoup de mots anglais courants sont des variantes plausibles d'entités techniques. Et il fausse le dénominateur : ses mots comptent dans `word_count`, donc dans toutes les densités, sans être indexables sur les entités françaises de la carte.

Repérer ces blocs, mesurer leur poids en pourcentage du corps, et le remonter comme un constat à part entière — c'est souvent un gap réel de la page, pas seulement une gêne de mesure.

### Relire avant de conclure

Après le premier passage, reprendre les entités **sur-densifiées** et **conformes de justesse** en regardant leur `matched_variants`. Un statut porté par une seule variante suspecte est presque toujours un artefact. Corriger la carte et relancer coûte quelques secondes ; livrer un audit contenant un faux positif décrédibilise tous les autres chiffres du rapport, y compris les justes.

## Ce que le script gère déjà

Inutile de pré-traiter le contenu :

- **Accents** — « pondérée », « ponderee » et « pondéree » sont comptés ensemble. Un contenu réel est mal accentué ; refuser ces formes produirait de faux « Absent ».
- **Tirets, slashs, apostrophes typographiques** — `passage-ranking`, `passage ranking` et `passage/ranking` convergent.
- **Casse** — insensible.
- **Frontières de mots** — `PR` n'est pas trouvé dans « prix », `BM25` n'est pas trouvé dans « BM250 ». Sans cette précaution, les acronymes courts produisent des comptages absurdes.
- **Ponctuation intercalaire** — « passage, ranking » compte comme une occurrence de l'expression.
- **Chevauchements** — les variantes longues passent d'abord. Sur « ranking de passage », le script compte une occurrence de la variante longue, pas deux occurrences de fragments, ce qui gonflerait la densité.

## Lecture de la sortie

```json
{
  "word_count": 2431,
  "entities": {
    "bm25": {
      "occurrences": 2,
      "density": 0.0823,
      "target_density": 0.3,
      "status": "sous-densifie",
      "matched_variants": { "BM25": 2 },
      "positions": [[412, 416], [1893, 1897]]
    }
  },
  "out_of_scope_candidates": [
    { "term": "netlinking", "occurrences": 12, "density": 0.4936 }
  ],
  "warnings": [],
  "summary": { "entities": { "conforme": 14, "sous-densifie": 5, "absent": 3 } }
}
```

- `matched_variants` — à reporter dans le rapport : c'est ce qui rend le comptage vérifiable par l'utilisateur, et ce qui révèle qu'une entité n'est présente que sous une forme marginale.
- `positions` — offsets dans le texte normalisé, bornés à 50 par entité. Utiles pour aller citer un passage.
- `warnings` — non vide quand le contenu fait moins de 500 mots : les statuts de densité ne sont alors pas interprétables, parce que les cibles de la carte sont calibrées sur 2000 mots et qu'une occurrence unique dépasse n'importe quelle cible. Relayer l'avertissement et lire les occurrences brutes.

## Les candidats hors scope sont des candidats

`out_of_scope_candidates` liste les termes fréquents (au moins 3 occurrences) qui n'appartiennent à aucune entité de la carte, mots-outils et vocabulaire générique écartés, triés par fréquence décroissante.

Le script ne tranche pas, et il ne peut pas : aucun calcul ne distingue un terme métier légitime d'une pollution du vecteur. Les premiers de liste sont les plus significatifs, mais il reste du bruit — un mot courant absent des listes de filtrage remontera parfois. C'est assumé : un faux négatif coûte plus cher qu'un candidat à écarter, parce qu'un terme polluant jamais remonté ne sera jamais corrigé.

Deux lectures possibles pour chaque candidat, à trancher par le modèle :

- **Pan de sujet non cartographié** — le terme relève de la requête, la carte l'a manqué. C'est un angle mort de la carte, pas un défaut du contenu.
- **Pollution du vecteur** — le terme relève d'une autre intention. Action : supprimer, ou déplacer vers la FAQ ou une autre page.

## Générer la carte JSON depuis la sortie du sous-agent

Le sous-agent qui produit la carte remonte les entités avec leur densité cible. Convertir en demandant directement ce format JSON, ce qui évite une transcription manuelle et ses erreurs. Lui préciser d'inclure les variantes de surface et les homographes à exclure — il connaît le domaine à ce moment-là, ce qui n'est plus vrai une fois la carte réduite à un tableau.
