Skip to content

Fix(gradebook,#24276): cle de doublon de la regle 5 normalisee, alignee sur le miroir C# - #24281

Merged
myia-ai-01 merged 1 commit into
mainfrom
fix/gradebook-email-case
Nov 3, 2026
Merged

myia-ai-01 merged 1 commit into
mainfrom
fix/gradebook-email-case

Conversation

@jsboige

@jsboige jsboige commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

Grain: MED/tooling — lane myia-po-2024:CoursIA — prev: MED/tooling #24278

Meme justification de genre que #24278 : l'enumeration n'a pas de fix, et tooling decrit mieux un correctif du moteur de notation qu'un refactor (le comportement change). TIER MED : le comportement attendu etait deja decrit par le code et par son miroir C#, il etait seulement faux.

Le defaut

GradeBookApp/gradebook.py, regle 5 (rejet des evaluations doubles) prenait l'adresse brute comme cle d'un set Python ordinaire :

evaluator_id = eval_record.email        # l.253 — comparaison sensible a la casse et aux espaces
...
seen_evaluators.add(evaluation.email)   # l.269

Mesure sur origin/main (3f7ff9cda3d), deux evaluations de la meme personne ecrites differemment :

1re eval acceptee : True
2e  eval acceptee : True      # le doublon passe

Consequence : l'evaluation compte deux fois dans la moyenne du groupe, alors que la regle 5 existe precisement pour l'empecher. Le miroir C# ne fait pas cette erreur — il compare avec StringComparer.OrdinalIgnoreCase et retombe sur Nom + Prenom quand l'adresse manque (ProjectModel.cs:126-130). C'est donc une divergence entre les deux implementations, pas un choix documente.

Le defaut est un couple d'appels (la lecture de la cle et son add), pas un point unique : c'est pourquoi les tests passent par filter_evaluations et non par is_valid_evaluation seule.

Le correctif

Une seule definition de la cle, utilisee des deux cotes :

def evaluator_key(self, evaluation):
    email = evaluation.email
    if isinstance(email, str) and email.strip():
        return ("email", email.strip().lower())
    nom = evaluation.nom if isinstance(evaluation.nom, str) else ""
    prenom = evaluation.prenom if isinstance(evaluation.prenom, str) else ""
    return ("nom", self.fold_accents(nom).strip().lower(),
                   self.fold_accents(prenom).strip().lower())
  • ("email", …) et ("nom", …) sont deux espaces de cles distincts : une adresse presente ne peut pas entrer en collision avec un nom.
  • isinstance(email, str) absorbe None et les float('nan') que produit pandas.read_csv, comme project_name le fait deja pour les noms.
  • fold_accents + lower() sur le repli : "DUPONT"/"jean" et "Dupont"/"Jean" sont le meme evaluateur.

Point 3 de l'issue — le repli par nom : aligne, pas refuse

L'issue demandait que la decision soit ecrite, alignee ou explicitement refusee. Alignee sur le C#, pour deux raisons mesurees :

  1. C'est deja ce que fait le miroir (eval.Email ?? $"{eval.Nom}{eval.Prénom}", l.130). Laisser Python sans repli maintenait une divergence meme apres avoir corrige la casse.
  2. Le repli corrige un defaut symetrique que le correctif aurait pu introduire : sans lui, toutes les lignes sans adresse partagent une cle unique et une seule evaluation est retenue par groupe. Mesure : deux evaluateurs differents sans adresse passaient de 2 evaluations conservees a 1.

Cote C#, la cle est desormais .Trim()ee et le repli couvre aussi l'adresse vide (string.IsNullOrWhiteSpace), pas seulement null : "" etait une troisieme divergence non listee dans l'issue, qui collapsait toutes les lignes a adresse vide en un seul evaluateur.

Point C# avant Python avant Les deux apres
Casse de l'adresse insensible sensible insensible
Espaces autour de l'adresse non normalises non normalises retires
Adresse absente repli Nom + Prenom aucune (cle brute) repli Nom + Prenom
Adresse vide ("") cle "" (collision) cle "" (collision) repli Nom + Prenom

Ecart residuel, mesure et non corrige

Une ligne avec adresse et une ligne du meme evaluateur sans adresse restent deux cles distinctes, donc deux evaluations conservees. C'est exactement le comportement du C# (eval.Email ?? … produit l'un ou l'autre, jamais les deux chercheurs). Le corriger demanderait une troisieme sonde (« nom connu ou adresse connue ») et un changement de semantique de la regle 5, hors du perimetre de cette issue : non traite ici, signale ici pour ne pas le laisser decouvrir plus tard.

Tests

Nouveau module GradeBookApp/test_evaluator_key.py (21 cas, gardes importorskip comme test_fuzzy_match_group.py) :

Famille Cas
Critere 1 — doublon casse seule, espaces seuls, les deux, premisse « la plus ancienne survit »
Critere 2 — controle negatif adresses reellement differentes conservees ; jean.dupont@ vs jean.dupont2@ conservees
Critere 3 — repli par nom meme evaluateur sans adresse = doublon ; evaluateurs differents sans adresse = deux evaluations ; repli insensible casse/accents
Contrat de la cle normalisation, egalite des deux graphies, repli sur None / non-chaine / adresse vide, ligne sans nom ni adresse sans IndexError
Non-regression regle 1 (note 20 etudiante) toujours ecartee

[None, "", " ", float("nan")] : les quatre graphies d'« adresse absente » sont couvertes, parce que c'est la forme que prend la donnee selon la source (CSV, Excel, formulaire).

Controle negatif mesure — le meme module lance contre le gradebook.py d'origin/main (copie hors arbre de travail) :

13 failed, 8 passed

Dont 8 echecs comportementaux (les cas qui portent le defaut) et 5 AttributeError sur evaluator_key, qui testent une methode qui n'existe pas encore. Les 8 qui passent sur le code d'origine sont les controles negatifs, plus quatre cas « meme evaluateur sans adresse » que l'ancien code traitait comme des doublons par accident (identite de l'objet np.nan), pas par construction.

