campagne mutate-emissive.py : 4 cas, 0 écart (M1/M2 ROUGE, M3 VERT consigné) + gotcha « l'émissif est multiplié par l'albédo »

This commit is contained in:
Jérôme Bousquié committed 2026-11-03 17:36:56 +01:00
1 parent 60d6a0636a
commit e4840b4fa7
5 files changed
+125 -9

No files matched your search

+2
View File
@@ -808,6 +808,8 @@ decoder_can_read`, dont la liste d'extensions est **lue dans `lib/Cargo.toml`**
- **Un instrument dont la DÉTECTION dépend de ce qu'il doit mesurer ne mesure pas — et un sol en ombre ne se détecte plus par la luminance (15.5, mesuré deux fois dans le même verrou)** : `horizon_row` trouvait le sol à « la première rangée dont le maximum dépasse le ciel de 24 de luminance ». Sur la jambe « lumière rase » (`L.y = 0,25`), le sol éclairé vaut **86** et le ciel (couleur de clear) **97** : l'horizon n'est plus détecté du tout (`y0 = 240`), la mesure rend 0, et cela se lit comme « l'effet a disparu ». Seconde faute au même endroit : un seuil **absolu** (35 % du niveau éclairé de CHAQUE image) classait **48 960 px** en ombre sur la jambe rase — c'est-à-dire TOUT le sol : la métrique se normalisait elle-même, la 6.6 dans sa forme la plus pure. Remède en deux temps, pas en commentaires : (a) `y0` est établi **une fois** sur un rendu de référence et partagé par les quatre rendus — c'est une propriété de la caméra, pas de l'éclairage ; d'où `testutil::bands_below` et `testutil::shadow_bbox_below(.., y0, ..)` qui reçoivent la rangée de départ au lieu de la deviner ; (b) quand les deux jambes **changent l'éclairage**, la grandeur partagée n'est pas une luminance absolue mais l'**assombrissement dû au casteur** : même scène avec et sans `set_shadow_caster`, et un pixel compte comme ombre s'il est assombri de plus de 25 % par rapport à sa valeur SANS casteur (garde `b > 60` : un pixel déjà noir ne peut pas être comparé à lui-même sans que le critère ne dépende du bruit de quantification — la dénominateur écrêté de 15.3). Mesures : aire d'ombre lumière haute **422 px** / rase **2 601 px**, barycentre x **167,5 → 235,3**, cube posé **185,9** / levé **227,4**, remplissage de la boîte englobante en vue de dessus **0,985**, trois régions d'ombre **[11, 89, 103]** pour trois casteurs, différence ON/OFF **4 696 / 4 697** px. Verrous : les cinq de `lib/src/core/shadow_edge_tests.rs`. Campagne `campaigns/mutate-shadows.py` : 5 cas, 0 écart — dont M4 (rayon PCF ×4) **prédit VERT de mémoire et mesuré ROUGE**, et un seul tap PCF qui fait tomber **quatre** verrous : les mesures d'aire et de barycentre dépendent de l'existence du filtrage, pas seulement la bande de pénombre.
*Et une leçon de localité :* `assert_eq!(pixels changés hors du sol, 0)` était **faux mesuré 1** — le sommet d'un cube dépasse la rangée d'horizon détectée et son éclairage propre dépend du casteur (self-shadowing). Ce qui discrimine est une **fraction** : 95 % du changement sur le sol (mesuré 99,98 %). Une identité est plus forte qu'un seuil quand elle est vraie ; quand elle ne l'est pas, c'est une borne structurelle avec les deux valeurs citées (6.6 / 12.15).
- **L'émissif est MULTIPLIÉ par l'albédo — un albédo noir annule l'émission, sans erreur ni log (15.5, trouvé en mesurant, pas en relisant)** : `standard_shader.wgsl` calcule `emissive_contrib = base_for_detail * object.emissive.rgb * object.emissive.a` en **quatre** endroits (deux entrées fragment `unlit`, deux lit — la même obligation que le gotcha 6.7 « brancher dans TOUS les points d'entrée »). Le premier harnais de la 15.5 mettait `with_color([0.0,0.0,0.0,1.0])` pour « isoler l'émissif » : la jambe HDR était **totalement muette**, sans le moindre message. Même famille qu'`ObjectUniform::ambient_color = (0,0,0,0)` : *une valeur par défaut qui annule un terme entier ne se voit pas*. Verrou de l'effet : `au_dessus_de_un_en_ldr_deux_niveaux_d_emission_rendent_a_l_identique_et_la_reference_ne_bouge_pas` (identité LDR **0 texel**, HDR **1 198** texels, référence **0**). ⚠ **Ce fait précis n'est pas verrouillé** : la campagne `campaigns/mutate-emissive.py` (4 cas, 0 écart — M1 émissif neutre aux quatre sites ROUGE, M2 écrêtage LDR supprimé ROUGE, **M3 retrait de la multiplication par l'albédo VERT consigné**) le dit mesuré. Un verrou d'identité+localité ne porte pas une magnitude : l'écrire comme couvert serait une fausse preuve (6.19).
<!-- lean-ctx -->
## lean-ctx
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Campagne 15.5 — le verrou de la famille `emissive` (`core::emissive_tests`).
Verrou : `au_dessus_de_un_en_ldr_deux_niveaux_d_emission_rendent_a_l_identique_et_la_reference_ne_bouge_pas`
Mesures propres : identité LDR **0 texel** ; contrôle positif HDR **1 198** texels (seuil 300) ;
empreinte de la référence `(106,44)-(143,75)` changée à **0** texel.
Trois cas, et les lignes mutées sont des **ancres multiples** : la formule
`emissive_contrib = base_for_detail * object.emissive.rgb * object.emissive.a` existe en **quatre**
endroits de `standard_shader.wgsl` (deux entrées `unlit`, deux entrées lit — la même raison que le
gotcha 6.7, « un effet doit être branché dans TOUS les points d'entrée »). Le script **affirme le
nombre d'occurrences mutées** avant de conclure (gotcha 13.6 : une ancre non trouvée rend VERT).
* **M1** l'émissif ne fait rien (`emissive.a` forcé à 0 aux quatre sites) → le contrôle positif HDR
meurt → ROUGE.
* **M2** l'écrêtage LDR n'a pas lieu (`final_rgb × 0.25` avant le return, donc les deux niveaux
passent sous 1.0) → la première phrase du bandeau est contredite → ROUGE.
* **M3** la multiplication par l'**albédo** est retirée aux quatre sites → attendu **VERT consigné** :
le verrou porte une identité et une localité, pas cette multiplication. C'est le trou de couverture
trouvé par le harnais (albédo noir = émissif invisible, sans erreur ni log) — le consigner vert vaut
mieux qu'un verrou décoratif qui prétendrait la couvrir.
Discipline (9.4 / 12.13 / 13.3) : verdict par compteur `test result:` ; compilation ou validation GPU
= REFUSÉ ; restauration par les octets d'avant, jamais `git checkout` (6.19 / 12.13).
Usage : ./campaigns/mutate-emissive.py
"""
import subprocess, sys, json, re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SH = ROOT / "lib/src/shaders/standard_shader.wgsl"
LOCK = "au_dessus_de_un_en_ldr_deux_niveaux_d_emission_rendent_a_l_identique_et_la_reference_ne_bouge_pas"
CMD = f"cargo test -q -p wsg-lib --lib {LOCK} -- --test-threads=1 2>&1"
EMISS = " let emissive_contrib = base_for_detail * object.emissive.rgb * object.emissive.a;"
EMISS_12 = " let emissive_contrib = base_for_detail * object.emissive.rgb * object.emissive.a;"
RET = " return vec4<f32>(apply_fog(final_rgb, in.world_pos), alpha);"
RET_12 = " return vec4<f32>(apply_fog(final_rgb, in.world_pos), alpha);"
CASES = [
dict(id="T0", attente="VERT", n=0, ancre=None,
description="arbre propre (6.6 : l'instrument doit savoir dire « vrai »)", edits=[]),
dict(id="M1", attente="ROUGE", n=4, ancre="base_for_detail * object.emissive.rgb * object.emissive.a;",
description="émissif neutre aux QUATRE sites",
edits=[(EMISS, " let emissive_contrib = base_for_detail * object.emissive.rgb * 0.0;"),
(EMISS_12, " let emissive_contrib = base_for_detail * object.emissive.rgb * 0.0;")]),
dict(id="M2", attente="ROUGE", n=4, ancre="return vec4<f32>(apply_fog(final_rgb, in.world_pos), alpha);",
description="plus d'écrêtage LDR : ×0.25 avant le return, les deux niveaux restent sous 1.0",
edits=[(RET, " return vec4<f32>(apply_fog(final_rgb * 0.25, in.world_pos), alpha);"),
(RET_12, " return vec4<f32>(apply_fog(final_rgb * 0.25, in.world_pos), alpha);")]),
dict(id="M3", attente="VERT", n=4, ancre="base_for_detail * object.emissive.rgb * object.emissive.a;",
description="albédo retiré du terme émissif aux quatre sites : le trou de couverture trouvé "
"par le harnais n'est porté par AUCUN verrou — VERT consigné, pas masqué",
edits=[(EMISS, " let emissive_contrib = object.emissive.rgb * object.emissive.a;"),
(EMISS_12, " let emissive_contrib = object.emissive.rgb * object.emissive.a;")]),
]
def count(out):
r = re.findall(r"(?m)^test result: \w+\. (\d+) passed; (\d+) failed", out)
return None if len(r) != 1 else (int(r[0][0]), int(r[0][1]))
def refused(out):
if re.search(r"(?m)^(error\[E\d+\]|error: could not compile)", out):
return "compilation"
if re.search(r"wgpu error: Validation Error|parsing error", out):
return "validation GPU (artefact)"
if re.search(r"(?m)^\s*(skip|\[skip\])", out, re.I):
return "test muet (skip)"
if count(out) is None:
return "aucun test result: (le filtre n'a rien lancé — 9.4)"
return None
def main():
original = SH.read_text()
rows = []
for c in CASES:
txt = original
for (old, new) in c["edits"]:
n = txt.count(old)
if n == 0:
continue
txt = txt.replace(old, new)
# Comptage **sans double emploi** : la variante à 4 espaces est un SOUS-CHAÎNE de celle à 8,
# donc sommer les deux comptages donnait 6 pour 4 sites (mesuré, première exécution).
touches = original.count(c["ancre"]) if c["ancre"] else 0
assert touches == c["n"], f"{c['id']} : ancrage vue {touches} fois, attendu {c['n']} (13.6)"
SH.write_text(txt)
try:
out = subprocess.run(CMD, shell=True, cwd=ROOT, capture_output=True, text=True,
timeout=2400).stdout
finally:
SH.write_text(original)
assert SH.read_text() == original, f"{c['id']} : restauration non exacte"
why = refused(out)
if why:
rows.append((c["id"], c["attente"], "REFUSÉ", "-", "-", why + f" ({touches} sites mutés)"))
continue
p, f = count(out)
rows.append((c["id"], c["attente"], "ROUGE" if f else "VERT", p, f,
f"{touches} sites mutés ; {c['description']}"))
print(json.dumps([dict(zip(("id", "attendu", "rendu", "passed", "failed", "note"), r))
for r in rows], ensure_ascii=False, indent=1))
ecart = sum(1 for r in rows if r[1] != r[2])
print(f"\n{len(rows)} cas, {ecart} écart(s)")
return 1 if ecart else 0
if __name__ == "__main__":
sys.exit(main())
@@ -140,17 +140,17 @@ le bloom d'un défaut qu'il n'a pas). Campagne `mutate-bloom.py` : 4 cas, 0 éca
ne fait tomber que le verrou des coins** — l'angle non couvert du verrou géométrique est le seuil,
et l'amplitude `intensity` n'est portée par aucun des deux (trou consigné, pas masqué).
### `emissive` FERMÉ (verrous + manifeste ; **campagne à jouer au prochain tour**)
### `emissive` FERMÉ (verrous + manifeste + campagne `campaigns/mutate-emissive.py`)
Un seul verrou de rendu, `core::emissive_tests`, pour les quatre phrases : identité **0 texel** entre
émissif 2,0 et 4,0 en LDR ; **1 198** texels changés en HDR (contrôle positif, seuil 300) ;
**0 texel** changé dans l'empreinte de la référence, mesurée par « présente / absente ».
Découverte trouvée par le harnais, pas la relecture : `emissive_contrib = base_for_detail ×
object.emissive.rgb × object.emissive.a` — l'émissif est **multiplié par l'albédo**, donc un albédo
noir annule l'émissif sans erreur ni log (famille `ambient_color = (0,0,0,0)`). Premier essai du
verrou, albédo noir → jambe HDR muette. Campagne non encore jouée : `mutate-emissive.py` (M1
`emissive.a` forcé à 0 dans les DEUX entrées fragment (unlit et lit — les deux doivent être mutées,
famille 6.7) : le contrôle positif HDR doit tomber ; attendu : M3 « retirer la multiplication par
l'albédo » laisse le verrou VERT, ce qui consigne que le fait découvert n'est pas verrouillé).
verrou, albédo noir → jambe HDR muette. Campagne jouée : **4 cas, 0 écart** — M1 ROUGE, M2 ROUGE,
M3 VERT comme prévu. L'ancrage multiple était d'abord faux : la variante à 4 espaces est une
**sous-chaîne** de celle à 8, donc sommer les deux comptages annonçait **6** pour 4 sites (mesuré dès
la première exécution ; famille 13.6 — le comptage d'occurrences est une grandeur, pas une intuition).
### `hdr` — 3 phrases, **zéro** verrou d'effet de tone mapping
Seul `the_sky_goes_through_tone_mapping_only_when_hdr_is_on` touche la courbe, pour le ciel.
+1
View File
@@ -130,6 +130,7 @@ recomptement, pas comme un fait, et la liste des 4 est **nommée** (une liste se
| `campaigns/mutate-pbr.py` | les trois phrases du bandeau de `meshes/pbr` : normale, metallic, nombres annoncés | `lib/src/resources/material_tint_tests.rs` (6 cas, 0 écart — M1 **carte de normales ignorée** : un seul verrou ; M2 **canaux r/b permutés** : trois verrous, mesure non prévue — le **repli** `(128,128,255)` n'est pas invariant par permutation, donc la faute atteint tout matériau PBR ; M3 **`(1 − metallic)` retiré des deux lobes diffus** : le verrou de masse lumineuse tombe, et celui de teinte spéculaire avec lui ; M4 **normale multipliée dans la couleur** : seul cas à faire rougir l'assertion de chroma (l'uniformité ne le voit pas, la carte étant uniforme) ; M5 **dérive de bandeau** (rugosité 0.7 → 0.65 dans le code seul) : le verrou de bandeau seul, les verrous d'image sont insensibles à une valeur annoncée ; **M6 VERT consigné** : reformuler la prose non chiffrée n'est pas une offense (14-F6)) |
| `campaigns/mutate-cube.py` | orientation de l'atlas sur les faces de `cube()` et attribution de l'éclairage au bandeau de `meshes/cube` | `lib/src/mesh/primitives/atlas_tests.rs` (4 cas, 0 écart — M1 **une cellule par face**, ce qu'affirmait le bandeau : les **deux** verrous tombent ; M2 **axes UV échangés** (l'atlas pivoté de 90° de plus que ne le dit la table du vertex stage) : seul le verrou d'orientation tombe, la jambe « même contenu » restant verte puisque le contenu est bien le même — mesure qui dit que les deux jambes ne se remplacent pas ; M3 **normale plate dans `vs_main`** : seul le verrou d'éclairage, c'est le cube « plat » que le bandeau nomme expressément. ⚠ une première version de la campagne refusait de conclure sur ses quatre cas parce que `cargo test` n'accepte qu'**un** filtre positionnel : les deux verrous sont désormais lancés séparément et leurs compteurs sommés (famille 9.4 / 13.3)) |
| `campaigns/mutate-shadows.py` | famille « ombres » des bandeaux `shadow`, `shadow_test`, `shadow_instances` | `lib/src/core/shadow_edge_tests.rs` | (5 cas, 0 écart — M1 **un seul tap PCF** : **quatre** verrous tombent, donc les mesures d'aire et de barycentre dépendent de l'existence du filtrage et pas seulement la bande de pénombre ; M2 **flip V de la re-projection retiré** : trois ; M3 **facteur d'ombre forcé à 1,0** : les **cinq** ; M4 **rayon PCF ×4** : prédit VERT de mémoire, **mesuré ROUGE** (deux verrous) — l'angle non couvert par cette mutation est la largeur de la pénombre et le taux de remplissage en vue de dessus, qui tolèrent ×4) |
| `campaigns/mutate-emissive.py` | le verrou de la famille `emissive` (identité LDR, contrôle positif HDR, localité de la référence) | `lib/src/core/emissive_tests.rs` (4 cas, 0 écart — M1 `emissive.a` forcé à 0 aux **quatre** sites du shader : ROUGE ; M2 `final_rgb × 0.25` donc plus d'écrêtage LDR : ROUGE ; **M3 retrait de la multiplication par l'albédo : VERT consigné**, le fait découvert n'est porté par aucun verrou. ⚠ l'ancrage multiple comptait **6** pour 4 sites parce que la variante à 4 espaces est une sous-chaîne de celle à 8 — comptage corrigé, famille 13.6) |
| `campaigns/mutate-bloom.py` | les deux verrous neufs `bloom` : extraction vide, seuil forcé à 0 (le scénario « threshold far too low » du bandeau), post-process jamais construit | `lib/src/core/bloom_tests.rs` (4 cas, 0 écart — M1 et M3 font tomber **les deux** verrous ; M2 ne fait tomber **que** le verrou des coins : le verrou géométrique du halo reste vert car un seuil à 0 élargit encore le débordement — angle de couverture consigné, avec l'amplitude `intensity` qui n'est portée par aucun des deux) |
| `campaigns/mutate-sky-msaa.py` | les trois verrous neufs `skybox` / `msaa` de la séance 15.5 | `lib/src/core/sky_tests.rs`, `lib/src/core/msaa_tests.rs` (4 cas, 0 écart — M1 `set_skybox` ignore son argument : seul le verrou du fond tombe ; M2 forcer 1 échantillon MSAA : **REFUSÉ par validation wgpu même après mutation de quatre sites** — le `sample_count` est une convention répartie sur ≥ 5 endroits, donc les deux verrous de `core::msaa_tests` n'ont pas encore de cas qui prouve qu'ils ne sont pas des décors (écart consigné) ; M3 seul `main_sample_count` muté : **VERT, mutation inerte** — ce champ ne décide pas de l'échantillonnage) |
| `campaigns/mutate-doc-contract.py` | — | AGENTS.md |
+4 -4
View File
@@ -4,14 +4,14 @@
# Suite de tests — mesurée, pas souvenir
- Généré le : 2026-11-03 17:14 +0100
- Généré le : 2026-11-03 17:36 +0100
- Périmètre : `cargo test --workspace --no-fail-fast --features debug-bbox,debug-wireframe,stats,import-obj,import-stl -- --nocapture --test-threads=2`
- Verdict : **VERT HONNÊTE : 924 tests exécutés, 0 skip, 9 doc-tests ignorés.**
- Verdict : **VERT HONNÊTE : 925 tests exécutés, 0 skip, 9 doc-tests ignorés.**
- Lignes de skip (les trois formes `skip …`, `[skip] …`, `SKIP …`, lues par `campaigns/skipdetect.py` : tests GPU qui n'ont pas observé, donc comptés `passed` par le harnais) : **0**
| Binaire de test | passed | failed | ignored |
|---|---:|---:|---:|
| `unittests src/lib.rs` | 813 | 0 | 0 |
| `unittests src/lib.rs` | 814 | 0 | 0 |
| `tests/doc_consistency.rs` | 24 | 0 | 0 |
| `tests/example_banners.rs` | 9 | 0 | 0 |
| `tests/roadmap_consistency.rs` | 14 | 0 | 0 |
@@ -19,7 +19,7 @@
| `tests/texture_procedural.rs` | 4 | 0 | 0 |
| `tests/wgsl_validate.rs` | 49 | 0 | 0 |
| `Doc-tests wsg_lib` | 9 | 0 | 9 |
| **Total (somme des lignes ci-dessus)** | **924** | **0** | **9** |
| **Total (somme des lignes ci-dessus)** | **925** | **0** | **9** |
Ce fichier est **régénéré** à chaque exécution de `./run-tests-honest.sh` : un nombre qui y
figure a été produit par les compteurs du harnais, pas recopié d'un souvenir. Ailleurs dans les