dupehell 0.5.1

Synthetic multi-domain dataset generator for record linkage benchmarking — Educational Use Only
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
# 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) :

| Taille | Records | Edges | Ratio edges/record | Temps |
|--------|---------|-------|--------------------|-------|
| 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

| Piste | Condition d'activation | Type | Confiance | Effort |
|-------|------------------------|------|-----------|--------|
| 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)

| Piste | Condition d'activation | Type | Confiance | Effort |
|-------|------------------------|------|-----------|--------|
| 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)

| Piste | Condition d'activation | Type | Confiance | Effort | Priorité |
|-------|------------------------|------|-----------|--------|----------|
| 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)

| Piste | Condition d'activation | Type | Confiance | Effort | Priorité |
|-------|------------------------|------|-----------|--------|----------|
| 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** :

| C (colonnes totales) | Temps moyen (100k lignes, 3 passes) | ms/colonne totale |
|---|---|---|
| 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) :

| C | Avant | Après |
|---|---|---|
| 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)

| Piste | Statut | Action |
|-------|--------|--------|
| 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)

| Piste | Statut | Gain mesuré |
|-------|--------|-------------|
| 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.