ia : détection de dérive du modèle de prévision (#117) #238

Merged
gabriel merged 5 commits from florian/117-derive-modele into develop 2026-09-09 17:32:33 +00:00
Member

Un modèle ne tombe pas en panne, il se démode. La passe horaire compare
désormais les entrées et l'erreur du modèle promu à une référence FIGÉE À SA
PROMOTION, lève un signal au-delà de seuils déclarés, et publie son verdict
pour Prometheus.

Ce que le ticket demandait, et où c'est :

  • inference/derive.py — la règle, en Python pur. Trois écarts : déplacement
    de la moyenne des entrées en écarts-types de la référence, rapport des
    dispersions (rendu dans les deux sens, un capteur bloqué fait chuter la
    dispersion autant qu'une agitation la fait monter), rapport des erreurs.
    Verdict à trois valeurs et non deux : « surveillance » à mi-chemin de la
    borne évite qu'une mesure oscillante allume et éteigne l'alerte.
  • model/entrainement.py — journalise la référence (moyenne, dispersion,
    effectif des entrées apprises) dans l'exécution MLflow. Elle est donc
    adressée par (modèle, version) et ne peut pas survivre au modèle.
  • inference/reference_promue.py — la relit par le protocole Registre du
    #36. Une version sans référence rend None et le dit : la prévision continue
    d'être servie, la dérive n'est pas mesurée.
  • inference/realise.py — remplit public.prevision.valeur_reference_kw, la
    colonne que la migration 0017 réservait et que l'ADR 0013 laissait vide « tant
    que ce ticket n'est pas ouvert ». C'est la moitié manquante : sans réalisé,
    aucune erreur récente n'est mesurable.
  • inference/publication.py — dépose un .prom pour le collecteur textfile de
    node-exporter, motif de pg-backup.sh (#42), y compris son chmod 0644 : en
    0640 node-exporter ne lit pas et l'alerte se déclenche sur une absence.
  • rules.yaml — sixième alerte, sur le verdict 2 tenu deux heures.
    noDataState: OK : une dérive non mesurée n'est pas une dérive.

Seuils déclarés (critère 2), surchargeables sans toucher au code :
ENERVISION_INFERENCE_DERIVE_{DEPLACEMENT_MAX,DISPERSION_MAX,ERREUR_MAX,
COUPLES_MINIMUM}.

Deux corrections qu'un essai contre le vrai serveur a imposées :

  • la requête visait une colonne valeur_kw qui n'existe pas ; l'agrégat porte
    moyenne_kw, la cible même du modèle. Un test verrouille le nom, parce que
    viser max_kw serait passé sans rien casser en comparant une prévision de
    moyenne à un maximum ;
  • psycopg rend une colonne numeric en Decimal : un filtre naïf sur
    (int, float) aurait laissé l'erreur « non mesurée » pour toujours, sans
    message. mypy strict a forcé à regarder ce que le pilote rend vraiment.

test_job_inference lisait caplog.records en entier alors que son propre
at_level vise inference.emission : n'importe quel avertissement d'un autre
module le cassait. Filtré sur le journal qu'il annonce.

Ce que ça change

Closes #

Preuve

1. La dégradation propre quand la version promue n'a pas de référence.
La v6 date d'avant ce ticket : elle porte modele_mae mais aucune métrique de
distribution. La passe le dit et continue de prévoir.

WARNING inference.derive modèle enervision-prevision-h1 version 6 : pas de
référence de dérive, métriques absentes (entrees_moyenne_kw,
entrees_ecart_type_kw, entrees_n). La dérive n'est pas mesurée pour cette
version ; un réentraînement en posera une.

2. valeur_reference_kw se remplit pour la première fois. La colonne
attendait ce ticket depuis la migration 0017.

INFO inference.derive réalisé recopié sur 55 prévision(s) des 24 dernières heures
   couples évalués : 55, MAE récente : 45.82

 site_id | horodatage             | prévu   | réalisé | écart
 SITE003 | 2026-09-09 07:00:00+00 | 749.759 | 757.002 |   7.2
 SITE005 | 2026-09-09 07:00:00+00 | 516.283 | 491.670 |  24.6

3. Critère 1 — une dérive simulée sur les entrées déclenche, sans réalisé.

=== Une dérive simulée sur les entrées ===
   verdict : derive | motifs : entrées déplacées de 4.02 écart-type (borne 3.00)

4. Critère 3 — le fichier déposé pour node-exporter. Répertoire vérifié
accessible en écriture à deploy (drwxrwxr-x deploy:deploy), fichier en 0644.

# HELP ev_ia_derive_verdict Verdict de dérive du modèle promu : 0 stable, 1 surveillance, 2 dérive.
# TYPE ev_ia_derive_verdict gauge
ev_ia_derive_verdict{modele_version="6"} 2
ev_ia_derive_entrees_deplacement_ecarts_types{modele_version="6"} 4.01731
ev_ia_derive_erreur_ratio{modele_version="6"} 2.86184
ev_ia_derive_erreur_recente_mae_kw{modele_version="6"} 45.8205
ev_ia_derive_erreur_reference_mae_kw{modele_version="6"} 16.0109

5. Les six règles Grafana se chargent. Une règle mal formée empêcherait
Grafana de charger toutes les alertes.

YAML valide — 6 regles
   ev_ops_collecte_arretee / disque_plein / api_source / cible_supervision /
   sauvegarde_pg / ev_ia_derive_modele
nouvelle regle : for = 2h | noDataState = OK | expr : max(ev_ia_derive_verdict) > bool 1

Relecture

  • Un pair a relu et laissé un commentaire, même court
  • Ses remarques sont traitées, ou une réponse explique pourquoi elles ne le sont pas

Ce qui suit le code

  • docs/runbooks/ mis à jour, un geste d'exploitation a changé
  • docs/adr/ complété, une décision structurante a été prise

Où regarder en priorité

Un modèle ne tombe pas en panne, il se démode. La passe horaire compare désormais les entrées et l'erreur du modèle promu à une référence FIGÉE À SA PROMOTION, lève un signal au-delà de seuils déclarés, et publie son verdict pour Prometheus. Ce que le ticket demandait, et où c'est : - `inference/derive.py` — la règle, en Python pur. Trois écarts : déplacement de la moyenne des entrées en écarts-types de la référence, rapport des dispersions (rendu dans les deux sens, un capteur bloqué fait chuter la dispersion autant qu'une agitation la fait monter), rapport des erreurs. Verdict à trois valeurs et non deux : « surveillance » à mi-chemin de la borne évite qu'une mesure oscillante allume et éteigne l'alerte. - `model/entrainement.py` — journalise la référence (moyenne, dispersion, effectif des entrées apprises) dans l'exécution MLflow. Elle est donc adressée par (modèle, version) et ne peut pas survivre au modèle. - `inference/reference_promue.py` — la relit par le protocole `Registre` du #36. Une version sans référence rend None et le dit : la prévision continue d'être servie, la dérive n'est pas mesurée. - `inference/realise.py` — remplit `public.prevision.valeur_reference_kw`, la colonne que la migration 0017 réservait et que l'ADR 0013 laissait vide « tant que ce ticket n'est pas ouvert ». C'est la moitié manquante : sans réalisé, aucune erreur récente n'est mesurable. - `inference/publication.py` — dépose un `.prom` pour le collecteur textfile de node-exporter, motif de `pg-backup.sh` (#42), y compris son `chmod 0644` : en 0640 node-exporter ne lit pas et l'alerte se déclenche sur une absence. - `rules.yaml` — sixième alerte, sur le verdict 2 tenu deux heures. `noDataState: OK` : une dérive non mesurée n'est pas une dérive. Seuils déclarés (critère 2), surchargeables sans toucher au code : ENERVISION_INFERENCE_DERIVE_{DEPLACEMENT_MAX,DISPERSION_MAX,ERREUR_MAX, COUPLES_MINIMUM}. Deux corrections qu'un essai contre le vrai serveur a imposées : - la requête visait une colonne `valeur_kw` qui n'existe pas ; l'agrégat porte `moyenne_kw`, la cible même du modèle. Un test verrouille le nom, parce que viser `max_kw` serait passé sans rien casser en comparant une prévision de moyenne à un maximum ; - psycopg rend une colonne `numeric` en `Decimal` : un filtre naïf sur (int, float) aurait laissé l'erreur « non mesurée » pour toujours, sans message. mypy strict a forcé à regarder ce que le pilote rend vraiment. `test_job_inference` lisait `caplog.records` en entier alors que son propre `at_level` vise `inference.emission` : n'importe quel avertissement d'un autre module le cassait. Filtré sur le journal qu'il annonce. ## Ce que ça change <!-- Deux phrases. Ce qu'un relecteur doit comprendre avant d'ouvrir le code. --> Closes # ## Preuve **1. La dégradation propre quand la version promue n'a pas de référence.** La v6 date d'avant ce ticket : elle porte `modele_mae` mais aucune métrique de distribution. La passe le dit et continue de prévoir. ``` WARNING inference.derive modèle enervision-prevision-h1 version 6 : pas de référence de dérive, métriques absentes (entrees_moyenne_kw, entrees_ecart_type_kw, entrees_n). La dérive n'est pas mesurée pour cette version ; un réentraînement en posera une. ``` **2. `valeur_reference_kw` se remplit pour la première fois.** La colonne attendait ce ticket depuis la migration 0017. ``` INFO inference.derive réalisé recopié sur 55 prévision(s) des 24 dernières heures couples évalués : 55, MAE récente : 45.82 site_id | horodatage | prévu | réalisé | écart SITE003 | 2026-09-09 07:00:00+00 | 749.759 | 757.002 | 7.2 SITE005 | 2026-09-09 07:00:00+00 | 516.283 | 491.670 | 24.6 ``` **3. Critère 1 — une dérive simulée sur les entrées déclenche, sans réalisé.** ``` === Une dérive simulée sur les entrées === verdict : derive | motifs : entrées déplacées de 4.02 écart-type (borne 3.00) ``` **4. Critère 3 — le fichier déposé pour node-exporter.** Répertoire vérifié accessible en écriture à `deploy` (`drwxrwxr-x deploy:deploy`), fichier en 0644. ``` # HELP ev_ia_derive_verdict Verdict de dérive du modèle promu : 0 stable, 1 surveillance, 2 dérive. # TYPE ev_ia_derive_verdict gauge ev_ia_derive_verdict{modele_version="6"} 2 ev_ia_derive_entrees_deplacement_ecarts_types{modele_version="6"} 4.01731 ev_ia_derive_erreur_ratio{modele_version="6"} 2.86184 ev_ia_derive_erreur_recente_mae_kw{modele_version="6"} 45.8205 ev_ia_derive_erreur_reference_mae_kw{modele_version="6"} 16.0109 ``` **5. Les six règles Grafana se chargent.** Une règle mal formée empêcherait Grafana de charger *toutes* les alertes. ``` YAML valide — 6 regles ev_ops_collecte_arretee / disque_plein / api_source / cible_supervision / sauvegarde_pg / ev_ia_derive_modele nouvelle regle : for = 2h | noDataState = OK | expr : max(ev_ia_derive_verdict) > bool 1 ``` ## Relecture - [ ] Un pair a relu et laissé un commentaire, même court - [ ] Ses remarques sont traitées, ou une réponse explique pourquoi elles ne le sont pas ## Ce qui suit le code <!-- Ne cocher que ce qui s'applique, supprimer le reste. --> - [x] `docs/runbooks/` mis à jour, un geste d'exploitation a changé - [x] `docs/adr/` complété, une décision structurante a été prise ## Où regarder en priorité <!-- Là où tu as hésité, ou ce qui mérite un second avis. Facultatif, mais ça fait gagner du temps au relecteur. -->
florian self-assigned this 2026-09-09 09:44:19 +00:00
Un modèle ne tombe pas en panne, il se démode. La passe horaire compare
désormais les entrées et l'erreur du modèle promu à une référence FIGÉE À SA
PROMOTION, lève un signal au-delà de seuils déclarés, et publie son verdict
pour Prometheus.

Ce que le ticket demandait, et où c'est :

- `inference/derive.py` — la règle, en Python pur. Trois écarts : déplacement
  de la moyenne des entrées en écarts-types de la référence, rapport des
  dispersions (rendu dans les deux sens, un capteur bloqué fait chuter la
  dispersion autant qu'une agitation la fait monter), rapport des erreurs.
  Verdict à trois valeurs et non deux : « surveillance » à mi-chemin de la
  borne évite qu'une mesure oscillante allume et éteigne l'alerte.
- `model/entrainement.py` — journalise la référence (moyenne, dispersion,
  effectif des entrées apprises) dans l'exécution MLflow. Elle est donc
  adressée par (modèle, version) et ne peut pas survivre au modèle.
- `inference/reference_promue.py` — la relit par le protocole `Registre` du
  #36. Une version sans référence rend None et le dit : la prévision continue
  d'être servie, la dérive n'est pas mesurée.
- `inference/realise.py` — remplit `public.prevision.valeur_reference_kw`, la
  colonne que la migration 0017 réservait et que l'ADR 0013 laissait vide « tant
  que ce ticket n'est pas ouvert ». C'est la moitié manquante : sans réalisé,
  aucune erreur récente n'est mesurable.
- `inference/publication.py` — dépose un `.prom` pour le collecteur textfile de
  node-exporter, motif de `pg-backup.sh` (#42), y compris son `chmod 0644` : en
  0640 node-exporter ne lit pas et l'alerte se déclenche sur une absence.
- `rules.yaml` — sixième alerte, sur le verdict 2 tenu deux heures.
  `noDataState: OK` : une dérive non mesurée n'est pas une dérive.

Seuils déclarés (critère 2), surchargeables sans toucher au code :
ENERVISION_INFERENCE_DERIVE_{DEPLACEMENT_MAX,DISPERSION_MAX,ERREUR_MAX,
COUPLES_MINIMUM}.

Deux corrections qu'un essai contre le vrai serveur a imposées :

- la requête visait une colonne `valeur_kw` qui n'existe pas ; l'agrégat porte
  `moyenne_kw`, la cible même du modèle. Un test verrouille le nom, parce que
  viser `max_kw` serait passé sans rien casser en comparant une prévision de
  moyenne à un maximum ;
- psycopg rend une colonne `numeric` en `Decimal` : un filtre naïf sur
  (int, float) aurait laissé l'erreur « non mesurée » pour toujours, sans
  message. mypy strict a forcé à regarder ce que le pilote rend vraiment.

`test_job_inference` lisait `caplog.records` en entier alors que son propre
`at_level` vise `inference.emission` : n'importe quel avertissement d'un autre
module le cassait. Filtré sur le journal qu'il annonce.
adr 0013 : la détection de dérive n'est plus hors périmètre (#117)
Some checks failed
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 49s
Intégration / Contrôles statiques du dépôt (pull_request) Failing after 5s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 20s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 6m27s
e1e92b8b81
La fiche disait « personne ne remplit valeur_reference_kw tant que ce ticket
n'est pas ouvert ». Il l'est, et la colonne est remplie : la ligne était
devenue fausse du fait même du travail qu'elle annonçait.
lenaic requested review from lenaic 2026-09-09 14:36:24 +00:00
lenaic left a comment

Relu en entier, y compris les 35 cas. Ce qui suit est une demande de changement sur un seul point ; le reste tient.

Ce qui est juste et mérite d'être nommé : la séparation entrée/erreur, la référence figée à la promotion avec refus explicite d'une comparaison entre versions, les trois verdicts plutôt que deux, toutes les divisions gardées (écart-type nul, n < 2, MAE de référence nulle), la dispersion rendue symétrique, l'isolation totale de la passe, et la dérive mesurée après l'écriture sur la même connexion. Le choix de recopier plutôt que de joindre est argumenté et je le partage.


Ce qui bloque : la surveillance annonce « stable » quand elle a cessé de mesurer

publication.publier n'est atteint que si ecart existe. Les deux sorties anticipées de surveiller_derive rendent None sans rien publier :

  • if reference is None: return None
  • except Exception: journal.warning(...); return None

Or le fichier .prom reste sur le disque avec ses dernières valeurs. Donc si MLflow devient injoignable, si l'exécution promue perd sa référence, ou si la requête tombe :

le fichier garde ev_ia_derive_verdict = 0
Grafana affiche stable, indéfiniment
l'alerte noDataState: OK, donc elle ne se déclenche pas non plus
le journal porte un warning que personne ne lit

Aucune des sept séries ne porte d'horodatage : il n'existe pas de ev_ia_derive_derniere_mesure_timestamp_seconds.

Une surveillance qui annonce « stable » alors qu'elle a cessé de mesurer est pire que pas de surveillance, parce qu'elle fait cesser de regarder ailleurs.

C'est la leçon que le #228 a encodée ce matin, dans bin/_metriques-ops.sh, et je te la cite parce qu'elle est écrite pour ce cas exactement :

LA RÉUSSITE VIT DANS UN FICHIER À PART. C'est le point qui porte l'alerte : une passe en échec réécrit le code et la tentative, mais LAISSE VIEILLIR la dernière réussite. C'est ce vieillissement qui déclenche, et non le code de retour.

Ce module fait l'inverse : il ne réécrit rien du tout, donc rien ne vieillit.

Ce que je propose, et c'est court :

  • publier ev_ia_derive_derniere_mesure_timestamp_seconds à chaque tentative, y compris quand elle échoue — donc sortir la publication du chemin heureux ;
  • ajouter la condition de vieillissement à la règle, en or du verdict :
    time() - ev_ia_derive_derniere_mesure_timestamp_seconds > 3 * 3600
    
  • garder noDataState: OK : il couvre le cas « pas encore de mesure », pas le cas « la mesure s'est arrêtée ». Ce sont deux choses différentes et ton commentaire a raison sur la première.

Deux points que je ne bloque pas

Le commentaire du SQL de recopie est faux, et le vrai garde-fou est ailleurs.

and p.horodatage < %s   -- « les lignes dont l'instant est passé »

horodatage est le début du seau visé. À 14 h 35, p.horodatage < 14:35 est vrai pour le seau de 14 h 00, qui n'est pas terminé. Ce qui sauve, c'est que mesure_horaire ne matérialise que des seaux complets — pas cette condition.

Ce n'est pas théorique. Si quelqu'un passe l'agrégat en materialized_only = false pour gagner en fraîcheur — la tentation est réelle, on vient de resserrer sa latence au #205 — la recopie fige une heure partielle comme « réalisé », définitivement, puisque is null interdit toute réécriture. La mesure de dérive serait corrompue sans que rien ne le dise.

Une ligne de commentaire suffit à protéger le prochain : dire que la sûreté vient de la matérialisation par seaux complets, et que la toucher casse ceci.

Le fichier temporaire échappe au purgeur d'orphelins.

tempfile.NamedTemporaryFile(prefix="ev_ia_derive.prom.") produit ev_ia_derive.prom.a8f3c2. Le purgeur du collecteur, repris par _metriques-ops.sh, ne balaie que *.prom.[0-9]* — la convention vient du $$ du shell, qui est numérique. Un suffixe commençant par une lettre n'est jamais nettoyé.


Deux choses mécaniques

Conflit sur rules.yaml, contre les dix alertes du #228 fusionnées ce matin. Un rebase suffit.

Le rouge de la chaîne est périmé. Aucun « Contrôles statiques » n'a échoué dans les cinq dernières exécutions de ce job ; les deux plus récentes rendent Job succeeded. Le rebase relancera tout et l'effacera.


Un point d'organisation

Le #117 est assigné à Justine et c'est toi qui l'as fait. Aucun problème sur le fond, mais il faut réassigner le ticket, sinon le backlog raconte autre chose que la forge.

Corrige le premier point et je fusionne dans la foulée.

Relu en entier, y compris les 35 cas. Ce qui suit est une demande de changement sur un seul point ; le reste tient. Ce qui est juste et mérite d'être nommé : la séparation entrée/erreur, la référence figée à la promotion avec refus explicite d'une comparaison entre versions, les trois verdicts plutôt que deux, toutes les divisions gardées (écart-type nul, `n < 2`, MAE de référence nulle), la dispersion rendue symétrique, l'isolation totale de la passe, et la dérive mesurée **après** l'écriture sur la même connexion. Le choix de recopier plutôt que de joindre est argumenté et je le partage. --- ## Ce qui bloque : la surveillance annonce « stable » quand elle a cessé de mesurer `publication.publier` n'est atteint **que si `ecart` existe**. Les deux sorties anticipées de `surveiller_derive` rendent `None` sans rien publier : - `if reference is None: return None` - `except Exception: journal.warning(...); return None` Or le fichier `.prom` reste sur le disque avec ses dernières valeurs. Donc si MLflow devient injoignable, si l'exécution promue perd sa référence, ou si la requête tombe : | | | |---|---| | le fichier | garde `ev_ia_derive_verdict = 0` | | Grafana | affiche **stable**, indéfiniment | | l'alerte | `noDataState: OK`, donc elle ne se déclenche pas non plus | | le journal | porte un `warning` que personne ne lit | Aucune des sept séries ne porte d'horodatage : il n'existe pas de `ev_ia_derive_derniere_mesure_timestamp_seconds`. **Une surveillance qui annonce « stable » alors qu'elle a cessé de mesurer est pire que pas de surveillance**, parce qu'elle fait cesser de regarder ailleurs. C'est la leçon que le #228 a encodée ce matin, dans `bin/_metriques-ops.sh`, et je te la cite parce qu'elle est écrite pour ce cas exactement : > LA RÉUSSITE VIT DANS UN FICHIER À PART. C'est le point qui porte l'alerte : une passe en échec réécrit le code et la tentative, mais LAISSE VIEILLIR la dernière réussite. C'est ce vieillissement qui déclenche, et non le code de retour. Ce module fait l'inverse : il ne réécrit rien du tout, donc rien ne vieillit. **Ce que je propose**, et c'est court : - publier `ev_ia_derive_derniere_mesure_timestamp_seconds` à **chaque tentative**, y compris quand elle échoue — donc sortir la publication du chemin heureux ; - ajouter la condition de vieillissement à la règle, en `or` du verdict : ```promql time() - ev_ia_derive_derniere_mesure_timestamp_seconds > 3 * 3600 ``` - garder `noDataState: OK` : il couvre le cas « pas encore de mesure », pas le cas « la mesure s'est arrêtée ». Ce sont deux choses différentes et ton commentaire a raison sur la première. --- ## Deux points que je ne bloque pas **Le commentaire du SQL de recopie est faux, et le vrai garde-fou est ailleurs.** ```sql and p.horodatage < %s -- « les lignes dont l'instant est passé » ``` `horodatage` est le **début** du seau visé. À 14 h 35, `p.horodatage < 14:35` est vrai pour le seau de 14 h 00, qui n'est pas terminé. Ce qui sauve, c'est que `mesure_horaire` ne matérialise que des seaux complets — pas cette condition. Ce n'est pas théorique. Si quelqu'un passe l'agrégat en `materialized_only = false` pour gagner en fraîcheur — la tentation est réelle, on vient de resserrer sa latence au #205 — la recopie fige une heure partielle comme « réalisé », **définitivement**, puisque `is null` interdit toute réécriture. La mesure de dérive serait corrompue sans que rien ne le dise. Une ligne de commentaire suffit à protéger le prochain : dire que la sûreté vient de la matérialisation par seaux complets, et que la toucher casse ceci. **Le fichier temporaire échappe au purgeur d'orphelins.** `tempfile.NamedTemporaryFile(prefix="ev_ia_derive.prom.")` produit `ev_ia_derive.prom.a8f3c2`. Le purgeur du collecteur, repris par `_metriques-ops.sh`, ne balaie que `*.prom.[0-9]*` — la convention vient du `$$` du shell, qui est numérique. Un suffixe commençant par une lettre n'est jamais nettoyé. --- ## Deux choses mécaniques **Conflit sur `rules.yaml`**, contre les dix alertes du #228 fusionnées ce matin. Un rebase suffit. **Le rouge de la chaîne est périmé.** Aucun « Contrôles statiques » n'a échoué dans les cinq dernières exécutions de ce job ; les deux plus récentes rendent `Job succeeded`. Le rebase relancera tout et l'effacera. --- ## Un point d'organisation Le #117 est assigné à Justine et c'est toi qui l'as fait. Aucun problème sur le fond, mais il faut réassigner le ticket, sinon le backlog raconte autre chose que la forge. Corrige le premier point et je fusionne dans la foulée.
Un seul conflit, sur `rules.yaml`. Les deux côtés ajoutaient au même endroit :
cette branche une sixième alerte au groupe unique du #42, `develop` une
restructuration en deux groupes de cinq (#228).

La structure de `develop` est retenue, et la règle de dérive prend place dans
le groupe « Exploitation » plutôt que d'ouvrir un troisième groupe : une
dérive de modèle relève du même registre que les cinq du #42, un service qui
se dégrade sans tomber. Le pavé d'en-tête est recompté — onze alertes, non dix
— et dit d'où vient la onzième.

Rien d'autre n'a été touché. Les onze identifiants sont uniques, le fichier se
relit en YAML, et les deux groupes portent bien six et cinq règles.
outillage : le banc de supervision comptait les règles d'un seul préfixe (#117)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 10s
Intégration / Terraform — format, validité et lint (pull_request) Successful in 16s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 45s
Intégration / Checkov — audit de la configuration (pull_request) Successful in 16s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 17s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 6m4s
0fb36cf039
Le contrôle « chaque règle nomme un destinataire » comparait deux nombres qui
ne portaient pas sur le même ensemble. Le dénominateur ne retenait que les
identifiants `ev_ops_*`, le numérateur comptait tous les labels
`destinataire`. Une règle nommée autrement était donc invisible au premier et
visible au second.

C'est arrivé dès la première : le #117 ajoute `ev_ia_derive_modele`, une
alerte de modèle et non d'exploitation, nommée en conséquence. Le banc
annonçait « 11 label(s) destinataire pour 10 règle(s) » et faisait rougir la
tâche « Contrôles statiques » sur un fichier parfaitement correct.

Deux compteurs désormais, chacun sur son ensemble. `n_ops` garde le plancher
de dix règles d'exploitation promis par le #42 et le #228 — c'est lui qui
empêche une disparition silencieuse. `n_regles` compte toutes les règles quel
que soit leur préfixe, et c'est lui qui se compare aux destinataires.

Renommer la règle en `ev_ops_ia_derive` aurait fait taire le banc sans le
corriger : la prochaine famille d'alertes aurait rouvert le même défaut. Le
banc doit décrire la règle du dépôt — toute règle nomme un destinataire — pas
une convention de nommage qu'il est seul à connaître.

Les quatorze bancs de `tests/ci/` passent.
ia : la surveillance de dérive cesse d'annoncer « stable » quand elle ne mesure plus (#117)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 9s
Intégration / Terraform — format, validité et lint (pull_request) Successful in 19s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 44s
Intégration / Checkov — audit de la configuration (pull_request) Successful in 15s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 18s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 5m51s
1911b2323f
Répond au point bloquant de la relecture. `publication.publier` n'était atteint
que sur le chemin heureux : les deux sorties anticipées de `surveiller_derive`
— référence absente, exception — rendaient `None` sans rien écrire. Le fichier
`.prom` restait alors sur le disque avec ses dernières valeurs, Grafana
affichait « stable » indéfiniment, l'alerte ne se déclenchait pas non plus
puisque son `noDataState` vaut `OK`, et seul un `warning` de journal en portait
la trace. Une surveillance qui annonce stable après avoir cessé de mesurer est
pire que pas de surveillance : elle fait cesser de regarder ailleurs.

Le motif retenu est celui que `bin/_metriques-ops.sh` a écrit au #228, cité
dans la relecture, et il est repris tel quel.

- `ev_ia_derive.prom` se réécrit à CHAQUE tentative, y compris celles qui
  n'ont rien mesuré. Il porte alors `derniere_tentative_timestamp_seconds` et
  RIEN D'AUTRE : pas de verdict. Un zéro y dirait « stable » là où le sens est
  « on ne sait pas ». La série disparaît, ce que `noDataState: OK` traite
  correctement.
- `ev_ia_derive_mesure.prom` ne s'écrit QUE sur une mesure réussie. Il vieillit
  donc dès que la surveillance s'arrête, et c'est ce vieillissement qui alerte,
  jamais un code de retour.
- La règle porte les deux causes par un `or`. Après `max()` les deux membres
  ont la même étiquette vide : `or` rend le gauche quand il existe, le droit
  sinon. Tant qu'on mesure, seul le verdict décide ; dès qu'on cesse, le
  verdict n'est plus là et l'âge prend le relais, au-delà de trois heures.

UN DÉFAUT TROUVÉ EN ÉCRIVANT LE CAS D'ESSAI, et il annulait le correctif.
`{valeur:.6g}` convient aux rapports, il détruit un horodatage : un epoch tient
sur dix chiffres et sortait en « 1.7574e+09 ». Toutes les dates d'une même
tranche de mille secondes devenaient la même, et l'arrondi pouvait aller vers
le HAUT — la métrique aurait paru plus fraîche qu'elle ne l'était, exactement
le mensonge que ces deux séries existent pour empêcher. Les secondes epoch se
rendent désormais en entier.

Les deux remarques non bloquantes sont traitées aussi.

Le commentaire du SQL de recopie disait que `p.horodatage < %s` écartait les
heures en cours. C'est faux, `horodatage` est le début du seau : à 14 h 35 le
seau de 14 h 00 passe la borne sans être terminé. Ce qui protège est que
`mesure_horaire` ne matérialise que des seaux complets. Le commentaire le dit
maintenant, et dit aussi ce qui casse si quelqu'un passe l'agrégat en
`materialized_only = false` pour gagner la fraîcheur qu'on vient de resserrer
au #205.

Le fichier temporaire sortait en `.prom.a8f3c2` via `NamedTemporaryFile`. Le
purgeur d'orphelins ne balaie que `*.prom.[0-9]*`, convention héritée du `$$`
du shell. Il est nommé par le pid, donc nettoyable, et supprimé si l'écriture
échoue.

Éprouvé : quatre cas d'essai de plus, 241 cas d'inférence et de modèle au vert,
quatorze bancs de chaîne au vert, ruff propre.
gabriel merged commit 7344d4486c into develop 2026-09-09 17:32:33 +00:00
gabriel deleted branch florian/117-derive-modele 2026-09-09 17:32:33 +00:00
gabriel approved these changes 2026-09-09 17:32:39 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
g2/enervision!238
No description provided.