# Chasse perf/RAM — dupehell — 2026-07-24
Méthodologie : voir skill `perf-hunt`. Document-only, jamais de fix appliqué
ici — chaque trouvaille est pour cross-check/implémentation par
l'utilisateur.
## Passe 1 — déclenchée par `benchmark_matrix` (2026-07-24)
**Scope** : investiguer 2 pistes surfacées par la matrice de benchmark de
cette session (`stpircs/benchmark_matrix_report.json` @1M,
`stpircs/benchmark_matrix_5M.json`/`_10M.json` @5M/10M, 5 domaines :
nonprofit, kyc, aviation, fintech, retail) :
1. `kyc` outlier de débit majeur à `hell` (~10x plus lent que la moyenne),
hypothèse préliminaire = nombre de colonnes.
2. `fintech --graph` qui semblait "exploser" en RAM/edges à 20M (32M
arêtes, log partiel avant kill du run).
Pas de couverture "inédite" cette passe — lecture ciblée de
`src/pipeline.rs` (chemin de génération des duplicatas + bruit) et
`src/noise/mod.rs` (dispatch bruit).
---
### H1 — `kyc` (et tout schéma large) paie un surcoût de bruit
proportionnel au nombre TOTAL de colonnes de l'entité, pas au nombre de
colonnes réellement ciblées par le bruit
**Fichier + lignes** : `src/pipeline.rs`, `apply_noise_with_retry`
(584-617, boucle de retry ligne 604-610) et `apply_extra_pass_per_row`
(645-690, boucle de merge ligne 668-678). Vivant, déclenché à chaque
duplicata généré à partir de `noise_passes >= 2` (medium=2, hell=3, cf.
`schema::DifficultySettings::passes`) et à chaque tentative de retry
(`MAX_NOISE_ATTEMPTS = 4`, ligne 591).
**Constat** : `apply_noise_to_batch` (414-...) est déjà efficace — il ne
clone/modifie que les colonnes de `target_cols`, les autres restent le
même `Arc` que la colonne d'entrée (`get_col` ligne 434-438, fallback
`rb.column(idx).clone()` = clone de pointeur Arc, O(1)). **Mais** les deux
boucles de fusion en aval (retry dans `apply_noise_with_retry` ligne
604-610, et fusion multi-pass dans `apply_extra_pass_per_row` ligne
668-678) itèrent sur **`0..noisy.num_columns()` / `0..current.num_columns()`
— TOUTES les colonnes de l'entité** — et appellent le kernel Arrow `zip()`
sur chacune, alors que seules les colonnes de `target_cols` (ou du
`noise_type` en cours) ont pu changer entre `candidate`/`retried` et
`current`/`noisy`. `zip()` matérialise un nouveau tableau (parcours complet
de la colonne, allocation) même quand les deux entrées sont litéralement le
même `Arc` (donc un no-op garanti par construction).
Coût réel : pour une entité à C colonnes dont T sont réellement ciblées par
le `noise_type` courant, chaque retry (jusqu'à 4x) et chaque pass
additionnelle (jusqu'à `noise_passes - 1` = 2 à `hell`) fait C appels
`zip()` au lieu de T. Pour `kyc.natural_person` (C=23, T typiquement 1-4
selon le noise_type — cf. `noise_type_targets_column`), c'est jusqu'à
~20 appels `zip()` inutiles par retry/pass, contre ~5-6 pour un schéma à
8-10 colonnes (T=1-4 aussi, mais C-T plus petit). C'est un facteur
multiplicatif proportionnel à la largeur du schéma, appliqué à chaque
combinaison (retry × pass × noise_type actif en thread parallèle).
**Preuve empirique (mesure isolée légitime — comparaison cross-domaine,
pas un seul run bout-en-bout)** : `kyc/hell` reste à ~29-38K rec/s de 1M à
10M (`benchmark_matrix_report.json`/`_5M.json`/`_10M.json`, run flat/IPC),
contre ~118-176K rec/s pour `nonprofit/hell` (entités ~9 colonnes) sur les
mêmes tailles — ratio stable ~4-5x, **pas dégradé avec l'échelle**
(confirme un surcoût par-ligne constant, pas une explosion algorithmique
en N). `kyc.natural_person` a 23 colonnes contre ~9 pour les entités
`nonprofit` (ratio colonnes ~2.5x) — le ratio de débit observé (~4-5x) est
supérieur au ratio de colonnes brut, cohérent avec un coût qui n'est pas
juste O(colonnes) une fois mais **multiplié par les retries/passes**
(jusqu'à 4×3=12 combinaisons possibles à `hell`, alors qu'à `light`/`medium`
il y a moins de passes → écart attendu plus faible, à vérifier).
**Levier proposé** : dans les deux boucles de fusion, ne `zip()` que les
colonnes appartenant à l'ensemble des colonnes réellement modifiées par ce
pass (`target_cols` pour le retry ; l'union des colonnes ciblées par
`ttype` pour la pass additionnelle — `match_noise_columns`/`plan_cols`
selon le cas). Pour toutes les autres colonnes, réutiliser directement
`current.column(i).clone()` (clone Arc, O(1)) au lieu de passer par `zip`.
**Statut bit-identique** : **garanti par construction**. `apply_noise_to_batch`
garantit déjà que les colonnes hors `target_cols` sont le même `Arc` entre
l'entrée et la sortie (lignes 432-438) — donc `zip(mask, candidate.column(i),
current.column(i))` sur une colonne non ciblée retourne, valeur par valeur,
exactement `current.column(i)` (les deux bras du zip sont déjà identiques).
Sauter le `zip` pour ces colonnes et cloner l'Arc directement produit un
résultat byte-identique par construction — pas de dérive flottante/ULP en
jeu ici (colonnes string/bool). A confirmer par un test de non-régression
existant (checksum du dataset avant/après le fix) plutôt qu'un A/B complet,
vu la garantie de construction.
**Priorité/confiance/effort** : confiance **haute** sur le mécanisme
(lecture directe du code + preuve empirique cross-domaine cohérente),
**effort faible-moyen** (limiter la boucle à un sous-ensemble de colonnes
identifié en amont — pas de changement de structure de données), **priorité
haute** si `kyc`/schémas larges sont fréquemment utilisés à `hell`/grande
échelle (impact direct sur le débit, pas juste la RAM).
**Mesure isolée à faire AVANT tout code** (pas encore exécutée cette
passe) : binaire `examples/profile_noise_pass.rs` dédié — construire un
`RecordBatch` synthétique à C colonnes contrôlé (variantes C=8, C=15, C=23),
appliquer `apply_noise_with_retry`/`apply_extra_pass_per_row` en isolation
sur un nombre de lignes fixe, mesurer le temps CPU pur (pas de génération
de données, pas d'I/O) pour confirmer que le surcoût observé est bien
proportionnel à `C - T` et pas à un autre facteur non identifié dans cette
lecture (ex. coût intrinsèque des noise_types eux-mêmes différent entre
schémas).
---
### H2 (invalidée — faux positif) — `fintech --graph` "explose" en RAM/edges à 20M
**Constat initial** (log partiel du run 20M tué) : `fintech hell --graph` à
20M affichait 32 343 464 arêtes en 218s, contre ~1.6M arêtes en ~9s à 1M —
lu à tort comme une explosion disproportionnée en cours de session.
**Vérification** (comparaison ratio edges/records entre 1M et 10M, données
déjà collectées, pas de nouveau run) :
| 1M | 1 018 797 | 1 617 392 | 1.588 | 9.28s |
| 10M | 10 187 881 | 16 173 623 | 1.588 | 79.89s |
| 20M (extrapolé) | ~20 375 000 | ~32 355 500 | 1.588 | — |
Le ratio arêtes/enregistrement est **strictement constant** (1.588) entre
1M et 10M, et l'extrapolation linéaire à 20M (32 355 500) colle exactement
à la valeur observée dans le log partiel (32 343 464, écart <0.04%). Le
temps scale aussi quasi-linéairement (8.6x pour 10x de données, léger
surcoût sans doute lié à la pression RAM croissante, pas une explosion).
**Verdict** : **pas un bug** — comportement linéaire attendu, le nombre
d'arêtes est structurellement proportionnel au volume de duplicatas/FK/HN
(chaque enregistrement produit un nombre borné d'arêtes, cf.
`GRAPH_MAX_CLUSTER_EDGES = 10_000` qui plafonne déjà les clusters
anormalement gros via fallback arbre couvrant). Classé comme leçon
négative : une extrapolation "ça a l'air énorme" sur un seul run partiel
sans comparer au ratio des tailles précédentes aurait fait perdre du temps
à investiguer un non-problème — toujours vérifier la proportionnalité
avant de qualifier un chiffre d'"explosion".
---
## Synthèse passe 1
| H1 — zip() sur colonnes non ciblées (retry + extra-pass) | Tout schéma à `noise_passes >= 2` (medium/hell) avec des entités larges (kyc en particulier) | CPU (débit) | Haute | Faible-moyen |
| H2 — fintech --graph "explosion" à 20M | — | Faux positif, aucune action | — | — |
**Recommandation d'ordre** : mesurer H1 isolément (`examples/profile_noise_pass.rs`,
3 largeurs de schéma) avant toute implémentation, pour confirmer que le
gain attendu (proportionnel à `(C-T)/C` par retry/pass, potentiellement
2-4x sur `kyc/hell` si l'hypothèse est complète) se matérialise vraiment —
sinon chercher un second facteur (ex. coût des `noise::*` eux-mêmes,
allocation `Vec<Option<String>>` dans `apply_random_subtype`, taille
moyenne des chaînes kyc vs autres domaines).
Pas de passe 2 enchaînée automatiquement — attendre instruction explicite
("go passe 2") pour poursuivre (ex. couverture inédite : `noise/*.rs`
individuels, `graph_gen.rs`/`gt.rs` en RAM à grande échelle, ou mesure
isolée de H1).
---
## Passe 2 — couverture inédite : `entity_gen.rs`, `gt.rs`, `graph_gen.rs`
**Scope** : lecture complète de 3 modules jamais audités dans ce fichier de
suivi — `src/entity_gen.rs` (génération de batch d'entité, conditions de
colonnes), `src/gt.rs` (`GtAccumulator`, `cluster_map`), `src/graph_gen.rs`
(`NodeWriter`/`EdgeWriter`, `push_dup_clusters`). Choisis parce que ce sont
les chemins directement en jeu dans les mesures de cette session
(génération par batch, ground truth, export graphe) et jamais lus en
profondeur dans les chasses précédentes (`session1807`/`session2007`
avaient couvert d'autres surfaces).
**Vérification des fixes déjà committés dans ce périmètre** : `gt.rs`
implémente bien l'accumulateur streaming 2-passes décrit dans son doc
comment (draft IPC + re-lecture à `finish`, `dup_masters`/
`masters_with_exact_copy` en `FxHashSet<String>` — pas de structure
O(n_base) qui grossirait avec tout le dataset). `graph_gen.rs::push_dup_clusters`
calcule bien `n_edges = k*(k-1)/2` **avant** de choisir complet vs arbre
couvrant (`GRAPH_MAX_CLUSTER_EDGES = 10_000`, pipeline.rs) — pas de
blowup O(k²) accidentel avant le plafond, conforme aux fixes C12/C13 de
session1507 (métadonnées edges + timestamp unique) toujours en place. Rien
à signaler de cassé — les deux fichiers sont dans l'état attendu.
### Pas de nouvelle trouvaille significative — 3 modules audités, RAS
- **`entity_gen.rs`** : le chemin `apply_column_conditions` (conditions
`set_null`/`set_value`/`set_pool` déclarées dans `schemas/*.json`) est
O(colonnes avec conditions), un sous-ensemble typiquement petit du schéma
— pas de coût caché proportionnel à la largeur totale comme H1. La
parallélisation par colonne (`into_par_iter` sur `col_defs`) est déjà en
place et ne duplique aucune donnée inutilement (chaque thread reçoit son
propre sous-RNG via `rng.fork()`, pas de contention).
- **`gt.rs`** : conception saine — `cluster_map` n'accumule que les
master_id **effectivement dupliqués** (fraction du dataset, pas O(n_base)
comme documenté), l'allocation `mid.to_string()` dans la boucle de
`finish()` ne se déclenche que pour les lignes appartenant à un cluster
dupliqué (déjà filtré par `dup_masters.contains(mid)` juste avant). Seule
micro-observation, **sans intérêt pratique** : `push_dup_batch` fait un
`contains()` puis un `insert()` séparés sur `dup_masters`/
`masters_with_exact_copy` (double lookup hashmap) au lieu d'utiliser la
valeur de retour de `HashSet::insert()` directement — gain quasi nul
(évite une allocation `to_string()` redondante sur les masters vus
plusieurs fois, ce qui est justement le cas courant pour un master
dupliqué), pas retenu comme piste (effort > bénéfice, ordre de grandeur
négligeable face à H1).
- **`graph_gen.rs`** : `EdgeWriter` flush déjà par lots de 100k
(`EDGE_FLUSH`), pas d'accumulation illimitée en RAM. `push_dup_clusters`
trie `master_ids` (`O(m log m)`, m = nb de masters dupliqués) uniquement
pour un ordre déterministe — coût proportionnel à l'échelle du dataset,
pas à la largeur du schéma, et largement dominé par le travail d'écriture
des arêtes lui-même. Rien à optimiser identifié.
**Conclusion de passe** : cette couverture confirme que le "coût
disproportionné pour schéma large" (H1, passe 1) est bien localisé dans le
chemin de bruit (`pipeline.rs`) et pas ailleurs dans le pipeline — la
génération de batch, la ground truth et l'export graphe sont déjà conçus
pour scaler avec le volume de données dupliquées/marquées, pas avec la
largeur du schéma. Pas de nouvelle piste ouverte cette passe.
## Synthèse cumulée (passes 1+2)
| H1 — zip() sur colonnes non ciblées (retry + extra-pass) | Tout schéma à `noise_passes >= 2` (medium/hell) avec des entités larges (kyc en particulier) | CPU (débit) | Haute | Faible-moyen |
| H2 — fintech --graph "explosion" à 20M | — | Faux positif, aucune action | — | — |
| entity_gen.rs / gt.rs / graph_gen.rs | — | RAS (passe 2), fixes committés vérifiés en place | — | — |
**Recommandation d'ordre** : H1 reste la seule piste actionnable —
mesurer isolément avant tout code (`examples/profile_noise_pass.rs`, cf.
passe 1). Pas de nouvelle piste de largeur similaire trouvée ailleurs dans
le pipeline cette passe.
Pas de passe 3 enchaînée automatiquement — attendre instruction explicite.
---
## Passe 3 — couverture inédite : `column_gen.rs`, `fk_remap.rs`, `hn_common.rs`
**Scope** : 3 modules jamais audités — `src/column_gen.rs` (dispatch de
génération par colonne, cœur du hot-path), `src/fk_remap.rs` (échantillonnage
FK, touché cette session pour `fk_weight`), `src/hn_common.rs` (génération
des hard negatives).
### `column_gen.rs` — RAS
`int_range`/`float_range` font un scan linéaire de tables statiques
(~26/~37 entrées) mais **une seule fois par colonne par batch**, pas par
ligne — coût négligeable. Le dispatch `generate_column` (stage 1: template
rapide, stage 2: préfixe strippé, stage 3: type) est déjà conçu pour éviter
tout travail redondant (le nom normalisé n'est calculé qu'une fois, ligne
294). Rien à signaler.
### `hn_common.rs` — RAS, conception déjà étroite par construction
Contrairement au chemin de bruit (`pipeline.rs`, H1 passe 1), la génération
de hard negatives ne souffre PAS du même problème de largeur de schéma :
`same_field`/les factories ne font `col_take` (kernel `take`, un `Arc`
frais par colonne) que sur les colonnes listées dans `id_fields`/
`attr_fields` du HN config — jamais sur les colonnes non concernées.
Aucune boucle `0..num_columns()` équivalente au `zip` de `pipeline.rs`
trouvée ici. Confirme, par contraste, que H1 est bien localisé au chemin de
bruit et pas un pattern répété ailleurs.
### H3 — `fk_remap_batch` alloue une `String` par ligne pour CHAQUE relation FK quand `--graph` est actif (fichier + lignes : `src/fk_remap.rs:70-79`)
**Statut** : vivant, conditionnel à `--graph` uniquement (`track_src=true`,
câblé depuis `config.graph_enabled` dans `pipeline.rs`).
**Constat** : quand `--graph` est actif, `fk_remap_batch` récupère la
colonne 1 du pool FK (les `record_id` cibles) via `take()` (déjà un
`ArrayRef`, zero-copy côté Arrow), puis la convertit immédiatement en
`Vec<String>` en allouant une String neuve par ligne
(`.map(|s| s.to_string())`, ligne 77) — pour CHAQUE relation FK de CHAQUE
batch. Ce `Vec<String>` est ensuite stocké dans `fk_edges_by_remap`
(`pipeline.rs`) et reparcouru une seconde fois lors de l'écriture des
arêtes (`ew.push(&rid_slice[i], &target_rids[i], ...)`, `pipeline.rs:~1102`)
où `EdgeWriter::push` recopie la `&str` dans son propre `StringBuilder` de
toute façon (`graph_gen.rs:142-146`) — donc la `String` intermédiaire de
`fk_remap_batch` ne survit que le temps d'un aller-retour et n'apporte
aucune valeur ajoutée par rapport à garder l'`ArrayRef` Arrow directement.
**Piste (non retenue comme prioritaire, mais notée)** : ce n'est
probablement pas la cause principale du "+31% avec --graph" mesuré cette
session (l'écriture des arêtes elles-mêmes, proportionnelle au volume, en
est la part dominante) — mais c'est un aller-retour Arrow→String→Arrow
évitable : `fk_remap_batch` pourrait retourner directement l'`ArrayRef`
(la colonne `take()`-ée) au lieu d'un `Vec<String>`, et `pipeline.rs`
lirait les `&str` directement dessus au moment d'appeler `ew.push`, sans
étape d'allocation intermédiaire. Gain attendu : modeste (une allocation +
une désallocation de `String` par ligne par relation FK, ordre de grandeur
bien inférieur à H1), mais gratuit si implémenté (pas de changement de
sémantique).
**Levier proposé** : changer la signature de `fk_remap_batch` pour
retourner `Option<ArrayRef>` au lieu de `Option<Vec<String>>` ; adapter le
call site `pipeline.rs` pour lire les `&str` via `.as_string::<i32>()`
directement au moment du `ew.push`.
**Statut bit-identique** : garanti par construction — c'est une pure
suppression d'étape de conversion, aucune valeur numérique/textuelle ne
change.
**Priorité/confiance/effort** : confiance haute (lecture directe), effort
faible, **priorité basse** (gain marginal comparé à H1, uniquement en jeu
avec `--graph`, feature déjà documentée comme plus coûteuse par
conception).
## Synthèse cumulée (passes 1+2+3)
| H1 — zip() sur colonnes non ciblées (retry + extra-pass) | `noise_passes >= 2` (medium/hell), schémas larges | CPU (débit) | Haute | Faible-moyen | **Haute** |
| H3 — `fk_remap_batch` alloue `Vec<String>` évitable | `--graph` actif, relations FK | CPU/RAM (mineur) | Haute | Faible | Basse |
| H2 — fintech --graph "explosion" à 20M | — | Faux positif | — | — | — |
| entity_gen.rs / gt.rs / graph_gen.rs / column_gen.rs / hn_common.rs | — | RAS, fixes vérifiés en place | — | — | — |
**Recommandation d'ordre** : H1 en premier (mesure isolée requise avant
code, cf. passe 1), H3 en bonus opportuniste si on touche déjà
`fk_remap.rs`/`pipeline.rs` pour autre chose (pas de session dédiée
justifiée pour H3 seul vu son gain marginal).
Pas de passe 4 enchaînée automatiquement — attendre instruction explicite.
Modules encore non couverts pour une passe future : `noise/typos.rs`,
`noise/visual.rs`, `noise/identifiers.rs`, `noise/names.rs`,
`noise/dates.rs`, `noise/addresses.rs`, `noise/companies.rs`,
`noise/extra.rs` (implémentations individuelles, pas juste le dispatch),
`buf_gen.rs`, `fast_template.rs`, `difficulty.rs`, `schema.rs`, `rng.rs`,
`pool_lookup.rs`, `canary.rs`.
---
## Passe 4 — couverture inédite : `rng.rs`, `pool_lookup.rs`, `noise/typos.rs`, `noise/extra.rs`
**Scope** : 4 modules jamais audités — le RNG central, le lookup de pools
(chemin de génération des colonnes string), et 2 implémentations de bruit
individuelles (`typos.rs` : 4 fonctions, `extra.rs` : 7 fonctions — les 2
familles avec le plus de sous-types dans `apply_random_subtype`).
### RAS sur les 4 modules — déjà bien optimisés
- **`rng.rs`** : `Pcg64Mcg` + méthode de Lemire pour `next_usize` (évite le
biais du `% bound`), rien à signaler. Fondation saine.
- **`pool_lookup.rs`** : `POOL_MAP` en `LazyLock<HashMap>` (init une fois,
pas par appel), `StringBuilder::with_capacity` pré-dimensionné avant la
boucle. Rien à signaler.
- **`noise/typos.rs`** (`apply_typos_str`/`_aggressive`/`_extreme`,
`apply_qwerty_azerty`) : déjà optimisé consciemment — commentaire en
ligne 115-117 explique le choix d'aplatir les tirages RNG en un seul
`Vec` par paramètre (`n * max_dist`) plutôt qu'un `Vec<Vec<_>>` (évite
n+1 allocations heap), et le buffer `chars: Vec<char>` est réutilisé
entre itérations via `get_chars_into` (pas de réallocation par ligne).
Exactement le pattern qu'on voudrait voir partout.
- **`noise/extra.rs`** : `apply_nullify`/`apply_exact` déjà optimisés en
zero-copy (constructeur Arrow natif / `arr.slice()`, commentaires
explicites le confirmant). Seules 2 micro-inefficacités mineures
repérées, **non retenues comme pistes** (gain négligeable, sous le seuil
de ce qui vaut la peine d'être mesuré) :
- `apply_blocking_initial` (ligne 89-95) : construit un `Vec<&str>`
(split_whitespace) puis un `Vec<String>` intermédiaire
(`.map(|c| format!("{}.", c))`) avant le `.join(" ")` final — pourrait
écrire directement dans un seul `String`/buffer sans les Vec
intermédiaires, mais l'entrée est typiquement 2-3 mots (noms), donc le
coût réel est minuscule.
- `apply_phonetic` (ligne 175) : `s.to_lowercase()` alloue une String par
ligne avant le scan des 8 motifs `PHONETIC_MAP` — inévitable pour une
recherche case-insensitive sans changer l'algorithme (ex. comparaison
ASCII-only si les motifs le permettent), gain marginal.
**Conclusion de passe** : 4 modules supplémentaires audités, tous propres.
Renforce le diagnostic des passes 2-3 : le problème n'est **pas répandu**
dans le crate, c'est spécifiquement le chemin de fusion `zip()`
retry/multi-pass de `pipeline.rs` (H1) qui casse le pattern par ailleurs
cohérent de "ne payer que pour ce qui change".
## Synthèse cumulée (passes 1-4)
| H1 — zip() sur colonnes non ciblées (retry + extra-pass) | `noise_passes >= 2` (medium/hell), schémas larges | CPU (débit) | Haute | Faible-moyen | **Haute** |
| H3 — `fk_remap_batch` alloue `Vec<String>` évitable | `--graph` actif, relations FK | CPU/RAM (mineur) | Haute | Faible | Basse |
| H2 — fintech --graph "explosion" à 20M | — | Faux positif | — | — | — |
**12 modules audités au total sur 4 passes** (`pipeline.rs`/`noise/mod.rs`
passe 1 ; `entity_gen.rs`/`gt.rs`/`graph_gen.rs` passe 2 ;
`column_gen.rs`/`fk_remap.rs`/`hn_common.rs` passe 3 ;
`rng.rs`/`pool_lookup.rs`/`noise/typos.rs`/`noise/extra.rs` passe 4) — une
seule piste à fort impact trouvée (H1), une mineure (H3), le reste du crate
est déjà dans un état sain.
**Recommandation** : la couverture large a fait son travail — les passes
suivantes sur les modules restants (`noise/visual.rs`, `identifiers.rs`,
`names.rs`, `dates.rs`, `addresses.rs`, `companies.rs`, `buf_gen.rs`,
`fast_template.rs`, `difficulty.rs`, `schema.rs`, `canary.rs`) ont un
rendement marginal décroissant probable vu le pattern observé (les
implémentations individuelles sont soignées ; le problème est
architectural, localisé à un seul endroit). Suggestion : basculer sur la
**mesure isolée de H1** (seule piste actionnable) plutôt que continuer la
couverture en aveugle — sauf si l'utilisateur a une raison spécifique de
vouloir vérifier un module précis.
Pas de passe 5 enchaînée automatiquement — attendre instruction explicite.
---
## Mesure isolée de H1 (2026-07-24, suite passe 4)
**Méthode** : ajout d'un test `#[ignore]` dans `src/pipeline.rs`
(`pipeline::tests::hunt_h1_zip_cost_vs_column_count`, section perf-hunt
dédiée, jamais exécuté par `cargo test` normal) — appelle directement
`apply_noise_with_retry` + 2×`apply_extra_pass_per_row` (reproduisant
exactement l'enchaînement `noise_passes=3` du tier `hell`) sur des
`RecordBatch` synthétiques à colonnes contrôlées (C=8/15/23, contenu
uniforme `"abcdefghijklmnop"`, 100 000 lignes, `plan_cols=["col0"]` → un
seul type/colonne réellement bruitée quel que soit C, exactement
l'hypothèse H1 : "coût ∝ C total, pas ∝ T colonnes bruitées"). 5 répétitions
par C, moyenne. Commande :
`cargo test --release pipeline::tests::hunt_h1_zip_cost_vs_column_count -- --ignored --nocapture`
**Résultat brut** :
| 8 | 51.106 ms | 6.388 |
| 15 | 66.609 ms | 4.441 |
| 23 | 86.414 ms | 3.757 |
Régression linéaire : `temps ≈ 31.9ms + 2.357ms × C` (r² visuellement
excellent — 3 points quasi parfaitement alignés, cohérent avec un coût
dominé par un terme linéaire en C comme prédit).
**Verdict : H1 CONFIRMÉE, mais PARTIELLE.** Le mécanisme existe bel et
bien et est mesurable proprement (ratio C=23/C=8 = **1.69x**, cohérent
avec le modèle linéaire prédit ci-dessus) — mais ce facteur seul
n'explique qu'environ **1.6x** d'écart entre un schéma à 9 colonnes
(≈ nonprofit) et un schéma à 23 colonnes (kyc.natural_person), alors que
l'écart de débit *réellement observé* entre `kyc/hell` et `nonprofit/hell`
sur la matrice de benchmark (1M/5M/10M) est de **~4-5x**. H1 explique donc
une part réelle et significative de l'écart (le fix éliminerait environ
**63% du temps de la partie zip/retry mesurée ici** pour un schéma à 23
colonnes, en retombant proche du terme fixe ~32ms), mais **pas la totalité**
— il reste un facteur résiduel non expliqué (~2.5-3x) à investiguer
séparément si on veut fermer tout l'écart kyc.
**Pistes pour le facteur résiduel (non explorées cette session, à noter
pour une passe future)** :
- Longueur moyenne des chaînes kyc (email, adresses, tax_id...) plus
longue que le contenu synthétique fixe utilisé ici (16 car.) — le coût
réel de `apply_noise_to_column`/`unchanged_row_mask` scale probablement
avec la longueur des chaînes, pas seulement leur nombre.
- Dans la vraie génération, **plusieurs `noise_type` actifs simultanément**
touchent des sous-ensembles de colonnes différents à chaque pass (pas un
seul T=1 fixe comme dans cette mesure isolée) — kyc, avec plus de
colonnes PII (email/phone/ssn/tax_id/document_number...), a
probablement un T réel plus grand que les schémas étroits, ce qui
cumule avec le coût de largeur C mesuré ici.
- `noise_type_targets_column` (matching heuristique) pourrait faire
correspondre plus de `noise_types` actifs pour un schéma riche en
colonnes PII/nommées comme kyc, augmentant le nombre de threads
parallèles lancés par batch (`ndata` dans `pipeline.rs`) — pression
RAM/scheduling additionnelle non mesurée ici (RSS de kyc/hell à 10M =
2018.9MB vs nonprofit/hell = 2642.9MB, donc pas clairement RAM-bound en
fait — piste probablement secondaire).
**Recommandation** : le fix H1 (ne `zip()` que les colonnes réellement
modifiées, cf. levier décrit passe 1) reste justifié et mesurablement
bénéfique (jusqu'à ~63% de réduction sur la partie zip/retry pour les
schémas larges type kyc), mais ne doit pas être présenté comme "la"
solution au problème kyc — un facteur résiduel ~2.5-3x subsiste. Implémenter
H1 d'abord (gain confirmé, risque faible), puis remesurer kyc en conditions
réelles pour quantifier ce qui reste avant d'investiguer plus loin.
Test de mesure laissé en place dans `src/pipeline.rs` (`#[ignore]`, ne
s'exécute jamais en CI/`cargo test` normal) pour permettre de rejouer la
mesure après implémentation d'un éventuel fix, en comparaison directe.
---
## H1 implémentée (2026-07-24, sur demande explicite "go implémenter h1")
**Fix appliqué** : `apply_noise_with_retry` et `apply_extra_pass_per_row`
(`src/pipeline.rs`) retournent désormais `target_cols` en plus de leur
résultat, et ne `zip()`ent plus que les indices de colonnes réellement
ciblés (`target_idxs`/`cand_target_idxs`) — toutes les autres colonnes sont
directement clonées (`Arc::clone`, O(1)) depuis le batch courant au lieu de
passer par le kernel `zip`. 3 call-sites mis à jour pour le nouveau tuple à
3 éléments (`apply_extra_pass_per_row`, le closure de génération de
duplicatas dans `run_pipeline`, le test de mesure `hunt_h1_...`).
**Vérification bit-identique** : dataset `kyc/hell` 200k enregistrements
généré avant/après le fix (même seed=42) — comparaison `polars` dataframe
(`.equals()`) sur le dataset ET la ground truth : **égalité stricte**
(204 256 lignes × 41 colonnes, contenu identique). Confirme la garantie de
construction posée en passe 1 (les colonnes hors `target_cols` sont déjà
des clones Arc identiques entre les deux bras du zip).
**Re-mesure isolée** (même harness, même méthode) :
| 8 | 51.106 ms | 31.526 ms |
| 15 | 66.609 ms | 31.385 ms |
| 23 | 86.414 ms | 31.573 ms |
La courbe est désormais **plate** (~31.5ms quel que soit C) — le fix
élimine intégralement la composante linéaire en C prédite et mesurée en
passe 4/mesure H1. Résultat exactement conforme à l'hypothèse.
**Sweep de régression** (`stpircs/validate_all_domains.py --size 100000`) :
0 crash, 0 violation de monotonicité, mêmes 2 violations `hell` connues
(cybersecurity/ecommerce, inchangées). **Débit moyen +60%** sur les 40
domaines × 3 difficultés (382 969 → 614 735 rec/s). `kyc/hell` (le pire cas
identifié) : 45 123 → 53 021 rec/s (**+17%**) — gain réel mais partiel,
cohérent avec le diagnostic de la mesure isolée (H1 n'explique
qu'~1.6x sur l'écart ~4-5x observé kyc vs nonprofit ; le facteur résiduel
documenté plus haut reste ouvert).
**Commit** : `e86f14b` (local, non pushé) — `src/pipeline.rs` uniquement.
Wheel Python rebuild + réinstallée après commit (convention du projet).
**Reste ouvert** : facteur résiduel ~2.5-3x sur `kyc/hell` non expliqué par
H1 seul (pistes notées dans la section "mesure isolée" ci-dessus : longueur
moyenne des chaînes PII, nombre réel de `noise_types` simultanément actifs
sur un schéma riche en colonnes PII). Non investigué cette session — à
reprendre dans une future passe si l'écart kyc reste jugé prioritaire.
H3 (allocation `Vec<String>` évitable dans `fk_remap.rs`, priorité basse)
reste également non implémentée.
---
## Investigation du facteur résiduel kyc (2026-07-24, suite, sur relance utilisateur "l'écart me paraît trop important")
### Correction d'une erreur de mesure : `hell` = 8 passes, pas 3
En relisant `schema.rs::DifficultySettings` (`DIFFICULTY_MAP`), le tier
`hell` a `passes: 8`, pas 3 comme supposé dans le harnais de mesure H1
initial (`EXTRA_PASSES: usize = 2`, soit 3 passes au total). Erreur de
lecture de ma part — la doc-string de `passes` (schema.rs:64-86) explique
même explicitement pourquoi 8 passes sont nécessaires (calcul
`1-(1-1/9)^8 ≈ 0.617` pour rester au moins aussi dur que `medium`). Le
harnais H1 original sous-estimait donc le vrai coût `hell` par un facteur
~2.7x rien que sur le nombre de passes.
### Second problème du harnais initial : noms de colonnes génériques ne déclenchent AUCUNE catégorie de bruit
`noise_type_targets_column` fait du matching **par nom de colonne**
(sous-chaînes sémantiques : "email", "phone", "address", "name"...). Le
harnais H1 original utilisait des colonnes `col0`..`colN` génériques pour
les passes additionnelles (`apply_extra_pass_per_row(..., all_types=["typo"], ...)`
avec `plan_cols=[]` en interne) — aucun des 9 types de `hell` ne matche un
nom `colN`, donc les "passes additionnelles" du harnais initial étaient en
réalité des no-ops silencieux, quel que soit `EXTRA_PASSES`. Seul le
premier appel (avec `plan_cols=["col0"]` explicite) faisait un vrai travail.
Le ratio C=23/C=8 mesuré (1.69x) restait valide pour DÉMONTRER le mécanisme
H1 (proportionnel à C), mais ne représentait pas fidèlement le coût réel
d'un run `hell` complet sur un vrai schéma.
### Mesure corrigée : vrais noms de colonnes (kyc.natural_person, 21 colonnes string ; nonprofit.donation, 6 colonnes string — entité dominante du domaine, weight=200), vrais 9 types `hell`, vraies 8 passes
Test `pipeline::tests::hunt_h1_residual_kyc_vs_nonprofit` (`#[ignore]`,
100k lignes, 5 répétitions, code déjà fixé pour H1) :
```
kyc.natural_person C=21 avg=19864.868ms
nonprofit.donation C= 6 avg= 484.077ms
```
**Ratio ~41x** sur cette mesure isolée (forçant artificiellement les 9
catégories sur 100% des lignes à chaque passe — plus sévère que la
génération réelle où seule une fraction des lignes est dupliquée/bruitée,
mais représentatif du coût *par ligne effectivement bruitée*).
### Root cause : la richesse du schéma détermine combien de catégories sont RÉELLEMENT actives
Breakdown par catégorie sur kyc.natural_person (1 appel `apply_noise_with_retry`
par catégorie, colonnes réellement matchées) :
```
typo_aggressive 91.7ms target_cols=[family_name, given_name, middle_name, residential_address_line1, residential_city]
visual 264.7ms target_cols=[family_name, given_name, middle_name, residential_address_line1, residential_city]
unicode_pollution 133.7ms target_cols=[family_name, given_name, middle_name, residential_address_line1, residential_city]
names 48.9ms target_cols=[family_name, given_name, middle_name]
dates 152.6ms target_cols=[birth_date, birth_place]
identifiers 151.4ms target_cols=[email_address, mobile_phone]
addresses 467.9ms target_cols=[email_address, residential_address_line1, residential_city, residential_country, residential_postal_code, residential_state]
companies 248.4ms target_cols=[family_name, given_name, middle_name]
extra 255.3ms target_cols=[family_name, given_name, middle_name, residential_address_line1, residential_city]
```
**Les 9 catégories sont TOUTES actives** sur kyc (schéma riche en PII :
noms, adresses, identifiants, dates de naissance — c'est la nature même
d'un domaine KYC). Sur `nonprofit.donation`, seule `dates` (matche
`donation_date`) est réellement active — les 8 autres catégories n'ont
aucune colonne à cibler (`donor_id`/`campaign_id`/`payment_method`/
`source_system` ne matchent aucun des motifs de noms). C'est une
différence structurelle légitime entre les deux schémas, pas un bug —
`kyc` est délibérément un domaine dense en colonnes bruitables (23
colonnes, majoritairement des PII), `nonprofit.donation` en a
délibérément très peu et volontairement peu "bruitables" par design
(entité transactionnelle, pas une entité "identité").
### Bug de correspondance trouvé au passage (indépendant de la perf) : `COMPANY_NAME_WORDS` et `PERSON_NAME_WORDS` contiennent tous les deux la sous-chaîne générique `"name"`
**Fichier + lignes** : `src/pipeline.rs:215-237` (`PERSON_NAME_WORDS`) et
`src/pipeline.rs:244-261` (`COMPANY_NAME_WORDS`) — les deux listes
incluent le fragment brut `"name"` (lignes 216 et 248).
**Constat, confirmé par la mesure ci-dessus** : la catégorie `companies`
(bruit `drop_legal_form`/`apply_word_dropout`/`apply_company_scramble`/
`apply_acronym`, conçu pour des colonnes d'entreprise) matche
`family_name`/`given_name`/`middle_name` sur kyc — des colonnes de
**personnes physiques**, pas d'entreprises — parce que `"name" ⊂
"given_name"` et `COMPANY_NAME_WORDS` contient `"name"`. Symétriquement,
`PERSON_NAME_WORDS` contient aussi `"name"`, donc la catégorie `names`
(nickname/initials/partial/name_compound) matcherait tout aussi bien
`company_name`/`legal_name`/`trading_name` sur un schéma qui en a — pas
vérifié empiriquement ici mais même mécanisme, symétrique.
**Second cas trouvé** : la catégorie `addresses` matche `email_address`
sur kyc (`target_cols` de la ligne `addresses` ci-dessus inclut
`email_address`) — parce que `"address" ⊂ "email_address"` et le motif
`addresses` (pipeline.rs:363-367) cherche `"address"` sans exclusion
spécifique pour `email_address` (contrairement à `ip_address`, qui a bien
son exclusion explicite ligne 363, héritage du fix C20 documenté).
**Impact** : au-delà du coût perf marginal que ça ajoute (~18% du "battery
total" mesuré sur kyc, `companies` + la part erronée d'`addresses`), c'est
un problème de **correctness sémantique** : sur kyc (et tout schéma avec
des colonnes `*_name`/`*email_address*`), le bruit "hell" applique des
transformations conçues pour des noms d'entreprise à des noms de
personnes, et des transformations conçues pour des adresses postales à des
adresses email — potentiellement hors du comportement documenté/attendu
pour ces colonnes, avec un effet possible sur la fiabilité du signal de
difficulté caractérisé ailleurs (`docs/BENCHMARK.md`, `light_ceilings.json`)
pour tout schéma affecté par ce chevauchement.
**Non corrigé** — trouvaille de correctness distincte de la perf, à
trancher explicitement avec l'utilisateur (le fix perf-hunt H1 était scope
performance pur ; celui-ci change potentiellement le comportement de bruit
sur des colonnes réelles, donc les métriques déjà publiées dans
`BENCHMARK.md`/`light_ceilings.json` pourraient nécessiter un recalcul si
corrigé).
### Verdict final sur le facteur résiduel kyc
**Résolu, pas un mystère** : la somme de (a) l'erreur de mesure initiale
(passes=3 au lieu de 8, corrigée), (b) la différence structurelle légitime
de richesse de schéma (kyc a 9/9 catégories actives, nonprofit.donation
1/9), et (c) le bug de matching `"name"`/`"address"` (~18% de surcoût
additionnel sur kyc, en plus d'être un problème de correctness) explique
intégralement l'écart observé. Le fix H1 (déjà implémenté, commit
`e86f14b`) reste valide et bénéfique dans tous les cas — il élimine le
gaspillage `zip()` sur les colonnes non ciblées, quel que soit le nombre
réel de colonnes ciblées par run.
## Synthèse finale (passes 1-4 + mesures H1 + investigation résiduelle)
| H1 — zip() sur colonnes non ciblées | **Implémentée, vérifiée, committée** (`e86f14b`) | Fait |
| Facteur résiduel kyc | **Expliqué** (erreur de mesure + richesse légitime de schéma + bug matching) | Documenté, pas d'action perf supplémentaire nécessaire |
| Bug `"name"`/`"address"` substring matching (companies↔person names, addresses↔email) | **Trouvé, non corrigé** | Décision utilisateur requise (correctness, pas perf pur) |
| H3 — `fk_remap_batch` Vec\<String\> évitable | Non implémentée | Priorité basse, en attente |
| H2 — fintech --graph "explosion" | Faux positif | Clos |
---
## Bug substring "name"/"address" CORRIGÉ (2026-07-24, sur demande explicite "go fix direct")
Fix implémenté : `is_specifically_person_name`/`is_specifically_company_name`
(nouvelles fonctions, `pipeline.rs`) vérifient qu'une colonne matche un
fragment SPÉCIFIQUE de l'autre liste (hors le `"name"` générique commun aux
deux) avant d'exclure. `"names"` exclut désormais les colonnes
spécifiquement identifiées comme noms d'entreprise ; `"companies"` exclut
celles spécifiquement identifiées comme noms de personne ; `"addresses"`
exclut `email_address` (même pattern que l'exclusion `ip_address` déjà en
place). `"middle"` ajouté à `PERSON_NAME_WORDS` (manquait un fragment
spécifique pour `middle_name`/`middle_initial`, sans quoi la nouvelle
exclusion ne les reconnaissait pas comme personne).
**Vérifié** : re-mesure du breakdown par catégorie sur kyc.natural_person —
`companies` → `target_cols=[]` (0.018ms, vrai no-op) au lieu de matcher
`family_name`/`given_name`/`middle_name` ; `addresses` ne touche plus
`email_address`. Nouveau test de régression dédié
(`test_no_person_company_name_cross_contamination`). 150 tests OK, clippy/
fmt clean, sweep `validate_all_domains.py` : 0 crash, 0 violation de
monotonicité, mêmes 2 violations `hell` connues inchangées, débit encore
amélioré (`kyc/hell` : 53021 → 57976 rec/s — bonus perf gratuit en plus du
fix de correctness).
**Commit** `d4723e0` (local, non pushé). Wheel Python rebuild + réinstallée.
Chantier clos : plus aucune piste ouverte issue de cette chasse (H1 fait,
facteur résiduel kyc expliqué et corrigé, H2 faux positif clos). Reste H3
(mineur, priorité basse, non implémentée) en dernier point du backlog.
---
## H4 implémentée — partitionnement des lignes par type avant calcul (2026-07-24, sur relance "on peut pas réduire plus")
Trouvaille bien plus grosse que le facteur résiduel du point précédent :
`apply_extra_pass_per_row` calculait le bruit de CHAQUE type actif sur les
n lignes complètes, jetait ensuite les lignes non masquées via `zip` —
gaspillage O(len(all_types) × n) au lieu de O(n). Sur kyc (9/9 catégories
actives après le fix de correctness), ça représentait jusqu'à 9x de calcul
de bruit redondant par passe additionnelle.
**Fix** : partitionnement des lignes par type assigné D'ABORD (même
technique que `noise::apply_random_subtype`), bruit appliqué uniquement au
sous-ensemble concerné (`pick_rows`), résultats dispersés dans un buffer
par colonne. **PAS bit-identique** (change l'ordre de consommation RNG,
donc le contenu généré — même catégorie que les fixes historiques
`apply_random_subtype`/`distribute_by_weight`) — validé par sweep qualité
standard, pas checksum.
**Mesure isolée** : kyc.natural_person 18.7s → 2.6s (~7.2x). nonprofit
aussi amélioré (530ms → 118ms — le surcoût des types no-op n'était pas
gratuit). **Sweep 40 domaines** : 0 crash, 0 violation, mêmes 2 violations
`hell` connues inchangées. `kyc/hell` réel (pipeline complet) : 57976 →
66389 rec/s (+14.5%).
**Commit** `97ab4d6` (local, non pushé). Wheel Python rebuild + réinstallée.
## Vérification finale du plafond restant — accepté comme légitime
Ratio kyc/hell vs moyenne des autres domaines : ~4.8x (contre ~8.5x en
début de session). Classement des domaines les plus lents à `hell`
(`kyc` 66389 < `crm` 94264 < `insurance` 133188 < `travel` 162220...)
vérifié cohérent avec la richesse de schéma — `crm.contact`/`crm.account`
(15 colonnes chacun, 2e plus lent) suit le même pattern que kyc, en moins
extrême. Confirme que le plafond restant est une **différence de charge de
travail légitime** (kyc = schéma délibérément le plus dense en PII, 9/9
catégories de bruit actives à `hell`), pas du gaspillage caché.
**Décision utilisateur : on s'arrête là, plafond accepté.**
## Bilan final de la chasse (session 2026-07-24)
| H1 — zip() colonnes non ciblées | Implémentée (e86f14b) | kyc/hell 45123→53021 rec/s, moyenne +60% |
| Bug substring "name"/"address" | Corrigé (d4723e0) | kyc/hell 53021→57976 rec/s, bonus gratuit |
| H4 — partition avant calcul | Implémentée (97ab4d6) | kyc/hell 57976→66389 rec/s (+14.5%), mesure isolée 7.2x |
| H2 — fintech --graph explosion | Faux positif, clos | — |
| H3 — fk_remap Vec\<String\> évitable | Non implémentée, priorité basse | — |
**Cumulé sur la session** : kyc/hell 45123 → 66389 rec/s (**+47%**),
débit moyen 40 domaines 382969 → 672187 rec/s (**+75%**). Plafond restant
(~4.8x kyc vs moyenne) vérifié légitime, chantier clos.