Files
Omika/scripts/Generer-Fixtures-SentencePiece.ps1
T
2026-10-23 18:46:50 +02:00

473 lines
19 KiB
PowerShell

# ============================================================
# Omika — Fixtures de référence SentencePiece
# ============================================================
# Usage (poste de développement, Python 3 + sentencepiece installés) :
# .\scripts\Generer-Fixtures-SentencePiece.ps1 -DossierModeles C:\ModelesLangues
# .\scripts\Generer-Fixtures-SentencePiece.ps1 -DossierModeles .\artifacts -CopierModeles
# .\scripts\Generer-Fixtures-SentencePiece.ps1 -DossierModeles C:\M -Sortie C:\Temp\fixtures
#
# Pour chaque modèle .spm trouvé RÉCURSIVEMENT sous -DossierModeles, écrit un
# fixture JSON (<nom>.fixtures.json) contenant, pour une série de textes
# français et espagnols, les identifiants produits par la bibliothèque de
# RÉFÉRENCE (sentencepiece) et le texte qu'elle reconstruit à partir de ces
# identifiants.
#
# À quoi ça sert : le tokeniseur de src\Omika.App.Local\Services\
# SentencePieceTokenizer.cs est réécrit à la main (lecture protobuf, Viterbi
# UNIGRAM, BPE). Son risque n'est pas de lever une exception, c'est de découper
# LÉGÈREMENT autrement que le modèle — donc de dégrader les traductions sans
# aucun signal. Ces fixtures sont la seule mesure de cette divergence : elles
# transforment un soupçon en un test rouge qui nomme la phrase fautive.
#
# Le script est idempotent : il réécrit ses propres fixtures (fichiers
# <nom>.fixtures.json du dossier -Sortie) et ne supprime jamais rien d'autre. Le
# programme Python temporaire qu'il crée est supprimé par lui, dans un finally.
# ============================================================
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DossierModeles,
# Dossier des fixtures, relatif à la racine du dépôt s'il ne l'est pas déjà.
[string]$Sortie = 'tests\fixtures\sentencepiece',
# Copie chaque .spm à côté de son fixture : le test .NET cherche le modèle
# dans le dossier des fixtures, et la copie évite de le faire à la main. Les
# .spm restent hors du dépôt (artefacts de modèle lourds et sous licence).
[switch]$CopierModeles
)
$ErrorActionPreference = 'Stop'
# Racine du dépôt = parent du dossier scripts\.
$RepoRoot = Split-Path -Parent $PSScriptRoot
function Stop-AvecMessage {
param([string]$Message)
Write-Host ''
Write-Host "ERREUR : $Message" -ForegroundColor Red
exit 1
}
function Format-CheminRelatif {
param([string]$Chemin)
# Les chemins absolus de la machine de développement n'ont rien à faire dans
# une sortie destinée à être recopiée dans un ticket : on les rend relatifs
# au dépôt dès que c'est possible.
if ($Chemin.StartsWith($RepoRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
return $Chemin.Substring($RepoRoot.Length).TrimStart('\', '/')
}
return $Chemin
}
function Find-Python {
# « python » sur Windows peut être l'alias du Microsoft Store, qui n'exécute
# rien : on vérifie donc que la commande RÉPOND « Python 3 », et pas
# seulement qu'un fichier de ce nom existe. Le lanceur « py -3 » couvre les
# installations qui n'exposent pas « python » dans le PATH.
$candidats = @(
@{ Nom = 'python'; Arguments = @() },
@{ Nom = 'py'; Arguments = @('-3') }
)
foreach ($candidat in $candidats) {
$commande = Get-Command $candidat.Nom -ErrorAction SilentlyContinue
if (-not $commande) { continue }
$arguments = @($candidat.Arguments) + @('--version')
$sortie = $null
$code = -1
# Windows PowerShell 5.1 transforme la sortie d'ERREUR d'un programme
# natif en erreur TERMINANTE dès que $ErrorActionPreference vaut Stop :
# l'alias Microsoft Store de « python », qui écrit sur stderr, ferait
# donc échouer la détection au lieu d'être simplement écarté. On
# assouplit la préférence le temps de la capture.
$preferenceErreur = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$sortie = (& $commande.Source @arguments 2>&1 | Out-String)
$code = $LASTEXITCODE
}
catch {
continue
}
finally {
$ErrorActionPreference = $preferenceErreur
}
if ($code -eq 0 -and $sortie -match 'Python 3') {
return @{ Nom = $candidat.Nom; Chemin = $commande.Source; Arguments = @($candidat.Arguments) }
}
}
return $null
}
# --- Dossiers -----------------------------------------------------------------
if (-not (Test-Path -LiteralPath $DossierModeles -PathType Container)) {
Stop-AvecMessage "Le dossier de modèles « $DossierModeles » n'existe pas. Indiquez -DossierModeles <dossier contenant les .spm>."
}
if (-not [System.IO.Path]::IsPathRooted($Sortie)) {
$Sortie = Join-Path $RepoRoot $Sortie
}
$modeles = @(Get-ChildItem -LiteralPath $DossierModeles -Filter '*.spm' -Recurse -File | Sort-Object FullName)
if ($modeles.Count -eq 0) {
Stop-AvecMessage "Aucun fichier .spm trouvé sous « $DossierModeles ». Le vocabulaire d'un pack de langue s'appelle vocab.spm (arborescence langues\{code}\{sens}\vocab.spm) : passez le dossier qui le contient."
}
# --- Interpréteur Python et bibliothèque de référence -------------------------
# Ces vérifications précèdent la création du dossier de sortie : un script qui
# échoue faute de Python ne doit laisser AUCUNE trace derrière lui.
$python = Find-Python
if (-not $python) {
Stop-AvecMessage @"
Python 3 est introuvable sur ce poste (ni « python », ni « py -3 »).
Installez Python 3 depuis https://www.python.org/downloads/ puis la bibliothèque
de référence utilisée par ce script :
pip install sentencepiece
Aucune fixture n'a été écrite.
"@
}
$commandePip = "$($python.Chemin) $($python.Arguments -join ' ') -m pip install sentencepiece".Trim()
$verification = @($python.Arguments) + @('-c', 'import sentencepiece')
# Même précaution que dans Find-Python : l'échec d'import écrit sur stderr, et
# sous Windows PowerShell 5.1 une erreur native redirigée devient terminante
# quand $ErrorActionPreference vaut Stop — on veut ici la LIRE pour la traduire.
$preferenceErreur = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$sortieImport = (& $python.Chemin @verification 2>&1 | Out-String).Trim()
$codeImport = $LASTEXITCODE
}
finally {
$ErrorActionPreference = $preferenceErreur
}
if ($codeImport -ne 0) {
Stop-AvecMessage @"
La bibliothèque « sentencepiece » n'est pas installée pour l'interpréteur détecté ($($python.Chemin)).
Installez-la :
$commandePip
Erreur renvoyée par Python :
$sortieImport
Aucune fixture n'a été écrite.
"@
}
if (-not (Test-Path -LiteralPath $Sortie)) {
New-Item -ItemType Directory -Path $Sortie -Force | Out-Null
}
# --- Programme Python embarqué ------------------------------------------------
# Le programme est ÉCRIT DANS UN FICHIER TEMPORAIRE puis supprimé : l'humain n'a
# qu'un seul script à connaître, et il ne peut pas désynchroniser un second
# fichier oublié dans le dépôt.
#
# ATTENTION ENCODAGE : le programme contient des textes accentués, arabes et
# cyrilliques. Il est donc écrit explicitement en UTF-8 SANS BOM (voir l'écriture
# ci-dessous) ; il en va de même du JSON qu'il produit, un BOM en tête de fichier
# faisant échouer les lecteurs JSON stricts.
#
# Corollaire pour CE fichier : le script doit lui-même être enregistré en UTF-8
# AVEC BOM, sinon Windows PowerShell 5.1 (qui lit l'UTF-8 sans BOM comme de
# l'ANSI) transformerait les textes de référence en caractères parasites — et un
# fixture de textes corrompus ne mesurerait plus rien.
$ProgrammePython = @'
# -*- coding: utf-8 -*-
"""Écrit le fixture JSON d'un modèle SentencePiece .spm.
Usage : python <ce_fichier.py> <modele.spm> <sortie.fixtures.json>
Le fixture décrit le découpage de la bibliothèque de RÉFÉRENCE, jamais celui de
l'implémentation maison : `ids` vient de EncodeAsIds et `decode` de DecodeIds
appliqué à CES identifiants. Le fixture est donc auto-cohérent, et le test .NET
compare le tokeniseur C# à la référence au lieu de le comparer à une
transcription manuelle, qui figerait une erreur de recopie.
"""
import json
import os
import sys
from sentencepiece import sentencepiece as spm
def lire_varint(donnees, position):
"""Lit un entier variable protobuf : (valeur, position suivante)."""
valeur = 0
decalage = 0
while True:
octet = donnees[position]
position += 1
valeur |= (octet & 0x7F) << decalage
if not octet & 0x80:
return valeur, position
decalage += 7
def champs(donnees):
"""Parcourt les champs d'un message protobuf : (numéro, type de fil, valeur)."""
position = 0
while position < len(donnees):
cle, position = lire_varint(donnees, position)
champ, fil = cle >> 3, cle & 0x07
if fil == 0:
valeur, position = lire_varint(donnees, position)
elif fil == 1:
valeur, position = donnees[position:position + 8], position + 8
elif fil == 2:
longueur, position = lire_varint(donnees, position)
valeur, position = donnees[position:position + longueur], position + longueur
elif fil == 5:
valeur, position = donnees[position:position + 4], position + 4
else:
raise ValueError("type de fil protobuf non pris en charge : %d" % fil)
yield champ, fil, valeur
def spec_normalisation_a_la_main(chemin):
"""Repli sans dépendance : lit normalizer_spec (champ 3 du ModelProto).
`sentencepiece_model_pb2` n'est pas toujours importable (il dépend du runtime
protobuf, absent de certaines installations minimales) ; or ces quatre
réglages décident du découpage — les deviner fausserait tout le fixture. Les
valeurs par défaut reproduites ici sont celles du proto2 de SentencePiece :
un champ absent vaut True.
"""
with open(chemin, "rb") as fichier:
donnees = fichier.read()
for champ, fil, valeur in champs(donnees):
if champ != 3 or fil != 2: # ModelProto.normalizer_spec
continue
nom, prefixe, superflus, echapper = "nmt_nfkc", True, True, True
for numero, type_fil, contenu in champs(valeur):
if numero == 1 and type_fil == 2:
nom = contenu.decode("utf-8") or nom
elif numero == 3 and type_fil == 0:
prefixe = bool(contenu)
elif numero == 4 and type_fil == 0:
superflus = bool(contenu)
elif numero == 5 and type_fil == 0:
echapper = bool(contenu)
return nom, prefixe, superflus, echapper
return "nmt_nfkc", True, True, True
def spec_normalisation(chemin):
"""Réglages de normalisation enregistrés dans le modèle, table lue en priorité."""
try:
from sentencepiece import sentencepiece_model_pb2
modele = sentencepiece_model_pb2.ModelProto()
with open(chemin, "rb") as fichier:
modele.ParseFromString(fichier.read())
spec = modele.normalizer_spec
return (
spec.name or "nmt_nfkc",
bool(spec.add_dummy_prefix),
bool(spec.remove_extra_whitespaces),
bool(spec.escape_whitespaces),
)
except Exception:
return spec_normalisation_a_la_main(chemin)
def version_sentencepiece():
"""Version de la référence, écrite dans le fixture : elle DATE la comparaison.
Sans elle, une divergence constatée dans six mois ne se distingue pas d'un
changement de version de la bibliothèque.
"""
import sentencepiece
version = getattr(sentencepiece, "__version__", None)
if not version:
version = getattr(getattr(sentencepiece, "sentencepiece", None), "__version__", None)
return str(version) if version else "inconnue"
def cas_de_reference():
"""Textes comparés, avec leur étiquette.
Les deux langues visées par les packs sont couvertes, plus les entrées qui
font diverger un tokeniseur réécrit à la main : espaces de bord et doubles,
chaîne vide, chiffres et ponctuation, casse mêlée, alphabets non latins, et
une phrase longue — c'est là, et nulle part ailleurs, que se voit le coût du
pire cas d'un Viterbi naïf.
"""
longue = (
"La traduction hors ligne doit rester fidèle même sur une phrase très "
"longue, parce que c'est exactement le genre d'entrée qui fait diverger "
"une implémentation réécrite à la main. "
) * 12
return [
("francais-accents", "Bonjour le monde, l'été dernier j'ai mangé une crêpe à Nîmes."),
("espagnol-accents", "¿Qué tal? ¡Hola, señor! El niño pequeño añadió más café."),
("espaces-bords-et-doubles", " Bonjour le monde "),
("chaine-vide", ""),
("espaces-seulement", " "),
("chiffres-et-ponctuation", "Le total est 1 234,56 € (soit 42 %), lundi 03/02/2025 ; OK ?"),
("phrase-tres-longue", longue.strip()),
("casse-melangee", "OMIKA Omika omika BonJour LE MONDE Traduction HORS LIGNE"),
("arabe", "مرحبا بالعالم، كيف حالك اليوم؟"),
("cyrillique", "Здравствуйте, мир! Как ваши дела сегодня?"),
("nouvelle-ligne-et-tabulation", "Bonjour\nle\tmonde"),
]
def main():
if len(sys.argv) != 3:
sys.stderr.write("Usage : python <programme> <modele.spm> <sortie.fixtures.json>\n")
return 2
chemin_modele, chemin_sortie = sys.argv[1], sys.argv[2]
processeur = spm.SentencePieceProcessor(model_file=chemin_modele)
nom, prefixe, superflus, echapper = spec_normalisation(chemin_modele)
cas = []
for etiquette, texte in cas_de_reference():
try:
ids = processeur.EncodeAsIds(texte)
decode = processeur.DecodeIds(ids)
except Exception as erreur:
# Le nom du cas est indispensable : sans lui, un modèle exotique
# ferait échouer la génération sans dire sur quelle entrée.
sys.stderr.write("Cas « %s » en échec : %s\n" % (etiquette, erreur))
raise
cas.append({"texte": texte, "ids": [int(i) for i in ids], "decode": decode})
fixture = {
"modele": os.path.basename(chemin_modele),
"sentencepieceVersion": version_sentencepiece(),
"vocabSize": int(processeur.get_piece_size()),
"addDummyPrefix": bool(prefixe),
"removeExtraWhitespaces": bool(superflus),
"escapeWhitespaces": bool(echapper),
"normalizerName": nom,
"cas": cas,
}
# ensure_ascii=False : les textes accentués, arabes et cyrilliques restent
# lisibles dans le JSON, ce qui rend une divergence lisible à l'œil.
# encoding="utf-8" SANS BOM (Python n'en écrit jamais par défaut) : un BOM
# ferait échouer les lecteurs JSON stricts.
with open(chemin_sortie, "w", encoding="utf-8", newline="\n") as fichier:
json.dump(fixture, fichier, ensure_ascii=False, indent=2)
fichier.write("\n")
return 0
if __name__ == "__main__":
sys.exit(main())
'@
$cheminProgramme = Join-Path ([System.IO.Path]::GetTempPath()) ('omika-fixtures-sentencepiece-' + (New-Guid).ToString('N') + '.py')
[System.IO.File]::WriteAllText($cheminProgramme, $ProgrammePython, (New-Object System.Text.UTF8Encoding($false)))
$lignes = New-Object System.Collections.Generic.List[object]
$copies = New-Object System.Collections.Generic.List[string]
try {
foreach ($modele in $modeles) {
$nomFixture = $modele.BaseName + '.fixtures.json'
$cheminFixture = Join-Path $Sortie $nomFixture
Write-Host ("Génération du fixture de « {0} » ({1} Ko)…" -f $modele.Name, [int]($modele.Length / 1KB))
$arguments = @($python.Arguments) + @($cheminProgramme, $modele.FullName, $cheminFixture)
& $python.Chemin @arguments
if ($LASTEXITCODE -ne 0) {
Stop-AvecMessage "La génération du fixture de « $($modele.FullName) » a échoué (code $LASTEXITCODE). Aucune fixture n'a été écrite pour ce modèle ; les messages de la bibliothèque sentencepiece ci-dessus indiquent la cause (fichier tronqué, modèle d'un type non géré…)."
}
# Relecture du fixture écrit : c'est ce que lira le test .NET, et c'est
# donc cette relecture qui alimente le tableau récapitulatif.
$fixture = Get-Content -LiteralPath $cheminFixture -Raw -Encoding UTF8 | ConvertFrom-Json
if (-not $fixture.vocabSize -or -not $fixture.cas) {
Stop-AvecMessage "Le fixture « $(Format-CheminRelatif $cheminFixture) » vient d'être écrit mais ne contient ni taille de vocabulaire ni cas de test : le fichier est inexploitable, ne le commitez pas."
}
if ($CopierModeles) {
$destination = Join-Path $Sortie $modele.Name
if (Test-Path -LiteralPath $destination) {
# Deux modèles de même nom venant de dossiers différents : on ne
# remplace jamais un .spm existant par un autre (le fixture déjà
# écrit pourrait décrire le premier).
if ((Get-FileHash -LiteralPath $modele.FullName).Hash -ne (Get-FileHash -LiteralPath $destination).Hash) {
Write-Host " ATTENTION : « $(Format-CheminRelatif $destination) » existe avec un contenu différent — copie ignorée, copiez le bon modèle à la main." -ForegroundColor Yellow
}
}
else {
Copy-Item -LiteralPath $modele.FullName -Destination $destination
$copies.Add($modele.Name)
}
}
$lignes.Add([pscustomobject]@{
'Modèle' = $modele.Name
'Vocabulaire' = [int]$fixture.vocabSize
'Cas' = @($fixture.cas).Count
'Fixture' = Format-CheminRelatif $cheminFixture
})
}
}
finally {
# Rien d'autre que ce fichier temporaire n'est supprimé par ce script.
Remove-Item -LiteralPath $cheminProgramme -Force -ErrorAction SilentlyContinue
}
Write-Host ''
Write-Host 'Résumé :'
$lignes | Format-Table -AutoSize | Out-String | Write-Host
Write-Host ("{0} fixture(s) écrite(s) dans {1}." -f $lignes.Count, (Format-CheminRelatif $Sortie))
if ($CopierModeles) {
if ($copies.Count -gt 0) {
Write-Host ("{0} modèle(s) copié(s) à côté de leur fixture : {1}." -f $copies.Count, ($copies -join ', '))
}
}
else {
Write-Host "Le test .NET cherche le .spm DANS le dossier des fixtures : relancez avec -CopierModeles, ou copiez-le à la main (il n'est pas versionné)."
}
Write-Host "Ensuite : dotnet test tests\Omika.Tests\Omika.Tests.csproj --filter FullyQualifiedName~SentencePieceFixturesTests"