From 41ea472932ed71da95e973391d2deff09bf778b7 Mon Sep 17 00:00:00 2001 From: Ugo ROSERAT Date: Sun, 16 Aug 2026 10:21:15 +0200 Subject: [PATCH] fix: /v1/meta cesse d'annoncer des chiffres que l'API contredit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les `remarques` de `/v1/meta` étaient un `Vec<&str>` en dur. Le document annonçait un champ `race` pollué de 11 747 valeurs pendant que `/v1/referentiels/races` en servait 211, propres : trente secondes de vérification suffisaient à ruiner la crédibilité du reste, y compris les mentions de réutilisation. Elles sont désormais dérivées du jeu chargé à chaque requête, et un test compare ce qu'elles citent à ce que `/v1/referentiels` et `/v1/stats` servent. Les mêmes chiffres figés traînaient dans `openapi.rs` et trois pages de documentation ; ils viennent maintenant du store. Le champ `race` n'est plus pollué en amont, mais deux nomenclatures coexistent toujours : 211 libellés développés sur les fiches, 198 codes abrégés sur les liens de filiation. C'est ce que la remarque décrit. `parents_pendants` mesurait les citations, pas les identifiants. Il annonçait 568 là où 160 parents distincts manquent au jeu — et la remarque, elle, disait 160. Le compteur suit maintenant son nom ; `parents_references`, `references_de_parents` et `references_pendantes` publient les trois autres dénombrements, dont celui par citation. `empreinte_source` n'annonçait pas sa fonction de hachage : un blake3 comparé à un sha256 passe pour une divergence de contenu. `algorithme_empreinte`, `fichier_source` et `octets_source` rendent l'empreinte reproductible, ce dont la remarque sur la mention obligatoire avait besoin pour désigner le jeu sans ambiguïté. Enfin `record` et `statut_reproducteur`, présents sur 159 852 et 1 006 043 fiches, étaient ignorés à l'ingestion. Ils sont exposés ; le second est filtrable, listé en référentiel et ventilable. Il ne fait pas double emploi avec `nombre_de_descendants`, qui est calculé : une jument déclarée poulinière sans produit enregistré n'est pas une jument jamais mise à la reproduction, distinction que le calcul ne reconstruit pas. Format d'image 5 → 6 : relancer `equides-ingest` avant de déployer. Les quatre changements partagent cette montée de format, d'où un seul commit. --- .github/workflows/ci.yml | 32 +++- .gitignore | 1 + docs/openapi.json | 170 ++++++++++++++++----- docs/src/content/docs/donnees/defauts.md | 82 +++++++--- docs/src/content/docs/guides/filtrer.md | 13 +- docs/src/content/docs/guides/genealogie.md | 8 +- src/api/meta.rs | 147 ++++++++++++++---- src/api/params.rs | 2 + src/api/referentiels.rs | 7 +- src/api/stats.rs | 13 +- src/bin/ingest.rs | 75 +++++++-- src/model.rs | 6 + src/openapi.rs | 134 +++++++++++++--- src/query.rs | 14 ++ src/snapshot.rs | 13 +- src/store/index.rs | 40 +++-- src/store/mod.rs | 79 +++++++++- tests/api.rs | 123 ++++++++++++++- 18 files changed, 803 insertions(+), 156 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d588cb..d4b11d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,9 +105,10 @@ jobs: run: | cat > echantillon.jsonl <<'JSONL' {"id":"AAAAAAAAAAAAAAAAAAAAAA","slug":"grand-pere","nom":"GRAND PERE","url":"https://infochevaux.ifce.fr/fr/grand-pere-AAAAAAAAAAAAAAAAAAAAAA/infos-generales","race":"Trotteur Francais","sexe":"Male","robe":"Bai","annee_naissance":1990} - {"id":"BBBBBBBBBBBBBBBBBBBBBA","slug":"grand-mere","nom":"GRAND MERE","url":"https://infochevaux.ifce.fr/fr/grand-mere-BBBBBBBBBBBBBBBBBBBBBA/infos-generales","race":"Trotteur Francais","sexe":"Femelle","robe":"Alezan","annee_naissance":1992} + {"id":"BBBBBBBBBBBBBBBBBBBBBA","slug":"grand-mere","nom":"GRAND MERE","url":"https://infochevaux.ifce.fr/fr/grand-mere-BBBBBBBBBBBBBBBBBBBBBA/infos-generales","race":"Trotteur Francais","sexe":"Femelle","robe":"Alezan","annee_naissance":1992,"statut_reproducteur":"Pouliniere"} + {"id":"DDDDDDDDDDDDDDDDDDDDDA","slug":"jamais-saillie","nom":"JAMAIS SAILLIE","url":"https://infochevaux.ifce.fr/fr/jamais-saillie-DDDDDDDDDDDDDDDDDDDDDA/infos-generales","race":"Trotteur Francais","sexe":"Femelle","robe":"Bai","annee_naissance":2018,"statut_reproducteur":"Pouliniere"} {"id":"CCCCCCCCCCCCCCCCCCCCCA","slug":"pere","nom":"PERE","url":"https://infochevaux.ifce.fr/fr/pere-CCCCCCCCCCCCCCCCCCCCCA/infos-generales","race":"Trotteur Francais","sexe":"Male","robe":"Bai","annee_naissance":2005,"filiation_texte":"Par GRAND PERE TF et GRAND MERE TF","pere":{"id":"AAAAAAAAAAAAAAAAAAAAAA","nom":"GRAND PERE","race":"TF","pays":null,"alias":null},"mere":{"id":"BBBBBBBBBBBBBBBBBBBBBA","nom":"GRAND MERE","race":"TF","pays":null,"alias":null}} - {"id":"EEEEEEEEEEEEEEEEEEEEEA","slug":"eldorado","nom":"ELDORÀDO","url":"https://infochevaux.ifce.fr/fr/eldorado-EEEEEEEEEEEEEEEEEEEEEA/infos-generales","race":"Trotteur Francais","sexe":"Femelle","robe":"Bai","annee_naissance":2015,"pere":{"id":"CCCCCCCCCCCCCCCCCCCCCA","nom":"PERE","race":"TF","pays":"IRLANDE","alias":null},"performances":[{"discipline":"TROT COURSE","texte":"BTR +59 (0.35)","indices":[{"code":"BTR","valeur":"+59","coefficient":0.35,"annee":null,"appreciation":"indice-satisfaisant"}]}]} + {"id":"EEEEEEEEEEEEEEEEEEEEEA","slug":"eldorado","nom":"ELDORÀDO","url":"https://infochevaux.ifce.fr/fr/eldorado-EEEEEEEEEEEEEEEEEEEEEA/infos-generales","race":"Trotteur Francais","sexe":"Femelle","robe":"Bai","annee_naissance":2015,"record":"1'16\"7 (AEL3V)","pere":{"id":"CCCCCCCCCCCCCCCCCCCCCA","nom":"PERE","race":"TF","pays":"IRLANDE","alias":null},"performances":[{"discipline":"TROT COURSE","texte":"BTR +59 (0.35)","indices":[{"code":"BTR","valeur":"+59","coefficient":0.35,"annee":null,"appreciation":"indice-satisfaisant"}]}]} {"id":"FFFFFFFFFFFFFFFFFFFFFA","slug":"orphelin","nom":"ORPHELIN","url":"https://infochevaux.ifce.fr/fr/orphelin-FFFFFFFFFFFFFFFFFFFFFA/infos-generales","race":"Pur Sang","sexe":"Hongre","annee_naissance":2020,"pere":{"id":"ZZZZZZZZZZZZZZZZZZZZZA","nom":"PERE INTROUVABLE","race":"PS","pays":null,"alias":null}} JSONL @@ -134,7 +135,7 @@ jobs: echo "— comptage" test "$(curl -sf 'http://127.0.0.1:8081/v1/equides' \ - | python3 -c 'import json,sys; print(json.load(sys.stdin)["pagination"]["total"])')" = "5" + | python3 -c 'import json,sys; print(json.load(sys.stdin)["pagination"]["total"])')" = "6" echo "— les listes servent un résumé" curl -sf 'http://127.0.0.1:8081/v1/equides?limite=1' \ @@ -160,6 +161,29 @@ jobs: | python3 -c 'import json,sys; p=json.load(sys.stdin)["pere"]; \ assert p["nom"] == "PERE INTROUVABLE" and p["fiche_disponible"] is False, p' + echo "— record et statut de reproducteur traversent l'ingestion" + curl -sf 'http://127.0.0.1:8081/v1/equides/EEEEEEEEEEEEEEEEEEEEEA' \ + | python3 -c 'import json,sys; e=json.load(sys.stdin); \ + assert e["record"] == "1'"'"'16\"7 (AEL3V)", e; \ + assert "statut_reproducteur" not in e, e' + + echo "— le statut déclaré ne se déduit pas de la descendance" + curl -sf 'http://127.0.0.1:8081/v1/equides/DDDDDDDDDDDDDDDDDDDDDA' \ + | python3 -c 'import json,sys; e=json.load(sys.stdin); \ + assert e["statut_reproducteur"] == "Pouliniere", e; \ + assert e["nombre_de_descendants"] == 0, e' + test "$(curl -sf 'http://127.0.0.1:8081/v1/equides?statut_reproducteur=pouliniere' \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["pagination"]["total"])')" = "2" + curl -sf 'http://127.0.0.1:8081/v1/referentiels/statuts_reproducteur' \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); \ + assert d["nombre_de_modalites"] == 1, d' + + echo "— parents pendants comptés par identifiant, citations à part" + curl -sf http://127.0.0.1:8081/v1/meta \ + | python3 -c 'import json,sys; a=json.load(sys.stdin)["jeu_de_donnees"]["anomalies"]; \ + assert (a["parents_references"], a["parents_pendants"]) == (4, 1), a; \ + assert (a["references_de_parents"], a["references_pendantes"]) == (4, 1), a' + echo "— filtre par code d'indice" test "$(curl -sf 'http://127.0.0.1:8081/v1/equides?indice=BTR' \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["pagination"]["total"])')" = "1" @@ -172,7 +196,7 @@ jobs: echo "— télémétrie hors du port public" test "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/metrics)" = "404" - curl -sf http://127.0.0.1:9091/metrics | grep -q 'equides_lignes 5' + curl -sf http://127.0.0.1:9091/metrics | grep -q 'equides_lignes 6' echo "— la racine sert la découverte, quel que soit l'Accept" curl -sf -H 'Accept: text/html' http://127.0.0.1:8081/ \ diff --git a/.gitignore b/.gitignore index 2d881ae..a16a531 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ .DS_Store *.jsonl /node_modules +/data/*.zst diff --git a/docs/openapi.json b/docs/openapi.json index 7f038ac..37d1f07 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -23,7 +23,19 @@ "type": "integer" }, "parents_pendants": { - "description": "Parents référencés mais absents du fichier : le lien est rompu, et exposé comme une absence de parent.", + "description": "Ceux d'entre eux qui sont absents du fichier : le lien est rompu, et exposé comme une absence de parent. Dénombrés par identifiant, pas par citation — un étalon absent cité mille fois compte pour un.", + "type": "integer" + }, + "parents_references": { + "description": "Identifiants de parents distincts cités par au moins une fiche.", + "type": "integer" + }, + "references_de_parents": { + "description": "Emplacements `pere`, `mere` ou `pere_de_mere` portant un identifiant, toutes fiches confondues.", + "type": "integer" + }, + "references_pendantes": { + "description": "Ceux d'entre eux dont la cible manque. Toujours supérieur ou égal à `parents_pendants` : c'est le même défaut compté par citation plutôt que par identifiant.", "type": "integer" }, "robe_absente": { @@ -89,7 +101,7 @@ "properties": { "donnees": { "items": { - "$ref": "#/components/schemas/Reference" + "$ref": "#/components/schemas/Produit" }, "type": "array" }, @@ -156,9 +168,15 @@ "type": "array" }, "race": { + "description": "Libellé développé. Les objets `pere`, `mere` et `pere_de_mere` portent, eux, le code abrégé du lien.", "example": "Trotteur Francais", "type": "string" }, + "record": { + "description": "Chrono de course déclaré par la source. Texte : ni comparable ni triable en l'état. Absent quand la source n'en publie pas.", + "example": "1'16\"7 (AEL3V)", + "type": "string" + }, "robe": { "example": "Bai", "type": "string" @@ -176,6 +194,11 @@ "example": "qabalah-mercury", "type": "string" }, + "statut_reproducteur": { + "description": "Statut déclaré à la reproduction, là où `nombre_de_descendants` est calculé : une poulinière sans produit enregistré porte quand même son statut. Son absence est un silence de la source, pas une négation.", + "example": "Pouliniere", + "type": "string" + }, "url": { "description": "Fiche d'origine, reconstruite depuis le slug et l'identifiant.", "type": "string" @@ -195,11 +218,21 @@ "JeuDeDonnees": { "description": "Provenance de l'extraction servie. `source` et `ingere_le` sont ce que la mention obligatoire de réutilisation doit citer.", "properties": { + "algorithme_empreinte": { + "description": "Fonction de hachage employée pour `empreinte_source`. Publiée pour que l'empreinte soit reproductible sans avoir à deviner l'algorithme.", + "example": "blake3", + "type": "string" + }, "anomalies": { "$ref": "#/components/schemas/Anomalies" }, "empreinte_source": { - "description": "Empreinte blake3 du fichier source : elle identifie exactement l'extraction servie.", + "description": "Empreinte du fichier ingéré, octet pour octet — pas celle d'un export normalisé. Elle identifie exactement l'extraction servie.", + "type": "string" + }, + "fichier_source": { + "description": "Nom du fichier d'extraction ingéré, celui que `empreinte_source` et `octets_source` décrivent.", + "example": "chevaux.jsonl", "type": "string" }, "ingere_le": { @@ -212,6 +245,10 @@ "lignes": { "type": "integer" }, + "octets_source": { + "description": "Taille exacte du fichier ingéré.", + "type": "integer" + }, "producteur": { "type": "string" }, @@ -435,7 +472,7 @@ "type": "string" }, "documentation": { - "example": "/", + "example": "https://docs.api-equides.org", "type": "string" }, "indice": { @@ -471,6 +508,42 @@ ], "type": "object" }, + "Produit": { + "description": "Un produit direct, décrit comme un nœud de pedigree : mêmes champs, même sens.", + "properties": { + "annee_naissance": { + "example": 2004, + "type": "integer" + }, + "fiche_disponible": { + "description": "Toujours vrai : un produit est une fiche du jeu.", + "type": "boolean" + }, + "id": { + "example": "QylKqa2SStCCxjmFVYNvvg", + "type": "string" + }, + "nom": { + "example": "PAMPA D'ARPO", + "type": "string" + }, + "race": { + "type": "string" + }, + "sexe": { + "example": "Femelle", + "type": "string" + } + }, + "required": [ + "id", + "nom", + "race", + "sexe", + "fiche_disponible" + ], + "type": "object" + }, "Reference": { "description": "Renvoi vers un autre équidé du jeu de données.", "properties": { @@ -635,6 +708,14 @@ "avec_performances": { "type": "integer" }, + "avec_record": { + "description": "Fiches portant un chrono de course déclaré.", + "type": "integer" + }, + "avec_statut_reproducteur": { + "description": "Fiches portant un statut déclaré à la reproduction, toutes modalités confondues.", + "type": "integer" + }, "avertissement": { "description": "Rappel du sens de « sans performances », que le seul effectif induirait en erreur.", "type": "string" @@ -734,32 +815,20 @@ "name": "Fiches d'origine", "url": "https://infochevaux.ifce.fr" }, - "description": "API REST ouverte sur les fiches publiques d'équidés de l'IFCE, avec généalogie et indices de performance.\n\n**Service indépendant, non affilié à l'IFCE.** La donnée servie est une extraction datée ; les fiches en ligne font seules autorité.\n\n**Données** : Extraction des fiches publiques d'infochevaux.ifce.fr, 4446638 fiches. Licence : Licence Ouverte / réutilisation d'informations publiques (CRPA art. L321-1). Mentionnez la source et la date de mise à jour, exposées par `/v1/meta`. Aucune donnée à caractère personnel n'est exposée.\n\n**Sans authentification** : ni clé, ni jeton, ni compte.\n\n## Limites\n\nDébit 50 unités par seconde et par adresse, rafale 200 — une requête ordinaire coûte une unité, les réponses volumineuses davantage. Concurrence 8, délai 5 s. Pagination 100 lignes, profondeur 10000. Pedigree 8 générations. Requête 64 paramètres et 4096 octets. Référentiels tronqués à 2000 modalités.\n\nLes réponses portent un `ETag` et un `Cache-Control` d'une heure ; une revalidation `If-None-Match` renvoie `304` sans rien recalculer. Les erreurs suivent la RFC 9457, en `application/problem+json`.", + "description": "API REST ouverte sur les fiches publiques d'équidés de l'IFCE, avec généalogie et indices de performance.\n\n**Service indépendant, non affilié à l'IFCE.** La donnée servie est une extraction datée ; les fiches en ligne font seules autorité.\n\n**Données** : Extraction des fiches publiques d'infochevaux.ifce.fr, 4446638 fiches. Licence : Licence Ouverte / réutilisation d'informations publiques (CRPA art. L321-1). Mentionnez la source et la date de mise à jour, exposées par `/v1/meta`. Aucune donnée à caractère personnel n'est exposée.\n\n**Sans authentification** : ni clé, ni jeton, ni compte.\n\n## Limites\n\nDébit 50 unités par seconde et par adresse, rafale 200 — une requête ordinaire coûte une unité, les réponses volumineuses davantage. Concurrence 48, délai 5 s. Pagination 100 lignes, profondeur 10000. Pedigree 8 générations. Requête 64 paramètres et 4096 octets. Référentiels tronqués à 2000 modalités.\n\nLes réponses portent un `ETag` et un `Cache-Control` d'une heure ; une revalidation `If-None-Match` renvoie `304` sans rien recalculer. Les erreurs suivent la RFC 9457, en `application/problem+json`.", "license": { "identifier": "MIT", "name": "MIT (code)" }, "summary": "Interrogation des données publiques des équidés enregistrés en France depuis 1976.", "title": "API Équidés", - "version": "0.1.0" + "version": "1.1.0" }, "openapi": "3.1.0", "paths": { "/": { "get": { - "description": "Une seule URL à retenir. La négociation de contenu évite d'avoir à choisir : un navigateur reçoit cette page, un client qui envoie `Accept: application/json` reçoit le document de découverte — routes servies et gabarits d'URL.", - "parameters": [ - { - "description": "`application/json` pour la découverte ; toute autre valeur, ou aucune, sert la documentation en HTML.", - "example": "application/json", - "in": "header", - "name": "Accept", - "required": false, - "schema": { - "type": "string" - } - } - ], + "description": "Le point d'entrée : routes servies, gabarits d'URL, licence et provenance. Un client qui ne connaît que cette URL y trouve de quoi construire toutes les autres. La documentation destinée aux humains vit sur .", "responses": { "200": { "content": { @@ -767,14 +836,9 @@ "schema": { "$ref": "#/components/schemas/Decouverte" } - }, - "text/html": { - "schema": { - "type": "string" - } } }, - "description": "Documentation HTML, ou document de découverte JSON." + "description": "Document de découverte." }, "304": { "description": "Contenu inchangé depuis l'`ETag` fourni en `If-None-Match`. Aucun corps, et rien n'est recalculé côté service." @@ -800,7 +864,7 @@ "description": "Capacité de traitement simultané atteinte, délai maximal dépassé, ou données non chargées. `Retry-After` indique le délai." } }, - "summary": "Documentation, ou document de découverte", + "summary": "Document de découverte", "tags": [ "Service" ] @@ -943,8 +1007,8 @@ "description": "Les filtres d'une même dimension se combinent en OU, les dimensions différentes en ET.\n\nLes lignes sont renvoyées en **résumé** par défaut ; `vue=complet` sert les fiches entières.\n\nAucune réponse ne porte de lien déductible d'un identifiant : les gabarits d'URL sont publiés par `GET /`.", "parameters": [ { - "description": "Race, répétable pour un OU (`?race=A&race=B`) ou séparée par des virgules. Insensible à la casse et aux accents. Le jeu compte 11 747 races distinctes ; valeurs admises sur /v1/referentiels/races.", - "example": "Pur Sang", + "description": "Race, répétable pour un OU (`?race=A&race=B`) ou séparée par des virgules. Insensible à la casse et aux accents. Le jeu compte 211 races distinctes ; valeurs admises sur /v1/referentiels/races. Attention : les objets `pere`, `mere` et `pere_de_mere` portent le code abrégé du lien (/v1/referentiels/races_lien), que ce filtre n'accepte pas.", + "example": "Trotteur Francais", "in": "query", "name": "race", "required": false, @@ -981,6 +1045,19 @@ "type": "array" } }, + { + "description": "Statut déclaré à la reproduction. Distinct de la descendance connue : une poulinière sans produit enregistré porte quand même son statut. Valeurs admises : /v1/referentiels/statuts_reproducteur.", + "example": "Pouliniere", + "in": "query", + "name": "statut_reproducteur", + "required": false, + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + }, { "description": "Restreint aux équidés portant des performances dans cette discipline. Valeurs admises : /v1/referentiels/disciplines.", "example": "TROT COURSE", @@ -1474,7 +1551,7 @@ "description": "Sans cette liste, un intégrateur ne peut pas deviner que la source écrit `Trotteur Francais` sans cédille ni accent, ni que le sexe se dit `Femelle` et non `F`.\n\nLes modalités sont rendues de la plus fréquente à la plus rare, et la liste est tronquée à 2000 entrées — `nombre_de_modalites` donne alors le total réel et `note` signale la troncature. `annees_naissance` fait exception : rendu en entier, par ordre chronologique.", "parameters": [ { - "description": "Référentiel demandé. `races_lien` porte les 13 races propres déclarées sur les liens de filiation, là où `races` reflète un champ pollué par la source.", + "description": "Référentiel demandé. `races` liste les 211 libellés développés portés par les fiches ; `races_lien` les 198 codes abrégés portés par les liens de filiation. Ce sont deux nomenclatures distinctes, et seule la première est admise par le filtre `race=`.", "example": "races", "in": "path", "name": "dimension", @@ -1484,6 +1561,7 @@ "races", "robes", "sexes", + "statuts_reproducteur", "disciplines", "codes_indice", "races_lien", @@ -1549,8 +1627,8 @@ "description": "Renvoie des suggestions **classées par pertinence**, pour alimenter un champ de saisie.\n\nÀ distinguer de `/v1/equides?nom=…`, qui filtre et pagine sans classer. Ici le coût est borné par `limite` et non par la popularité du préfixe : la réponse doit arriver avant la frappe suivante.", "parameters": [ { - "description": "Début de nom, insensible à la casse et aux accents. Obligatoire et non vide.", - "example": "qab", + "description": "Début de nom, insensible à la casse et aux accents. Obligatoire et non vide.\n\nLes espaces comptent : chaque mot déjà saisi doit se retrouver dans le nom, seul le dernier est traité comme un début de mot. `INVICTUS DU F` ne suggère donc que des noms portant `INVICTUS`, `DU`, et un mot commençant par `F`.", + "example": "invictus du f", "in": "query", "name": "q", "required": true, @@ -1673,8 +1751,8 @@ "description": "Accepte les mêmes filtres que la recherche : la répartition porte sur la sélection filtrée.\n\nDe quoi peupler des facettes sans rapatrier les lignes : seuls les effectifs traversent le réseau.", "parameters": [ { - "description": "Race, répétable pour un OU (`?race=A&race=B`) ou séparée par des virgules. Insensible à la casse et aux accents. Le jeu compte 11 747 races distinctes ; valeurs admises sur /v1/referentiels/races.", - "example": "Pur Sang", + "description": "Race, répétable pour un OU (`?race=A&race=B`) ou séparée par des virgules. Insensible à la casse et aux accents. Le jeu compte 211 races distinctes ; valeurs admises sur /v1/referentiels/races. Attention : les objets `pere`, `mere` et `pere_de_mere` portent le code abrégé du lien (/v1/referentiels/races_lien), que ce filtre n'accepte pas.", + "example": "Trotteur Francais", "in": "query", "name": "race", "required": false, @@ -1711,6 +1789,19 @@ "type": "array" } }, + { + "description": "Statut déclaré à la reproduction. Distinct de la descendance connue : une poulinière sans produit enregistré porte quand même son statut. Valeurs admises : /v1/referentiels/statuts_reproducteur.", + "example": "Pouliniere", + "in": "query", + "name": "statut_reproducteur", + "required": false, + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + }, { "description": "Restreint aux équidés portant des performances dans cette discipline. Valeurs admises : /v1/referentiels/disciplines.", "example": "TROT COURSE", @@ -1791,7 +1882,7 @@ } }, { - "description": "Dimension à ventiler : `race`, `robe`, `sexe`, `discipline`, `annee_naissance`.", + "description": "Dimension à ventiler : `race`, `robe`, `sexe`, `statut_reproducteur`, `discipline`, `annee_naissance`.", "example": "race", "in": "query", "name": "dimension", @@ -1802,6 +1893,7 @@ "race", "robe", "sexe", + "statut_reproducteur", "discipline", "annee_naissance" ], @@ -1912,35 +2004,35 @@ "quand": "Un paramètre est inconnu, mal typé, hors bornes, ou porte une valeur que le référentiel n'admet pas. Le champ `parametre` le nomme et `indice` propose la correction — une faute de frappe reçoit la suggestion du paramètre le plus proche.", "statut": 400, "titre": "Paramètre invalide", - "uri": "/#erreur-parametre-invalide" + "uri": "https://docs.api-equides.org/guides/erreurs/#erreur-parametre-invalide" }, { "ancre": "erreur-requete-invalide", "quand": "La requête ne peut pas être traitée telle quelle, sans qu'un paramètre précis soit en cause.", "statut": 400, "titre": "Requête invalide", - "uri": "/#erreur-requete-invalide" + "uri": "https://docs.api-equides.org/guides/erreurs/#erreur-requete-invalide" }, { "ancre": "erreur-introuvable", "quand": "L'identifiant ne correspond à aucune fiche du jeu de données, ou la route n'existe pas. Un identifiant mal formé donne le même résultat qu'un identifiant inconnu.", "statut": 404, "titre": "Ressource introuvable", - "uri": "/#erreur-introuvable" + "uri": "https://docs.api-equides.org/guides/erreurs/#erreur-introuvable" }, { "ancre": "erreur-trop-de-requetes", "quand": "Le quota de débit de l'adresse appelante est dépassé. La réponse porte un `Retry-After` indiquant le délai à respecter.", "statut": 429, "titre": "Trop de requêtes", - "uri": "/#erreur-trop-de-requetes" + "uri": "https://docs.api-equides.org/guides/erreurs/#erreur-trop-de-requetes" }, { "ancre": "erreur-service-surcharge", "quand": "La capacité de traitement simultané est atteinte, ou la requête a dépassé le délai maximal. Le service refuse tôt, avec un `Retry-After`, plutôt que de faire attendre une réponse qui expirerait.", "statut": 503, "titre": "Service momentanément surchargé", - "uri": "/#erreur-service-surcharge" + "uri": "https://docs.api-equides.org/guides/erreurs/#erreur-service-surcharge" } ] } diff --git a/docs/src/content/docs/donnees/defauts.md b/docs/src/content/docs/donnees/defauts.md index 3c85fbc..7d43836 100644 --- a/docs/src/content/docs/donnees/defauts.md +++ b/docs/src/content/docs/donnees/defauts.md @@ -11,45 +11,57 @@ ci-dessous viennent de la donnée d'origine et ne sont pas retouchés : les signaler vaut mieux que les masquer, car un correctif silencieux produirait des chiffres qui ne se raccordent à rien. -## Le champ `race` est pollué +## Deux nomenclatures de race -Le champ `race` d'une fiche compte **11 747 valeurs distinctes**. Une part -d'entre elles ne sont pas des races mais des chronos de course, du genre -`1'10"2 (ASC6H)`. +Ce n'est pas un défaut, mais le piège d'intégration le plus fréquent. La source +emploie deux vocabulaires de race qui **ne se recouvrent pas** : -La race déclarée sur un **lien de filiation**, elle, n'en prend que 13, toutes -propres. C'est celle que porte le champ `race` des objets `pere`, `mere` et -`pere_de_mere`. - -Deux référentiels distincts en découlent : +| Où | Forme | Référentiel | +|---|---|---| +| champ `race` d'une fiche | libellé développé — `Trotteur Francais` | `races` | +| champ `race` d'un `pere`, `mere`, `pere_de_mere` | code abrégé du lien — `TF` | `races_lien` | ```sh -curl 'https://api-equides.org/v1/referentiels/races' # 11 747, pollué -curl 'https://api-equides.org/v1/referentiels/races_lien' # 13, propre +curl 'https://api-equides.org/v1/referentiels/races' +curl 'https://api-equides.org/v1/referentiels/races_lien' ``` -:::tip -Pour classer ou facetter proprement, préférez `races_lien`. Pour filtrer sur ce -que porte réellement une fiche, il faut `race`. +:::caution +Le filtre `race=` n'accepte que la **première** forme. Passer un code de lien +(`race=TF`) renvoie un `400`, avec le référentiel à consulter en indice. ::: +Les effectifs de chaque nomenclature sont donnés par `nombre_de_modalites` sur +ces deux routes, et repris par `/v1/meta` : ils ne sont pas recopiés ici, pour +qu'aucune page ne puisse annoncer un chiffre que l'API contredit. + ## « Sans performances » ne veut pas dire « n'a jamais couru » Le filtre `avec_performances=false` ne désigne pas les équidés qui n'ont jamais -concouru, mais ceux pour lesquels **aucun indice n'est publié**. C'est le cas de -75,1 % du jeu : seuls 24,9 % des équidés portent un indice. +concouru, mais ceux pour lesquels **aucun indice n'est publié**. L'absence d'indice est une absence de publication, pas une absence de carrière. +`/v1/stats` donne la part réelle via `avec_performances` et `total`. ## Liens de filiation rompus -Sur 1 202 446 parents référencés, 1 202 286 figurent au jeu de données. Les 160 -restants sont des **liens rompus** : un parent est nommé, mais sa fiche est -absente. +Un **lien rompu** est un parent nommé dont la fiche est absente du jeu. Ils sont +exposés comme une **absence de parent**, jamais comme une référence morte qui +renverrait un 404 ; le champ `fiche_disponible` d'un objet `Reference` vaut +alors `false`. + +`/v1/meta` en donne deux dénombrements qu'il ne faut pas confondre : -Ces liens sont exposés comme une **absence de parent**, jamais comme une -référence morte qui renverrait un 404. Le champ `fiche_disponible` d'un objet -`Reference` vaut alors `false`. +| Champ d'`anomalies` | Ce qu'il compte | +|---|---| +| `parents_references` | identifiants de parents **distincts** cités | +| `parents_pendants` | ceux d'entre eux qui manquent au jeu | +| `references_de_parents` | emplacements `pere`, `mere`, `pere_de_mere` renseignés | +| `references_pendantes` | ceux d'entre eux dont la cible manque | + +`references_pendantes` est toujours supérieur ou égal à `parents_pendants` : un +même étalon absent est cité par toutes ses fiches de produits. Les deux mesurent +le même défaut, l'un par citation, l'autre par identifiant. C'est ce qui explique l'écart entre `noeuds` et `noeuds_theoriques` d'un pedigree — voir [Généalogie](/guides/genealogie/). @@ -72,6 +84,25 @@ de clé `robe` ; un équidé sans millésime n'a pas de clé `annee_naissance`. Cela allège les réponses, et distingue sans ambiguïté « non renseigné » de « renseigné à zéro ». +## Déclaré n'est pas calculé + +Deux champs de fiche sont **déclarés par la source** et ne se reconstruisent pas +par le calcul : + +- `record`, le chrono de course, sous la forme `1'16"7 (AEL3V)`. C'est du texte : + ni comparable, ni triable en l'état. +- `statut_reproducteur` — `Pouliniere`, `Etalon Actif`… — que + `nombre_de_descendants`, lui, est calculé par cette API. + +Les deux derniers ne font pas double emploi. Une jument déclarée `Pouliniere` +sans aucun produit enregistré n'est pas une jument jamais mise à la +reproduction ; c'est une distinction que la descendance seule ne rend pas. + +:::note +L'absence de `statut_reproducteur` est un **silence de la source**, pas une +négation. +::: + ## Le champ `url` Il n'est pas stocké mais **reconstruit** depuis le slug et l'identifiant, selon @@ -82,4 +113,9 @@ d'origine, qui fait autorité. `/v1/meta` expose sous `anomalies` le décompte exact de chacun de ces défauts, établi à l'ingestion : millésimes absents, robes absentes, fiches sans -filiation, parents pendants, identifiants illisibles et slugs non déductibles. +filiation, parents et citations pendants, identifiants illisibles et slugs non +déductibles. + +Les `remarques` du même document sont **dérivées du jeu chargé** à chaque +requête, jamais recopiées à la main : les chiffres qu'elles citent sont ceux que +les autres routes servent, par construction. diff --git a/docs/src/content/docs/guides/filtrer.md b/docs/src/content/docs/guides/filtrer.md index 796cc42..4c13c24 100644 --- a/docs/src/content/docs/guides/filtrer.md +++ b/docs/src/content/docs/guides/filtrer.md @@ -27,6 +27,14 @@ curl '…/v1/equides?race=Trotteur%20Francais,Pur%20Sang' # virgules Les valeurs sont insensibles à la casse et aux accents : `pur sang`, `PUR SANG` et `Pur Sang` désignent la même race. +`statut_reproducteur` filtre sur ce que la source **déclare**, indépendamment de +la descendance connue : + +```sh +# Les poulinières déclarées, qu'elles aient ou non un produit enregistré +curl 'https://api-equides.org/v1/equides?statut_reproducteur=Pouliniere' +``` + ## Découvrir les valeurs admises Sans cette liste, rien ne laisse deviner que la source écrit `Trotteur Francais` @@ -38,10 +46,11 @@ curl 'https://api-equides.org/v1/referentiels/races' | Dimension | Contenu | |---|---| -| `races` | le champ `race` d'une fiche — [pollué par la source](/donnees/defauts/) | -| `races_lien` | les 13 races propres déclarées sur les liens de filiation | +| `races` | le champ `race` d'une fiche, en libellé développé | +| `races_lien` | les codes abrégés portés par les liens de filiation — [une autre nomenclature](/donnees/defauts/) | | `robes` | robes renseignées | | `sexes` | `Femelle`, `Male`, `Hongre`, `Indeter` | +| `statuts_reproducteur` | `Pouliniere`, `Etalon Actif`… — [déclaré, pas calculé](/donnees/defauts/) | | `disciplines` | disciplines portant des performances | | `codes_indice` | `BTR`, `ISO`, `ITR`… | | `annees_naissance` | millésimes, par ordre chronologique | diff --git a/docs/src/content/docs/guides/genealogie.md b/docs/src/content/docs/guides/genealogie.md index 85b2adb..49f7866 100644 --- a/docs/src/content/docs/guides/genealogie.md +++ b/docs/src/content/docs/guides/genealogie.md @@ -86,6 +86,8 @@ de proche en proche. ## La race d'un parent Le champ `race` des objets `pere`, `mere` et `pere_de_mere` ne vient **pas** de -la fiche du parent : il vient de la race déclarée sur le lien de filiation. Ces -deux valeurs diffèrent, et celle du lien est la propre — 13 modalités contre -11 747. La raison est expliquée dans [Défauts connus](/donnees/defauts/). +la fiche du parent : il vient de la race déclarée sur le lien de filiation, et +s'écrit dans une autre nomenclature — `TF` là où la fiche porte +`Trotteur Francais`. Les deux référentiels sont distincts, et seul celui des +fiches est admis par le filtre `race=` : voir +[Défauts connus](/donnees/defauts/). diff --git a/src/api/meta.rs b/src/api/meta.rs index 951c10f..25009c5 100644 --- a/src/api/meta.rs +++ b/src/api/meta.rs @@ -43,45 +43,126 @@ pub async fn racine(State(etat): State) -> Result struct Meta<'a> { version_api: &'a str, jeu_de_donnees: &'a crate::snapshot::DatasetMeta, - remarques: Vec<&'a str>, + remarques: Vec, +} + +fn milliers(n: u64) -> String { + let chiffres = n.to_string(); + let mut out = String::with_capacity(chiffres.len() + chiffres.len() / 3); + for (i, c) in chiffres.chars().enumerate() { + if i > 0 && (chiffres.len() - i).is_multiple_of(3) { + out.push(' '); + } + out.push(c); + } + out +} + +fn pourcentage(partie: u64, total: u64) -> String { + if total == 0 { + return "0,0".to_string(); + } + format!("{:.1}", partie as f64 * 100.0 / total as f64).replace('.', ",") +} + +fn remarques(store: &crate::store::Store) -> Vec { + let a = &store.meta.anomalies; + let total = u64::from(store.n); + let perfs = store.bitmap_avec_performances().len(); + let records = store.bitmap_avec_record().len(); + let statuts = store.nombre_avec_statut_reproducteur(); + let resolus = a.parents_references.saturating_sub(a.parents_pendants); + + vec![ + "Les identifiants exposés sont ceux de l'IFCE : ils figurent tels quels dans \ + l'URL de chaque fiche publique sur infochevaux.ifce.fr." + .to_string(), + format!( + "La filiation est résolue en interne : {resolus} des {references} parents distincts \ + cités figurent au jeu de données. Les {pendants} autres sont des liens rompus, \ + exposés comme une absence de parent plutôt que comme une référence morte. \ + Ces deux nombres dénombrent des identifiants ; les citations, elles, sont plus \ + nombreuses puisqu'un même parent est cité par plusieurs fiches : \ + {citations_pendantes} citations rompues sur {citations} emplacements `pere`, \ + `mere` ou `pere_de_mere` renseignés. `anomalies` publie les quatre.", + resolus = milliers(u64::from(resolus)), + references = milliers(u64::from(a.parents_references)), + pendants = milliers(u64::from(a.parents_pendants)), + citations_pendantes = milliers(u64::from(a.references_pendantes)), + citations = milliers(u64::from(a.references_de_parents)), + ), + format!( + "« Sans performances » signifie qu'aucun indice n'est publié pour cet équidé, \ + non qu'il n'a jamais concouru : seuls {} % des équidés en portent.", + pourcentage(perfs, total) + ), + "Les millésimes de naissance aberrants présents dans la source sont conservés tels \ + quels ; cette API est un miroir fidèle de la donnée, pas un correcteur." + .to_string(), + "Le champ `url` n'est pas stocké mais reconstruit depuis le slug et l'identifiant, \ + gabarit exposé par `url_modele`." + .to_string(), + format!( + "Deux nomenclatures de race coexistent, et ne se recouvrent pas. Le champ `race` \ + d'une fiche porte le libellé développé de la source ({races} valeurs, « Trotteur \ + Francais ») ; celui des objets `pere`, `mere` et `pere_de_mere` porte le code \ + abrégé du lien de filiation ({races_lien} valeurs, « TF »). Un filtre `race=` \ + attend la première forme : /v1/referentiels/races et \ + /v1/referentiels/races_lien listent l'une et l'autre.", + races = milliers(store.races.len() as u64), + races_lien = milliers(store.races_lien.len() as u64), + ), + format!( + "`record` porte le chrono de course déclaré par la source, sur {records} fiches \ + ({pct_records} % du jeu) et sous la forme « 1'16\"7 (AEL3V) ». C'est une donnée \ + textuelle, ni comparable ni triable en l'état.", + records = milliers(records), + pct_records = pourcentage(records, total), + ), + format!( + "`statut_reproducteur` est déclaré par la source ({statuts} fiches, \ + {pct_statuts} % du jeu), là où `nombre_de_descendants` est calculé par cette API. \ + Les deux ne se déduisent pas l'un de l'autre : une jument portant « Pouliniere » \ + sans aucun produit enregistré n'est pas une jument jamais mise à la reproduction. \ + L'absence de statut est un silence de la source, pas une négation. \ + Modalités sur /v1/referentiels/statuts_reproducteur.", + statuts = milliers(statuts), + pct_statuts = pourcentage(statuts, total), + ), + "API Équidés est un service indépendant, non affilié à l'IFCE. La donnée servie \ + est une extraction datée, dont l'empreinte figure ci-dessus ; elle peut diverger \ + des fiches en ligne, qui font seules autorité." + .to_string(), + "Réutilisation : l'IFCE est un établissement public administratif (décret n° 2010-90). \ + L'article L321-1 du code des relations entre le public et l'administration ouvre la \ + réutilisation des informations publiques qu'il diffuse ; l'article L321-3 lui interdit \ + d'opposer le droit sui generis du producteur de base de données ; et le décret \ + n° 2016-1617, qui fixe limitativement les informations soumises à redevance, ne \ + mentionne ni l'IFCE ni les données équines." + .to_string(), + format!( + "La réutilisation impose de mentionner la source et la date de mise à jour \ + (CRPA art. L322-1), et de ne pas dénaturer la donnée. Les champs `source` et \ + `ingere_le` y pourvoient. `empreinte_source` désigne l'extraction sans ambiguïté : \ + c'est l'empreinte {algo}, annoncée par `algorithme_empreinte`, du fichier \ + `{fichier}` tel qu'ingéré — octet pour octet, {octets} octets, et non un export \ + normalisé. Hacher le fichier publié doit redonner exactement cette valeur.", + algo = store.meta.algorithme_empreinte, + fichier = store.meta.fichier_source, + octets = milliers(store.meta.octets_source), + ), + "Cette API n'expose aucune donnée à caractère personnel : elle ne sert que des \ + informations relatives à des animaux. Le champ `naisseur` de la source est écarté \ + dès l'ingestion, et n'existe dans aucune image." + .to_string(), + ] } pub async fn meta(State(etat): State) -> Result { json(&Meta { version_api: crate::VERSION, jeu_de_donnees: &etat.store.meta, - remarques: vec![ - "Les identifiants exposés sont ceux de l'IFCE : ils figurent tels quels dans \ - l'URL de chaque fiche publique sur infochevaux.ifce.fr.", - "La filiation est résolue en interne : 1 202 286 des 1 202 446 parents référencés \ - figurent au jeu de données. Les 160 restants sont des liens rompus, exposés comme \ - une absence de parent plutôt que comme une référence morte.", - "« Sans performances » signifie qu'aucun indice n'est publié pour cet équidé, \ - non qu'il n'a jamais concouru : seuls 24,9 % des équidés en portent.", - "Les millésimes de naissance aberrants présents dans la source sont conservés tels \ - quels ; cette API est un miroir fidèle de la donnée, pas un correcteur.", - "Le champ `url` n'est pas stocké mais reconstruit depuis le slug et l'identifiant, \ - gabarit exposé par `url_modele`.", - "Le champ `race` d'une fiche est pollué : il compte 11 747 valeurs distinctes, \ - dont une part sont des chronos de course (« 1'10\"2 (ASC6H) ») et non des races. \ - La race déclarée sur un lien de filiation, elle, n'en prend que 13, toutes propres — \ - c'est celle que porte le champ `race` des objets `pere`, `mere` et `pere_de_mere`. \ - Ce défaut vient de la source et n'est pas corrigé ici.", - "API Équidés est un service indépendant, non affilié à l'IFCE. La donnée servie \ - est une extraction datée, dont l'empreinte figure ci-dessus ; elle peut diverger \ - des fiches en ligne, qui font seules autorité.", - "Réutilisation : l'IFCE est un établissement public administratif (décret n° 2010-90). \ - L'article L321-1 du code des relations entre le public et l'administration ouvre la \ - réutilisation des informations publiques qu'il diffuse ; l'article L321-3 lui interdit \ - d'opposer le droit sui generis du producteur de base de données ; et le décret \ - n° 2016-1617, qui fixe limitativement les informations soumises à redevance, ne \ - mentionne ni l'IFCE ni les données équines.", - "La réutilisation impose de mentionner la source et la date de mise à jour \ - (CRPA art. L322-1), et de ne pas dénaturer la donnée. Les champs `source`, \ - `ingere_le` et `empreinte_source` ci-dessus y pourvoient.", - "Cette API n'expose aucune donnée à caractère personnel : elle ne sert que des \ - informations relatives à des animaux.", - ], + remarques: remarques(&etat.store), }) } diff --git a/src/api/params.rs b/src/api/params.rs index 01dad22..2f9f59d 100644 --- a/src/api/params.rs +++ b/src/api/params.rs @@ -95,6 +95,7 @@ impl Params { races: self.toutes("race"), robes: self.toutes("robe"), sexes: self.toutes("sexe"), + statuts_reproducteur: self.toutes("statut_reproducteur"), disciplines: self.toutes("discipline"), codes_indice: self.toutes("indice"), annees_naissance: self.annees("annee_naissance")?, @@ -202,6 +203,7 @@ pub const PARAMS_FILTRE: &[&str] = &[ "race", "robe", "sexe", + "statut_reproducteur", "discipline", "indice", "annee_naissance", diff --git a/src/api/referentiels.rs b/src/api/referentiels.rs index b42c876..18bf98c 100644 --- a/src/api/referentiels.rs +++ b/src/api/referentiels.rs @@ -60,6 +60,9 @@ pub async fn lister( "races" => modalites(&store.races, |c| store.bitmap_race(c)), "robes" => modalites(&store.robes, |c| store.bitmap_robe(c)), "sexes" => modalites(&store.sexes, |c| store.bitmap_sexe(c)), + "statuts_reproducteur" => modalites(&store.statuts_reproducteur, |c| { + store.bitmap_statut_reproducteur(c) + }), "disciplines" => modalites(&store.disciplines, |c| store.bitmap_discipline(c)), "codes_indice" => modalites(&store.codes_indice, |c| store.bitmap_code_indice(c)), "races_lien" => modalites(&store.races_lien, |_| None), @@ -67,8 +70,8 @@ pub async fn lister( return Err(Erreur::parametre_invalide( "dimension", format!("référentiel inconnu « {autre} »"), - "référentiels disponibles : races, robes, sexes, disciplines, \ - codes_indice, races_lien, annees_naissance", + "référentiels disponibles : races, robes, sexes, statuts_reproducteur, \ + disciplines, codes_indice, races_lien, annees_naissance", )); } }; diff --git a/src/api/stats.rs b/src/api/stats.rs index 62c3d87..e655adb 100644 --- a/src/api/stats.rs +++ b/src/api/stats.rs @@ -20,6 +20,8 @@ struct Global<'a> { sans_performances: u64, avec_filiation: u64, sans_filiation: u64, + avec_record: u64, + avec_statut_reproducteur: u64, par_sexe: Vec, nombre_de_races: usize, nombre_de_robes: usize, @@ -51,6 +53,8 @@ pub async fn global(State(etat): State) -> Result sans_performances: total as u64 - perfs, avec_filiation: total as u64 - sans_filiation, sans_filiation, + avec_record: s.bitmap_avec_record().len(), + avec_statut_reproducteur: s.nombre_avec_statut_reproducteur(), par_sexe, nombre_de_races: s.races.len(), nombre_de_robes: s.robes.len(), @@ -126,6 +130,12 @@ pub async fn repartition(State(etat): State, uri: Uri) -> Result ventiler_dimension(&selection, &store.races, |c| store.bitmap_race(c), limite), "robe" => ventiler_dimension(&selection, &store.robes, |c| store.bitmap_robe(c), limite), "sexe" => ventiler_dimension(&selection, &store.sexes, |c| store.bitmap_sexe(c), limite), + "statut_reproducteur" => ventiler_dimension( + &selection, + &store.statuts_reproducteur, + |c| store.bitmap_statut_reproducteur(c), + limite, + ), "discipline" => ventiler_dimension( &selection, &store.disciplines, @@ -143,7 +153,8 @@ pub async fn repartition(State(etat): State, uri: Uri) -> Result, robe: Option, annee_naissance: Option, + record: Option, + statut_reproducteur: Option, filiation_texte: Option, pere: Option, mere: Option, @@ -129,6 +133,7 @@ struct Colonnes { robe: Vec, sexe: Vec, annee: Vec, + statut_reproducteur: Vec, } fn main() -> Result<()> { @@ -136,8 +141,8 @@ fn main() -> Result<()> { let debut = Instant::now(); eprintln!("→ empreinte de {}", args.source.display()); - let empreinte = empreinte_fichier(&args.source)?; - eprintln!(" blake3 = {empreinte}"); + let (empreinte, octets_source) = empreinte_fichier(&args.source)?; + eprintln!(" {ALGORITHME_EMPREINTE} = {empreinte} ({octets_source} octets)"); eprintln!("→ analyse du JSONL"); let fichier = std::fs::File::open(&args.source) @@ -152,12 +157,14 @@ fn main() -> Result<()> { let mut disciplines = Interneur::default(); let mut codes_indice = Interneur::default(); let mut appreciations = Interneur::default(); + let mut statuts_reproducteur = Interneur::default(); let mut col = Colonnes::default(); let mut index_id: HashMap<[u8; ID_BYTES], u32> = HashMap::new(); let mut noms = Arene::nouvelle(); let mut slugs = Arene::nouvelle(); let mut filiations = Arene::nouvelle(); + let mut records = Arene::nouvelle(); let mut brut_pere: Vec> = Vec::new(); let mut brut_mere: Vec> = Vec::new(); @@ -178,6 +185,7 @@ fn main() -> Result<()> { let mut ind_appreciation: Vec = Vec::new(); let mut anomalies = Anomalies::default(); + let mut nb_records: u32 = 0; let mut n: u32 = 0; for (numero, ligne) in lecteur.lines().enumerate() { @@ -205,6 +213,9 @@ fn main() -> Result<()> { noms.pousser(nom)?; slugs.pousser(slug)?; filiations.pousser(r.filiation_texte.as_deref().unwrap_or(""))?; + let record = r.record.as_deref().unwrap_or(""); + nb_records += u32::from(!record.is_empty()); + records.pousser(record)?; col.race.push(match r.race.as_deref() { Some(v) if !v.is_empty() => races.intern16(v, "race")?, @@ -223,6 +234,13 @@ fn main() -> Result<()> { } _ => u8::MAX, }); + col.statut_reproducteur + .push(match r.statut_reproducteur.as_deref() { + Some(v) if !v.is_empty() => { + statuts_reproducteur.intern16(v, "statut de reproducteur")? + } + _ => SANS_MODALITE, + }); col.annee.push(if let Some(a) = r.annee_naissance { anomalies.annee_naissance_min = Some(anomalies.annee_naissance_min.map_or(a, |m| m.min(a))); @@ -307,7 +325,10 @@ fn main() -> Result<()> { eprintln!("→ {n} lignes analysées en {:.1?}", debut.elapsed()); eprintln!("→ résolution du graphe de filiation"); - let mut pendants = 0u32; + let mut references = 0u32; + let mut references_pendantes = 0u32; + let mut pendants_distincts: HashSet<[u8; ID_BYTES]> = HashSet::new(); + let mut cible_referencee = vec![false; n as usize]; let mut liens_rompus: Vec<(u32, u8, String)> = Vec::new(); let mut resoudre = |brut: Vec>, genre: u8| -> Vec { brut.into_iter() @@ -316,10 +337,13 @@ fn main() -> Result<()> { let Some(id) = id else { return SANS_PARENT; }; + references += 1; if let Some(&cible) = index_id.get(&id) { + cible_referencee[cible as usize] = true; return cible; } - pendants += 1; + references_pendantes += 1; + pendants_distincts.insert(id); if let Some(nom) = noms_lien[row * 3 + genre as usize].take() { liens_rompus.push((row as u32, genre, nom)); } @@ -330,7 +354,12 @@ fn main() -> Result<()> { let col_pere = resoudre(brut_pere, 0); let col_mere = resoudre(brut_mere, 1); let col_pdm = resoudre(brut_pdm, 2); - anomalies.parents_pendants = pendants; + anomalies.references_de_parents = references; + anomalies.references_pendantes = references_pendantes; + anomalies.parents_pendants = pendants_distincts.len() as u32; + anomalies.parents_references = + cible_referencee.iter().filter(|&&vu| vu).count() as u32 + anomalies.parents_pendants; + drop((pendants_distincts, cible_referencee)); liens_rompus.sort_unstable_by_key(|(row, genre, _)| (*row, *genre)); lien_alias.sort_unstable_by_key(|(row, genre, _)| (*row, *genre)); drop((index_id, noms_lien)); @@ -356,13 +385,27 @@ fn main() -> Result<()> { ind_code.len() ); eprintln!( - " anomalies : {} sans filiation, {} parents pendants, {} sans millésime, {} sans robe, {} slugs non déductibles", - anomalies.sans_filiation, + " filiation : {} parents distincts cités, dont {} absents ({} citations sur {})", + anomalies.parents_references, anomalies.parents_pendants, + anomalies.references_pendantes, + anomalies.references_de_parents + ); + eprintln!( + " anomalies : {} sans filiation, {} sans millésime, {} sans robe, {} slugs non déductibles", + anomalies.sans_filiation, anomalies.annee_naissance_absente, anomalies.robe_absente, anomalies.slugs_non_deductibles ); + eprintln!( + " champs déclarés : {nb_records} records, {} statuts de reproducteur ({} modalités)", + col.statut_reproducteur + .iter() + .filter(|&&s| s != SANS_MODALITE) + .count(), + statuts_reproducteur.valeurs.len() + ); if let (Some(min), Some(max)) = (anomalies.annee_naissance_min, anomalies.annee_naissance_max) { eprintln!(" millésimes : {min} → {max}"); } @@ -375,6 +418,13 @@ fn main() -> Result<()> { licence: args.licence, url_modele: URL_MODELE.to_string(), ingere_le: aujourdhui_iso(), + fichier_source: args + .source + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or_default(), + octets_source, + algorithme_empreinte: ALGORITHME_EMPREINTE.to_string(), empreinte_source: empreinte, lignes: n, anomalies, @@ -387,14 +437,17 @@ fn main() -> Result<()> { dict_disciplines: disciplines.valeurs, dict_codes_indice: codes_indice.valeurs, dict_appreciations: appreciations.valeurs, + dict_statuts_reproducteur: statuts_reproducteur.valeurs, ids: col.ids, noms, slugs, filiations, + records, race: col.race, robe: col.robe, sexe: col.sexe, annee: col.annee, + statut_reproducteur: col.statut_reproducteur, pere: col_pere, mere: col_mere, pere_de_mere: col_pdm, @@ -434,19 +487,21 @@ fn dossier_parent(chemin: &Path) -> Option { .map(PathBuf::from) } -fn empreinte_fichier(chemin: &Path) -> Result { +fn empreinte_fichier(chemin: &Path) -> Result<(String, u64)> { let mut f = std::fs::File::open(chemin) .with_context(|| format!("ouverture de {}", chemin.display()))?; let mut h = blake3::Hasher::new(); let mut tampon = vec![0u8; 1 << 22]; + let mut octets = 0u64; loop { let lus = f.read(&mut tampon)?; if lus == 0 { break; } + octets += lus as u64; h.update(&tampon[..lus]); } - Ok(h.finalize().to_hex().to_string()) + Ok((h.finalize().to_hex().to_string(), octets)) } fn aujourdhui_iso() -> String { diff --git a/src/model.rs b/src/model.rs index 39ed3bb..7ec2585 100644 --- a/src/model.rs +++ b/src/model.rs @@ -96,6 +96,10 @@ pub struct Equide<'a> { #[serde(skip_serializing_if = "Option::is_none")] pub annee_naissance: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub record: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub statut_reproducteur: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] pub filiation_texte: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] pub pere: Option>, @@ -142,6 +146,8 @@ impl<'a> Equide<'a> { sexe: store.sexe_libelle(row), robe: non_vide(store.robe_libelle(row)), annee_naissance: store.annee(row), + record: non_vide(store.record(row)), + statut_reproducteur: non_vide(store.statut_reproducteur_libelle(row)), filiation_texte: non_vide(store.filiation_texte(row)), pere: store .lien(row, crate::store::PERE) diff --git a/src/openapi.rs b/src/openapi.rs index 9cfb95d..8f2277e 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -35,15 +35,32 @@ fn param(nom: &str, description: &str, schema: &Value, exemple: &Value) -> Value }) } +fn part_sans_performances(store: &Store) -> String { + let total = u64::from(store.n); + if total == 0 { + return "0,0".to_string(); + } + let sans = total - store.bitmap_avec_performances().len(); + format!("{:.1}", sans as f64 * 100.0 / total as f64).replace('.', ",") +} + fn params_filtre(store: &Store) -> Vec { let (race, robe, sexe) = exemples(store); let repetable = |t: &str| json!({ "type": "array", "items": { "type": t } }); + let statut = plus_frequent(&store.statuts_reproducteur, |c| { + store.bitmap_statut_reproducteur(c) + }); vec![ param( "race", - "Race, répétable pour un OU (`?race=A&race=B`) ou séparée par des virgules. \ - Insensible à la casse et aux accents. Le jeu compte 11 747 races distinctes ; \ - valeurs admises sur /v1/referentiels/races.", + &format!( + "Race, répétable pour un OU (`?race=A&race=B`) ou séparée par des virgules. \ + Insensible à la casse et aux accents. Le jeu compte {} races distinctes ; \ + valeurs admises sur /v1/referentiels/races. Attention : les objets `pere`, \ + `mere` et `pere_de_mere` portent le code abrégé du lien \ + (/v1/referentiels/races_lien), que ce filtre n'accepte pas.", + store.races.len() + ), &repetable("string"), &json!(race), ), @@ -59,6 +76,14 @@ fn params_filtre(store: &Store) -> Vec { &repetable("string"), &json!(sexe), ), + param( + "statut_reproducteur", + "Statut déclaré à la reproduction. Distinct de la descendance connue : une \ + poulinière sans produit enregistré porte quand même son statut. \ + Valeurs admises : /v1/referentiels/statuts_reproducteur.", + &repetable("string"), + &json!(statut), + ), param( "discipline", "Restreint aux équidés portant des performances dans cette discipline. \ @@ -100,10 +125,13 @@ fn params_filtre(store: &Store) -> Vec { ), param( "avec_performances", - "Restreint aux équidés portant (ou non) des indices de performance. \ - Accepte `true`, `false`, `1`, `0`, `oui`, `non`. \ - Attention : `false` ne signifie pas que l'équidé n'a jamais concouru, \ - seulement qu'aucun indice n'est publié — 75,1 % du jeu est dans ce cas.", + &format!( + "Restreint aux équidés portant (ou non) des indices de performance. \ + Accepte `true`, `false`, `1`, `0`, `oui`, `non`. \ + Attention : `false` ne signifie pas que l'équidé n'a jamais concouru, \ + seulement qu'aucun indice n'est publié — {} % du jeu est dans ce cas.", + part_sans_performances(store) + ), &json!({ "type": "boolean" }), &json!(true), ), @@ -141,10 +169,30 @@ fn schemas() -> Value { "type": "string", "description": "Fiche d'origine, reconstruite depuis le slug et l'identifiant." }, - "race": { "type": "string", "example": "Trotteur Francais" }, + "race": { + "type": "string", + "description": "Libellé développé. Les objets `pere`, `mere` et \ + `pere_de_mere` portent, eux, le code abrégé du lien.", + "example": "Trotteur Francais" + }, "sexe": { "type": "string", "enum": ["Femelle", "Male", "Hongre", "Indeter"] }, "robe": { "type": "string", "example": "Bai" }, "annee_naissance": { "type": "integer", "example": 2026 }, + "record": { + "type": "string", + "description": "Chrono de course déclaré par la source. Texte : ni \ + comparable ni triable en l'état. Absent quand la source \ + n'en publie pas.", + "example": "1'16\"7 (AEL3V)" + }, + "statut_reproducteur": { + "type": "string", + "description": "Statut déclaré à la reproduction, là où \ + `nombre_de_descendants` est calculé : une poulinière sans \ + produit enregistré porte quand même son statut. Son absence \ + est un silence de la source, pas une négation.", + "example": "Pouliniere" + }, "filiation_texte": { "type": "string", "description": "Filiation telle que rédigée par la source.", @@ -379,6 +427,15 @@ fn schemas() -> Value { "description": "Équidés dont au moins un parent est renseigné." }, "sans_filiation": { "type": "integer" }, + "avec_record": { + "type": "integer", + "description": "Fiches portant un chrono de course déclaré." + }, + "avec_statut_reproducteur": { + "type": "integer", + "description": "Fiches portant un statut déclaré à la reproduction, \ + toutes modalités confondues." + }, "par_sexe": { "type": "array", "items": { "$ref": "#/components/schemas/Tranche" } @@ -443,10 +500,28 @@ fn schemas() -> Value { "description": "Gabarit reconstruisant l'URL publique d'une fiche." }, "ingere_le": { "type": "string", "description": "Date d'ingestion, ISO 8601." }, + "fichier_source": { + "type": "string", + "description": "Nom du fichier d'extraction ingéré, celui que \ + `empreinte_source` et `octets_source` décrivent.", + "example": "chevaux.jsonl" + }, + "octets_source": { + "type": "integer", + "description": "Taille exacte du fichier ingéré." + }, + "algorithme_empreinte": { + "type": "string", + "description": "Fonction de hachage employée pour `empreinte_source`. \ + Publiée pour que l'empreinte soit reproductible sans \ + avoir à deviner l'algorithme.", + "example": "blake3" + }, "empreinte_source": { "type": "string", - "description": "Empreinte blake3 du fichier source : elle identifie \ - exactement l'extraction servie." + "description": "Empreinte du fichier ingéré, octet pour octet — pas celle \ + d'un export normalisé. Elle identifie exactement \ + l'extraction servie." }, "lignes": { "type": "integer" }, "anomalies": { "$ref": "#/components/schemas/Anomalies" } @@ -462,10 +537,27 @@ fn schemas() -> Value { "annee_naissance_max": { "type": ["integer", "null"] }, "robe_absente": { "type": "integer" }, "sans_filiation": { "type": "integer" }, + "parents_references": { + "type": "integer", + "description": "Identifiants de parents distincts cités par au moins une fiche." + }, "parents_pendants": { "type": "integer", - "description": "Parents référencés mais absents du fichier : le lien est \ - rompu, et exposé comme une absence de parent." + "description": "Ceux d'entre eux qui sont absents du fichier : le lien est \ + rompu, et exposé comme une absence de parent. Dénombrés par \ + identifiant, pas par citation — un étalon absent cité mille \ + fois compte pour un." + }, + "references_de_parents": { + "type": "integer", + "description": "Emplacements `pere`, `mere` ou `pere_de_mere` portant un \ + identifiant, toutes fiches confondues." + }, + "references_pendantes": { + "type": "integer", + "description": "Ceux d'entre eux dont la cible manque. Toujours supérieur ou \ + égal à `parents_pendants` : c'est le même défaut compté par \ + citation plutôt que par identifiant." }, "identifiants_illisibles": { "type": "integer" }, "slugs_non_deductibles": { "type": "integer" } @@ -646,10 +738,11 @@ pub fn document(store: &Store) -> Value { params_repartition.extend([ param( "dimension", - "Dimension à ventiler : `race`, `robe`, `sexe`, `discipline`, `annee_naissance`.", + "Dimension à ventiler : `race`, `robe`, `sexe`, `statut_reproducteur`, \ + `discipline`, `annee_naissance`.", &json!({ "type": "string", - "enum": ["race", "robe", "sexe", "discipline", "annee_naissance"], + "enum": ["race", "robe", "sexe", "statut_reproducteur", "discipline", "annee_naissance"], "default": "race" }), &json!("race"), @@ -927,11 +1020,16 @@ pub fn document(store: &Store) -> Value { ), "parameters": [{ "name": "dimension", "in": "path", "required": true, - "description": "Référentiel demandé. `races_lien` porte les 13 races \ - propres déclarées sur les liens de filiation, là où \ - `races` reflète un champ pollué par la source.", + "description": format!( + "Référentiel demandé. `races` liste les {races} libellés développés \ + portés par les fiches ; `races_lien` les {races_lien} codes abrégés \ + portés par les liens de filiation. Ce sont deux nomenclatures \ + distinctes, et seule la première est admise par le filtre `race=`.", + races = store.races.len(), + races_lien = store.races_lien.len(), + ), "schema": { "type": "string", - "enum": ["races", "robes", "sexes", "disciplines", "codes_indice", "races_lien", "annees_naissance"] }, + "enum": ["races", "robes", "sexes", "statuts_reproducteur", "disciplines", "codes_indice", "races_lien", "annees_naissance"] }, "example": "races" }], "responses": reponses(json!({ diff --git a/src/query.rs b/src/query.rs index a3886f4..23fbcf8 100644 --- a/src/query.rs +++ b/src/query.rs @@ -31,6 +31,7 @@ pub struct Filtres { pub races: Vec, pub robes: Vec, pub sexes: Vec, + pub statuts_reproducteur: Vec, pub disciplines: Vec, pub codes_indice: Vec, pub annees_naissance: Vec, @@ -45,6 +46,7 @@ impl Filtres { self.races.is_empty() && self.robes.is_empty() && self.sexes.is_empty() + && self.statuts_reproducteur.is_empty() && self.disciplines.is_empty() && self.codes_indice.is_empty() && self.annees_naissance.is_empty() @@ -105,6 +107,18 @@ pub fn resoudre<'a>( appliquer_dimension(&mut acc, &filtres.sexes, "sexe", "sexes", |v| { store.sexes.code(v).and_then(|c| store.bitmap_sexe(c)) })?; + appliquer_dimension( + &mut acc, + &filtres.statuts_reproducteur, + "statut_reproducteur", + "statuts_reproducteur", + |v| { + store + .statuts_reproducteur + .code(v) + .and_then(|c| store.bitmap_statut_reproducteur(c)) + }, + )?; appliquer_dimension( &mut acc, &filtres.disciplines, diff --git a/src/snapshot.rs b/src/snapshot.rs index 28b6add..63e6e0e 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use std::io::{BufReader, BufWriter, Read, Write}; use std::path::Path; -pub const FORMAT_VERSION: u32 = 5; +pub const FORMAT_VERSION: u32 = 6; pub const SANS_PARENT: u32 = u32::MAX; pub const SANS_MODALITE: u16 = u16::MAX; @@ -17,7 +17,10 @@ pub struct Anomalies { pub annee_naissance_max: Option, pub robe_absente: u32, pub sans_filiation: u32, + pub parents_references: u32, pub parents_pendants: u32, + pub references_de_parents: u32, + pub references_pendantes: u32, pub identifiants_illisibles: u32, pub slugs_non_deductibles: u32, } @@ -29,6 +32,9 @@ pub struct DatasetMeta { pub licence: String, pub url_modele: String, pub ingere_le: String, + pub fichier_source: String, + pub octets_source: u64, + pub algorithme_empreinte: String, pub empreinte_source: String, pub lignes: u32, pub anomalies: Anomalies, @@ -83,16 +89,19 @@ pub struct Snapshot { pub dict_disciplines: Vec, pub dict_codes_indice: Vec, pub dict_appreciations: Vec, + pub dict_statuts_reproducteur: Vec, pub ids: Vec, pub noms: Arene, pub slugs: Arene, pub filiations: Arene, + pub records: Arene, pub race: Vec, pub robe: Vec, pub sexe: Vec, pub annee: Vec, + pub statut_reproducteur: Vec, pub pere: Vec, pub mere: Vec, @@ -163,6 +172,7 @@ impl Snapshot { colonne("robe", self.robe.len())?; colonne("sexe", self.sexe.len())?; colonne("annee", self.annee.len())?; + colonne("statut_reproducteur", self.statut_reproducteur.len())?; colonne("pere", self.pere.len())?; colonne("mere", self.mere.len())?; colonne("pere_de_mere", self.pere_de_mere.len())?; @@ -176,6 +186,7 @@ impl Snapshot { colonne("noms", self.noms.len())?; colonne("slugs", self.slugs.len())?; colonne("filiations", self.filiations.len())?; + colonne("records", self.records.len())?; anyhow::ensure!( self.ids.len() == n * crate::ids::ID_BYTES, diff --git a/src/store/index.rs b/src/store/index.rs index d4de987..db1c92b 100644 --- a/src/store/index.rs +++ b/src/store/index.rs @@ -51,28 +51,34 @@ pub(super) struct Bitmaps { pub(super) idx_race: Vec, pub(super) idx_robe: Vec, pub(super) idx_sexe: Vec, + pub(super) idx_statut_reproducteur: Vec, pub(super) idx_discipline: Vec, pub(super) idx_code_indice: Vec, pub(super) idx_annee: BTreeMap, pub(super) bm_avec_performances: RoaringBitmap, + pub(super) bm_avec_record: RoaringBitmap, } -pub(super) fn construire_bitmaps( - snap: &Snapshot, - races: usize, - robes: usize, - sexes: usize, - disciplines: usize, - codes_indice: usize, -) -> Bitmaps { +pub(super) struct Tailles { + pub(super) races: usize, + pub(super) robes: usize, + pub(super) sexes: usize, + pub(super) statuts_reproducteur: usize, + pub(super) disciplines: usize, + pub(super) codes_indice: usize, +} + +pub(super) fn construire_bitmaps(snap: &Snapshot, tailles: &Tailles) -> Bitmaps { let mut b = Bitmaps { - idx_race: vec![RoaringBitmap::new(); races], - idx_robe: vec![RoaringBitmap::new(); robes], - idx_sexe: vec![RoaringBitmap::new(); sexes], - idx_discipline: vec![RoaringBitmap::new(); disciplines], - idx_code_indice: vec![RoaringBitmap::new(); codes_indice], + idx_race: vec![RoaringBitmap::new(); tailles.races], + idx_robe: vec![RoaringBitmap::new(); tailles.robes], + idx_sexe: vec![RoaringBitmap::new(); tailles.sexes], + idx_statut_reproducteur: vec![RoaringBitmap::new(); tailles.statuts_reproducteur], + idx_discipline: vec![RoaringBitmap::new(); tailles.disciplines], + idx_code_indice: vec![RoaringBitmap::new(); tailles.codes_indice], idx_annee: BTreeMap::new(), bm_avec_performances: RoaringBitmap::new(), + bm_avec_record: RoaringBitmap::new(), }; const CROISSANT: &str = "insertion en ordre croissant"; for row in 0..snap.meta.lignes { @@ -92,6 +98,14 @@ pub(super) fn construire_bitmaps( .try_push(row) .expect(CROISSANT); } + if !snap.records.get(i).is_empty() { + b.bm_avec_record.try_push(row).expect(CROISSANT); + } + if snap.statut_reproducteur[i] != SANS_MODALITE { + b.idx_statut_reproducteur[snap.statut_reproducteur[i] as usize] + .try_push(row) + .expect(CROISSANT); + } if snap.annee[i] != ANNEE_ABSENTE { b.idx_annee .entry(snap.annee[i]) diff --git a/src/store/mod.rs b/src/store/mod.rs index 62920ef..f10e148 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -2,7 +2,7 @@ pub mod dict; pub mod index; pub use dict::Dict; -use index::{Bitmaps, construire_bitmaps, construire_descendance, permutation_par_annee}; +use index::{Bitmaps, Tailles, construire_bitmaps, construire_descendance, permutation_par_annee}; pub use index::{Descendance, Lien, MERE, PERE, PERE_DE_MERE, Permutation}; use crate::ids::{self, ID_BYTES}; @@ -23,11 +23,13 @@ pub struct Store { pub disciplines: Dict, pub codes_indice: Dict, pub appreciations: Dict, + pub statuts_reproducteur: Dict, race: Vec, robe: Vec, sexe: Vec, annee: Vec, + statut_reproducteur: Vec, pere: Vec, mere: Vec, @@ -41,6 +43,7 @@ pub struct Store { noms: Arene, slugs: Arene, filiations: Arene, + records: Arene, perf_bornes: Vec, perf_discipline: Vec, @@ -57,11 +60,13 @@ pub struct Store { idx_race: Vec, idx_robe: Vec, idx_sexe: Vec, + idx_statut_reproducteur: Vec, idx_discipline: Vec, idx_code_indice: Vec, idx_annee: BTreeMap, bm_tous: RoaringBitmap, bm_avec_performances: RoaringBitmap, + bm_avec_record: RoaringBitmap, tri_nom: Permutation, tri_annee: Permutation, @@ -81,6 +86,7 @@ impl Store { let disciplines = Dict::new(std::mem::take(&mut snap.dict_disciplines)); let codes_indice = Dict::new(std::mem::take(&mut snap.dict_codes_indice)); let appreciations = Dict::new(std::mem::take(&mut snap.dict_appreciations)); + let statuts_reproducteur = Dict::new(std::mem::take(&mut snap.dict_statuts_reproducteur)); let bm_tous = if n == 0 { RoaringBitmap::new() @@ -115,11 +121,14 @@ impl Store { let h_desc = portee.spawn(|| construire_descendance(&snap)); let bitmaps = construire_bitmaps( &snap, - races.len(), - robes.len(), - sexes.len(), - disciplines.len(), - codes_indice.len(), + &Tailles { + races: races.len(), + robes: robes.len(), + sexes: sexes.len(), + statuts_reproducteur: statuts_reproducteur.len(), + disciplines: disciplines.len(), + codes_indice: codes_indice.len(), + }, ); ( h_noms.join().expect("construction de l'index des noms"), @@ -135,10 +144,12 @@ impl Store { idx_race, idx_robe, idx_sexe, + idx_statut_reproducteur, idx_discipline, idx_code_indice, idx_annee, bm_avec_performances, + bm_avec_record, } = bitmaps; Self { @@ -152,10 +163,12 @@ impl Store { disciplines, codes_indice, appreciations, + statuts_reproducteur, race: snap.race, robe: snap.robe, sexe: snap.sexe, annee: snap.annee, + statut_reproducteur: snap.statut_reproducteur, pere: snap.pere, mere: snap.mere, pere_de_mere: snap.pere_de_mere, @@ -173,6 +186,7 @@ impl Store { noms: snap.noms, slugs: snap.slugs, filiations: snap.filiations, + records: snap.records, perf_bornes: snap.perf_bornes, perf_discipline: snap.perf_discipline, perf_textes: snap.perf_textes, @@ -186,11 +200,13 @@ impl Store { idx_race, idx_robe, idx_sexe, + idx_statut_reproducteur, idx_discipline, idx_code_indice, idx_annee, bm_tous, bm_avec_performances, + bm_avec_record, tri_nom, tri_annee, descendance, @@ -223,6 +239,19 @@ impl Store { libelle_ou_vide(&self.robes, self.robe[row as usize]) } + #[inline] + pub fn record(&self, row: u32) -> &str { + self.records.get(row as usize) + } + + #[inline] + pub fn statut_reproducteur_libelle(&self, row: u32) -> &str { + libelle_ou_vide( + &self.statuts_reproducteur, + self.statut_reproducteur[row as usize], + ) + } + #[inline] pub fn sexe_libelle(&self, row: u32) -> &str { match self.sexe[row as usize] { @@ -374,6 +403,10 @@ impl Store { self.idx_sexe.get(code as usize) } + pub fn bitmap_statut_reproducteur(&self, code: u32) -> Option<&RoaringBitmap> { + self.idx_statut_reproducteur.get(code as usize) + } + pub fn bitmap_discipline(&self, code: u32) -> Option<&RoaringBitmap> { self.idx_discipline.get(code as usize) } @@ -392,6 +425,20 @@ impl Store { &self.bm_avec_performances } + pub fn bitmap_avec_record(&self) -> &RoaringBitmap { + &self.bm_avec_record + } + + pub fn nombre_avec_statut_reproducteur(&self) -> u64 { + self.statuts_reproducteur + .iter() + .map(|(code, _)| { + self.bitmap_statut_reproducteur(code) + .map_or(0, roaring::RoaringBitmap::len) + }) + .sum() + } + pub fn annees(&self) -> impl Iterator { self.idx_annee.iter().map(|(a, b)| (*a as i32, b.len())) } @@ -537,7 +584,10 @@ pub mod tests { let mut noms = Arene::nouvelle(); let mut slugs = Arene::nouvelle(); let mut filiations = Arene::nouvelle(); + let mut records = Arene::nouvelle(); let (mut race, mut robe, mut sexe, mut annee) = (vec![], vec![], vec![], vec![]); + let mut statut_reproducteur = Vec::new(); + let mut dict_statuts: Vec = Vec::new(); let mut index: HashMap<&str, u32> = HashMap::new(); for (i, l) in LIGNES.iter().enumerate() { @@ -548,6 +598,14 @@ pub mod tests { .pousser(&l.1.to_lowercase().replace(' ', "-")) .unwrap(); filiations.pousser("").unwrap(); + records + .pousser(if i == 4 { "1'16\"7 (AEL3V)" } else { "" }) + .unwrap(); + statut_reproducteur.push(match i { + 0 => intern(&mut dict_statuts, "Etalon Actif"), + 1 | 3 => intern(&mut dict_statuts, "Pouliniere"), + _ => SANS_MODALITE, + }); race.push(intern(&mut dicts[0], l.2)); sexe.push(intern(&mut dicts[2], l.3) as u8); robe.push(if l.4.is_empty() { @@ -600,10 +658,16 @@ pub mod tests { licence: "Non déterminée".into(), url_modele: "https://infochevaux.ifce.fr/fr/{slug}-{id}/infos-generales".into(), ingere_le: "2026-01-01".into(), + fichier_source: "test.jsonl".into(), + octets_source: 1024, + algorithme_empreinte: "blake3".into(), empreinte_source: "test".into(), lignes: n as u32, anomalies: Anomalies { + parents_references: 5, parents_pendants: 1, + references_de_parents: 5, + references_pendantes: 1, ..Default::default() }, }, @@ -615,14 +679,17 @@ pub mod tests { dict_disciplines: vec!["TROT COURSE".into()], dict_codes_indice: vec!["BTR".into()], dict_appreciations: vec![], + dict_statuts_reproducteur: dict_statuts, ids: ids_plats, noms, slugs, filiations, + records, race, robe, sexe, annee, + statut_reproducteur, pere, mere, pere_de_mere: vec![SANS_PARENT; n], diff --git a/tests/api.rs b/tests/api.rs index dc73749..0ff8060 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -112,7 +112,10 @@ fn snapshot_test() -> Snapshot { let mut noms = Arene::nouvelle(); let mut slugs = Arene::nouvelle(); let mut filiations = Arene::nouvelle(); + let mut records = Arene::nouvelle(); let (mut race, mut robe, mut sexe, mut annee) = (vec![], vec![], vec![], vec![]); + let mut statut_reproducteur = Vec::new(); + let mut dict_statuts: Vec = Vec::new(); let mut index: HashMap<&str, u32> = HashMap::new(); for (i, l) in LIGNES.iter().enumerate() { @@ -129,6 +132,14 @@ fn snapshot_test() -> Snapshot { "Par PERE et MERE" }) .unwrap(); + records + .pousser(if i == 4 { "1'16\"7 (AEL3V)" } else { "" }) + .unwrap(); + statut_reproducteur.push(match i { + 0 => intern(&mut dict_statuts, "Etalon Actif"), + 1 | 3 => intern(&mut dict_statuts, "Pouliniere"), + _ => SANS_MODALITE, + }); race.push(intern(&mut dicts[0], l.2)); sexe.push(intern(&mut dicts[2], l.3) as u8); robe.push(if l.4.is_empty() { @@ -168,11 +179,17 @@ fn snapshot_test() -> Snapshot { .into(), url_modele: "https://infochevaux.ifce.fr/fr/{slug}-{id}/infos-generales".into(), ingere_le: "2026-01-01".into(), + fichier_source: "chevaux-de-test.jsonl".into(), + octets_source: 4096, + algorithme_empreinte: "blake3".into(), empreinte_source: "empreinte-de-test".into(), lignes: n as u32, anomalies: Anomalies { sans_filiation: 4, + parents_references: 5, parents_pendants: 1, + references_de_parents: 5, + references_pendantes: 1, annee_naissance_absente: 1, annee_naissance_min: Some(1990), annee_naissance_max: Some(2020), @@ -187,14 +204,17 @@ fn snapshot_test() -> Snapshot { dict_disciplines: vec!["TROT COURSE".into()], dict_codes_indice: vec!["BTR".into()], dict_appreciations: vec![], + dict_statuts_reproducteur: dict_statuts, ids: ids_plats, noms, slugs, filiations, + records, race, robe, sexe, annee, + statut_reproducteur, pere: LIGNES.iter().map(|l| lien(l.6)).collect(), mere: LIGNES.iter().map(|l| lien(l.7)).collect(), pere_de_mere: vec![SANS_PARENT; n], @@ -697,6 +717,64 @@ async fn repartition_respecte_les_filtres() { ); } +#[tokio::test] +async fn record_et_statut_reproducteur_sont_servis_tels_que_declares() { + let fiche = json("/v1/equides/EEEEEEEEEEEEEEEEEEEEEA").await; + assert_eq!(fiche["record"], "1'16\"7 (AEL3V)"); + assert!( + fiche.get("statut_reproducteur").is_none(), + "un statut absent de la source ne doit pas être inventé" + ); + + let mere = json("/v1/equides/DDDDDDDDDDDDDDDDDDDDDA").await; + assert_eq!(mere["statut_reproducteur"], "Pouliniere"); + assert!(mere.get("record").is_none()); +} + +#[tokio::test] +async fn le_statut_de_reproducteur_ne_se_deduit_pas_de_la_descendance() { + let grand_mere = json("/v1/equides/BBBBBBBBBBBBBBBBBBBBBA").await; + assert_eq!(grand_mere["statut_reproducteur"], "Pouliniere"); + assert_eq!(grand_mere["nombre_de_descendants"], 1); + + let mere = json("/v1/equides/DDDDDDDDDDDDDDDDDDDDDA").await; + assert_eq!(mere["statut_reproducteur"], "Pouliniere"); + assert_eq!(mere["nombre_de_descendants"], 1); + + let eldorado = json("/v1/equides/EEEEEEEEEEEEEEEEEEEEEA").await; + assert_eq!(eldorado["nombre_de_descendants"], 0); + assert!(eldorado.get("statut_reproducteur").is_none()); +} + +#[tokio::test] +async fn filtre_referentiel_et_repartition_sur_le_statut_de_reproducteur() { + let page = json("/v1/equides?statut_reproducteur=Pouliniere").await; + assert_eq!(page["pagination"]["total"], 2); + + let insensible = json("/v1/equides?statut_reproducteur=POULINIERE").await; + assert_eq!(insensible["pagination"]["total"], 2); + + let ref_ = json("/v1/referentiels/statuts_reproducteur").await; + assert_eq!(ref_["nombre_de_modalites"], 2); + assert_eq!(ref_["donnees"][0]["valeur"], "Pouliniere"); + assert_eq!(ref_["donnees"][0]["nombre"], 2); + + let rep = json("/v1/stats/repartition?dimension=statut_reproducteur").await; + assert_eq!(rep["effectif_affiche"], 3); + + assert_eq!( + get("/v1/equides?statut_reproducteur=Licorne").await.0, + StatusCode::BAD_REQUEST + ); +} + +#[tokio::test] +async fn les_effectifs_globaux_comptent_les_champs_declares() { + let v = json("/v1/stats").await; + assert_eq!(v["avec_record"], 1); + assert_eq!(v["avec_statut_reproducteur"], 3); +} + #[tokio::test] async fn les_exports_ont_ete_retires() { for route in ["/v1/export.csv", "/v1/export.ndjson"] { @@ -745,7 +823,50 @@ async fn metadonnees_exposent_provenance_et_limites() { assert!(remarques.contains("aucune donnée à caractère personnel")); assert!(remarques.contains("non affilié")); assert!(remarques.contains("liens rompus")); - assert!(remarques.contains("11 747")); +} + +#[tokio::test] +async fn l_empreinte_annonce_son_algorithme_et_son_fichier() { + let v = json("/v1/meta").await; + let jeu = &v["jeu_de_donnees"]; + assert_eq!(jeu["algorithme_empreinte"], "blake3"); + assert_eq!(jeu["fichier_source"], "chevaux-de-test.jsonl"); + assert_eq!(jeu["octets_source"], 4096); + + let remarques = serde_json::to_string(&v["remarques"]).unwrap(); + assert!( + remarques.contains("blake3") && remarques.contains("chevaux-de-test.jsonl"), + "la remarque sur la mention obligatoire doit désigner le jeu sans ambiguïté : {remarques}" + ); +} + +#[tokio::test] +async fn les_remarques_ne_contredisent_pas_l_api() { + let meta = json("/v1/meta").await; + let remarques = serde_json::to_string(&meta["remarques"]).unwrap(); + let anomalies = &meta["jeu_de_donnees"]["anomalies"]; + + let races = json("/v1/referentiels/races").await["nombre_de_modalites"] + .as_u64() + .unwrap(); + let races_lien = json("/v1/referentiels/races_lien").await["nombre_de_modalites"] + .as_u64() + .unwrap(); + assert!( + remarques.contains(&format!("{races} valeurs")), + "la remarque sur les races doit citer les {races} modalités réellement servies" + ); + assert!(remarques.contains(&format!("{races_lien} valeurs"))); + + let pendants = anomalies["parents_pendants"].as_u64().unwrap(); + let citations = anomalies["references_pendantes"].as_u64().unwrap(); + assert!(citations >= pendants); + assert!(remarques.contains(&format!("Les {pendants} autres"))); + assert!(remarques.contains(&format!("{citations} citations rompues"))); + + let stats = json("/v1/stats").await; + let avec_record = stats["avec_record"].as_u64().unwrap(); + assert!(remarques.contains(&format!("sur {avec_record} fiches"))); } #[tokio::test]