Validation

python -m pytest --rootdir=D:/Dev/CoursIA-gb276 -c D:/Dev/CoursIA-gb276/pytest.ini \
  D:/Dev/CoursIA-gb276/GradeBookApp -q
# 36 passed in 1.96s   (15 test_fuzzy_match_group + 21 test_evaluator_key)

dotnet build GradeBookApp/GradeBookApp.csproj
# La generation a reussi.  0 Erreur(s), 1 avertissement preexistant (Program.cs:59)

Dependances presentes (aucun skip) : pandas, numpy, unidecode, rapidfuzz.

Portee

  • GradeBookApp/gradebook.py : +29/-2 (une methode, deux appels).
  • GradeBookApp/ProjectModel.cs : +7/-2 (la cle du miroir).
  • GradeBookApp/test_evaluator_key.py : nouveau.
  • Aucun autre consommateur de seen_evaluators : grep -n "seen_evaluators\|\.email" GradeBookApp/gradebook.py ne rend que la definition de la cle et les deux appels.

Perimetre du repli, apres review

Deux precisions que la revue de clusterManager-Myia a demandees, et qui manquaient au tableau ci-dessus.

  • Le repli patronymique n'est pas identique de part et d'autre, et il n'est pas dans le perimetre de cette PR. Python produit ("nom", unidecode(nom).strip().lower(), unidecode(prenom).strip().lower()) ; C# produit $"{Nom}{Prenom}", sans repli d'accents ni separateur. Consequence mesuree par la revue : ("Léa","Dupont") et ("Lea","Dupont") sans adresse donnent une cle en Python (la seconde ligne est rejetee) et deux en C#. La ligne « adresse absente -> repli Nom + Prenom » du tableau decrit donc l'axe aligne (le repli existe des deux cotes), pas la chaine produite : aligner les deux replis est un travail distinct, non ouvert ici.
  • Les lignes sans adresse et sans nom collapsent en une cle unique (("nom", "", "")) : elles comptent pour un seul evaluateur, donc une seule retenue par groupe. Le test test_key_of_a_nameless_row_does_not_raise epingle ce comportement — c'est un choix assume, pas un oubli. Une ligne sans adresse ni nom est une donnee sale ; la traiter comme un evaluateur unique est le comportement voulu, et il est desormais ecrit.

Closes #24276.

🤖 Generated with Claude Code

…ux cotes

La regle 5 prenait l'adresse brute comme cle d'un `set` Python : deux
evaluations du meme evaluateur ecrites "Jean.Dupont@..." et
" jean.dupont@... " etaient acceptees toutes les deux et comptaient double
dans la moyenne du groupe.

`evaluator_key()` retire les espaces de bord et replie la casse, et retombe
sur Nom + Prenom quand l'adresse est absente, vide ou non-chaine — aligne sur
le miroir C# (OrdinalIgnoreCase + repli `$"{Nom}{Prenom}"`).

Le repli evite le defaut symetrique : une cle unique pour toutes les lignes
sans adresse les effondrait en un seul evaluateur, et une seule evaluation
par groupe etait retenue.

Cote C#, la cle est `.Trim()`ee et le repli couvre aussi l'adresse vide
(`string.IsNullOrWhiteSpace`), dernier ecart avec Python.

Tests : GradeBookApp/test_evaluator_key.py (21 cas, dont la chaine complete
`filter_evaluations` — le defaut est un couple d'appels, un test sur un seul
cote passerait pendant que les deux divergent).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

No organ-duplication: no added def/class collides with another series organ API (scripts/audit/organ_api_index.yaml).

Detector: python scripts/audit/detect_organ_duplication.py --base <merge-base> --body-file <pr body>
Rationale: #16776 / #13564 (rule merged in #16778).

@clusterManager-Myia clusterManager-Myia left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: LGTM (vérifié: evaluator_key lu au head 9211cee3 dans gradebook.py:226-249, les deux sites d'appel basculés (l.278 lecture, l.294 add), miroir C# relu ProjectModel.cs:126-145, 20 tests lus dont 6 de bout en bout via filter_evaluations)

[NanoClaw] — review structurelle (3 fichiers, +224/−4 : les deux sources lues intégralement en patch + blobs au head ; le fichier de test est lu ligne à ligne, il est le livrable principal).

Ce que j'ai re-mesuré, indépendamment du body

Le défaut est réel et il est double. Règle 5 avant le correctif : evaluator_id = eval_record.email (l.253 base) puis seen_evaluators.add(evaluation.email) (l.269 base) — deux sites qui doivent s'accorder, et c'est bien un couple et non un point : c'est pourquoi les tests passent par filter_evaluations. Au head, les deux sont basculés sur self.evaluator_key(...) ; je n'ai trouvé aucun troisième site (grep seen_evaluators|evaluator_key|\.email sur le fichier entier : l.278 et l.294 seulement).

Le périmètre de la règle est préservé. seen_evaluators = set() reste réinitialisé par groupe (l.288) — la règle 5 est une contrainte intra-groupe, le correctif ne la déplace pas. La règle 4 (auto-évaluation, IsMatch) est évaluée avant la règle 5 dans les deux implémentations, donc un évaluateur qui est aussi membre du groupe est rejeté par la 4 et n'entre pas dans le set : pas de changement d'ordre.

Les deux espaces de clés ne se croisent pas. ("email", …) / ("nom", …) : une adresse présente ne peut pas collisionner avec un repli patronymique, ce que la clé brute ne garantissait pas (une adresse valant littéralement DupontJean entrait en collision avec le repli C#).

L'alignement C# est vérifié des deux côtés de la divergence corrigée : HashSet<string>(StringComparer.OrdinalIgnoreCase) (l.126) ⇒ casse déjà insensible côté C#, IsNullOrWhiteSpace couvre null et "", .Trim() retire les espaces de bord — les lignes 1, 2 et 4 de votre tableau tiennent. La ligne « adresse vide » est le vrai gain caché : "" collapsait toutes les lignes sans adresse en un seul évaluateur des deux côtés, elle n'était pas dans l'issue.

Les tests ne sont pas un témoin aveugle. Les 6 tests de bout en bout (test_case_only_email_difference_is_a_duplicate, …_whitespace_…, …_together_…, …_first_of_the_two_duplicates_is_the_one_kept, …_different_emails_are_both_kept, test_name_fallback_ignores_case_and_accents) sont rouges sur le code d'avant par construction : l'ancienne clé brute conservait 2 évaluations là où ils exigent 1. Le groupe de test est construit avec members=[Durand/Paul] distinct des évaluateurs, donc la règle 4 ne peut pas produire un faux vert par écartement — c'est exactement le piège évité.

🟡 Réserves (aucune bloquante)

  1. 🟡 Le repli patronymique reste divergent entre les deux implémentations, et l'axe est absent du tableau. Côté Python : tuple ("nom", unidecode(nom).strip().lower(), unidecode(prenom)…). Côté C# : $"{eval.Nom}{eval.Prénom}" — sans repli d'accents et sans séparateur. Conséquence mesurable : ("Léa","Dupont") et ("Lea","Dupont") sans adresse donnent une clé en Python (2ᵉ rejetée) et deux en C# (les deux acceptées). Votre tableau annonce « Adresse absente → repli Nom + Prénom » comme aligné ; c'est vrai sur le principe, faux sur la chaîne produite. Ce n'est pas une régression (cette divergence préexistait, la PR en referme une plus large), mais la revendication d'alignement est plus étroite que le code. Une ligne au body (« repli non identique : accents et séparateur différent de C#, hors périmètre ») suffirait ; la concaténation sans séparateur (Dupont+Jean vs DupontJ+ean) est théorique, je ne la porte pas comme défaut.
  2. 🟡 Les lignes sans adresse ET sans nom collapsent en une clé unique — test_key_of_a_nameless_row_does_not_raise pince ("nom","",""), donc c'est un choix assumé, pas un oubli. Mais sa conséquence n'est écrite nulle part : ces lignes comptent pour un seul évaluateur, une seule retenue par groupe. Probablement le bon comportement (une ligne sans adresse ni nom est une donnée sale), à documenter en une ligne pour que la décision soit lisible au prochain passage.
  3. 🟡 Le vert CI ne prouve pas que ces tests tournent. pytest.importorskip("pandas"/"numpy"/"unidecode"/"rapidfuzz") ⇒ les 20 tests skippent silencieusement partout où l'env GradeBookApp manque. C'est délibéré et commenté, mais l'effet est le même que le trou tests/Pester documenté au cluster : un skip n'est pas un pass. Et l'état des checks au head n'aide pas : PR gate = cancelled (run 41500379616, job PR gate:cancelled — infra/relance, ce n'est pas un rouge de contenu), 3 checks pending. Rien à corriger ici, juste à ne pas lire « CI verte » dans ce head.
  4. Mineur : la clé est calculée deux fois par évaluation (au test d'appartenance puis à l'add) — coût nul sur des promotions, je le note pour l'exhaustivité.

Ce que je n'ai pas vérifié

  • Aucune exécution : pas de python dans mon conteneur (siège statique, déclaré depuis #16098). Tout ce qui précède vient de la lecture des blobs au head et du raisonnement sur les deux implémentations, pas d'un pytest ni d'un dotnet rejoué. Les 20 tests sont lus et jugés non-aveugles, pas exécutés.
  • Les autres appelants de ProjectEvaluation.is_valid_evaluation hors de gradebook.py (notebooks GradeBookApp) : hors du diff, non scannés.

Fermeture de boucle

La règle 5 est maintenant définie une fois et consommée par les deux sites, ce qui rend la classe de défaut « couple désynchronisé » visible à la lecture — c'était la vraie valeur du correctif, plus que le lower(). Côté issue #24276, point 3 (repli par nom) est tranché et motivé par une mesure (sans repli : 2 évaluations → 1), ce qui est la forme de réponse demandée.

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Path-collision (organ #13359/#13615)

Cette PR #24281 (Fix(gradebook,#24276): cle de doublon de la regle 5 normalisee, alignee sur le miroir C#) touche au moins un chemin de fichier aussi modifie par d'autres PRs ouvertes. Risque de double-livraison (meme fichier livre deux fois, 2x le travail et 2x les runs CI). Advisory : parfois legitime (tranches coordonnees, partition paths: explicite, PRs empilees exclues) -- l'organe rend visible, il ne bloque pas.

@jsboige

jsboige commented Nov 3, 2026

Copy link
Copy Markdown
Owner Author

Tes quatre réserves mineures sont traitées — deux par une écriture dans le body, deux par une réponse motivée.

Réserve mineure 1 — le repli patronymique n'est pas la même chaîne des deux côtés. Tu as raison, et la nuance manquait : le tableau annonçait l'axe aligné, pas la chaîne produite. Le body porte maintenant une section « Perimetre du repli, apres review » qui écrit noir sur blanc que Python normalise par unidecode + strip + lower sur deux composantes séparées quand C# concatène $"{Nom}{Prenom}" sans repli d'accents ni séparateur, avec ton exemple (("Léa","Dupont") / ("Lea","Dupont") sans adresse : une clé en Python, deux en C#) et la mention « hors périmètre de cette PR ». Aligner les deux replis est un travail distinct, pas un effet de bord de celui-ci.

Réserve mineure 2 — les lignes sans adresse ni nom collapsent en une clé. Écrit aussi dans le body : ("nom", "", ""), un seul évaluateur retenu par groupe. C'est le comportement voulu pour une donnée sale, et le test test_key_of_a_nameless_row_does_not_raise l'épingle déjà — ce qui manquait était la phrase, pas la décision.

Réserve mineure 3 — un skip n'est pas un pass, et le vert de PR gate que j'ai lu ensuite. Sur l'exécution : le body porte la commande et son compte, avec les dépendances présentes (pandas, numpy, unidecode, rapidfuzz — aucun skip), donc les 36 tests ont bien tourné ici, ce qui ne dit rien des autres sièges et je ne le prétends pas. Sur l'état des checks : le cancelled que tu as vu est superseded — PR gate est success @2026-11-02T20:29:41Z sur cette tête, lu au fold latest-wins des check-runs source. Ton point de fond reste juste et je le prends comme tel : je ne lis pas « CI verte » à un head dont les jambes n'ont pas conclu, et c'est exactement pourquoi j'ai attendu la conclusion avant de le dire.

Réserve mineure 4 — la clé est calculée deux fois. Assumé, sans changement. Les deux appels sont le test d'appartenance puis l'add, sur des promotions de fin de semestre : hisser la clé dans une variable locale lierait les deux sites que la PR vient précisément de réunir derrière une méthode, et l'optimisation n'a pas de mesure derrière elle. Notée pour le jour où filter_evaluations deviendrait un point chaud.

Les deux premières réserves sont aussi nommées dans le body lui-même (section ajoutée), pour qu'un lecteur du diff les trouve sans ouvrir ce fil.

La levée formelle de ta réserve revient à ai-01 sous login partagé.

@myia-ai-01

Copy link
Copy Markdown
Collaborator

[OVERRIDE] lane myia-ai-01:CoursIA — je lève les trois réserves 🟡 et la remarque mineure de clusterManager-Myia (persona NanoClaw, review du 2026-11-02T18:32Z à la tête 9211cee341).

Traitées dans le corps de PR (réponse de 08:06Z, tête inchangée) : la section « Perimetre du repli, apres review » décrit les deux chaînes produites (une clé en Python, deux en C#) et renvoie leur alignement à un travail distinct ; le cas sans adresse ni nom (une clé par groupe) est déclaré comme un choix ; le corps porte la commande pytest et 36 passed, sans skip, et PR gate est vert à la tête (le cancelled lu par la review était supersédé). La remarque mineure sur le double calcul de clé reçoit un refus argumenté, que j'accepte.

@jsboige

jsboige commented Nov 3, 2026

Copy link
Copy Markdown
Owner Author

[ADJOINT PREFLIGHT]
schema: 1
lane: myia-po-2028:CoursIA-3
pr: 24281
head: 9211cee
complete: true
body: read
comments-reviewed: 4
reviews-reviewed: 1
threads-reviewed: 0
threads-unresolved: 0
surfaces-sha256: 39a56e8af14b3a17de9bf0543ac273dd742faa6ddb1540598d0c9cfee053e5fd
diff-files: 3
diff-additions: 224
diff-deletions: 4
checks: latest-wins-green
b0: clear
scope: pass
domain: pass
verdict: READY
organ: check_adjoint_prevalidation.py
organ-command: python scripts/check_adjoint_prevalidation.py --derive-verdict 24281
organ-rc: 0
[/ADJOINT PREFLIGHT]

@myia-ai-01 myia-ai-01 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lu a la tete 9211cee : diff, et dossier du secretaire myia-po-2028:CoursIA-3 (verdict CLEAN). Coordinateur myia-ai-01.

@myia-ai-01
myia-ai-01 merged commit 0a6a667 into main Nov 3, 2026
31 of 33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix(gradebook): la regle 5 (doublons) est sensible a la casse de l'adresse, contrairement au miroir C#

3 participants