Files
Omika/tests/Omika.Tests/Mobile/SentencePieceFixturesTests.cs
T
2026-10-23 18:46:50 +02:00

576 lines
23 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Omika.App.Local.Services;
using Xunit;
using Xunit.Sdk;
namespace Omika.Tests.Mobile;
/// <summary>
/// Conformité du tokeniseur SentencePiece maison à la bibliothèque de référence.
///
/// <para>
/// Le risque propre à un tokeniseur réécrit à la main n'est pas de lever une
/// exception : c'est de découper <b>légèrement autrement</b> que le modèle, donc
/// de dégrader les traductions sans aucun signal. Ces tests comparent les
/// identifiants produits par <see cref="SentencePieceTokenizer"/> à ceux de la
/// bibliothèque <c>sentencepiece</c>, figés dans des fixtures JSON par
/// <c>scripts\Generer-Fixtures-SentencePiece.ps1</c>.
/// </para>
///
/// <para>
/// <b>Sans fixtures, ces tests passent.</b> Ni les fixtures ni les modèles
/// <c>.spm</c> ne sont versionnés (modèles lourds, sous licence, et propres à
/// chaque pack de langue) : une suite rouge sur une machine neuve pour de simples
/// fichiers absents pousserait à désactiver le test — exactement l'inverse du
/// but. L'absence est donc expliquée sur la sortie standard, et seul un écart
/// réellement mesuré fait échouer.
/// </para>
/// </summary>
public sealed class SentencePieceFixturesTests : IDisposable
{
/// <summary>
/// Variable d'environnement qui désigne le dossier des fixtures. Elle permet
/// de vérifier un pack de langue sans rien copier dans le dépôt.
/// </summary>
private const string VariableDossierFixtures = "OMIKA_SPM_FIXTURES";
/// <summary>
/// Textes témoins du test d'idempotence : ils n'ont besoin d'aucune fixture,
/// seulement d'un modèle. Volontairement chargés en accents, en espaces
/// parasites et en alphabet non latin, car ce sont ces entrées qui font
/// diverger un aller-retour.
/// </summary>
private static readonly string[] TextesTemoins =
[
"Bonjour le monde.",
"L'été dernier, j'ai mangé une crêpe à Nîmes.",
"¿Qué tal? ¡Hola, señor! El niño pequeño añadió más café.",
" Bonjour le monde ",
string.Empty,
" ",
"Traduction hors ligne : 1 234,56 € (soit 42 %).",
"مرحبا بالعالم",
"Здравствуйте, мир!"
];
private readonly string _racineTemporaire = Path.Combine(
Path.GetTempPath(), "omika-spm-tests", Guid.NewGuid().ToString("N"));
public void Dispose()
{
try
{
if (Directory.Exists(_racineTemporaire))
{
Directory.Delete(_racineTemporaire, recursive: true);
}
}
catch (IOException)
{
// Best-effort : un fichier encore verrouillé ne doit pas faire
// échouer la suite de tests.
}
}
// ===== Comparaison au découpage de référence =====
[Fact]
public async Task Fixtures_LeDecoupageCorrespondAuxIdentifiantsDeReference()
{
string? dossier = TrouverDossierFixtures();
if (dossier is null)
{
Console.WriteLine(
"Aucun dossier de fixtures SentencePiece : comparaison à la référence ignorée. "
+ "Générez-les avec .\\scripts\\Generer-Fixtures-SentencePiece.ps1 -DossierModeles <dossier des .spm>.");
return;
}
string[] fixtures = Directory.GetFiles(dossier, "*.fixtures.json", SearchOption.TopDirectoryOnly);
Array.Sort(fixtures, StringComparer.OrdinalIgnoreCase);
if (fixtures.Length == 0)
{
Console.WriteLine(
$"Le dossier de fixtures « {dossier} » ne contient aucun fichier *.fixtures.json : "
+ "comparaison à la référence ignorée (voir tests/fixtures/sentencepiece/LISEZMOI.md).");
return;
}
int modelesCharges = 0;
int casVerifies = 0;
foreach (string cheminFixture in fixtures)
{
string nomFixture = Path.GetFileName(cheminFixture);
FixtureSentencePiece fixture = LireFixture(cheminFixture);
string? cheminModele = TrouverModele(dossier, fixture.Modele);
if (cheminModele is null)
{
// Le vocabulaire n'est pas versionné : son absence est le cas
// NORMAL d'un dépôt fraîchement cloné, pas une anomalie.
Console.WriteLine(
$"Fixture « {nomFixture} » ignorée : le vocabulaire « {fixture.Modele} » "
+ $"est absent de {dossier} (copiez-le, ou lancez le générateur avec -CopierModeles).");
continue;
}
SentencePieceTokenizer tokeniseur = SentencePieceTokenizer.Charger(cheminModele);
modelesCharges++;
// Une fixture et un modèle de tailles de vocabulaire différentes ne
// viennent pas du même modèle : comparer leurs découpages n'aurait
// aucun sens, et le signaler ici évite de chercher une divergence de
// Viterbi là où il n'y a qu'un mauvais appariement de fichiers.
Assert.True(
fixture.VocabSize == tokeniseur.TailleVocabulaire,
$"Le vocabulaire « {Path.GetFileName(cheminModele)} » compte "
+ $"{tokeniseur.TailleVocabulaire} pièces, alors que la fixture « {nomFixture} » "
+ $"en annonce {fixture.VocabSize} : les deux fichiers ne viennent pas du même modèle.");
if (fixture.Cas.Count == 0)
{
Console.WriteLine($"Fixture « {nomFixture} » ignorée : elle ne décrit aucun cas.");
continue;
}
// Les réglages de la référence sont affichés avant les cas : ils
// datent la comparaison (une divergence constatée dans six mois ne se
// confond pas avec un changement de version de la bibliothèque) et
// disent si le découpage attendu inclut un préfixe factice ou des
// espaces échappés — deux causes classiques d'écart systématique.
Console.WriteLine(
$"Fixture « {nomFixture} » : {fixture.Cas.Count} cas sur « {Path.GetFileName(cheminModele)} » "
+ $"(sentencepiece {fixture.SentencePieceVersion}, normalisation {fixture.NormalizerName}, "
+ $"préfixe factice={fixture.AddDummyPrefix}, espaces superflus={fixture.RemoveExtraWhitespaces}, "
+ $"espaces échappés={fixture.EscapeWhitespaces}).");
foreach (CasFixture cas in fixture.Cas)
{
await VerifierCasAsync(tokeniseur, cas, nomFixture, fixture.SentencePieceVersion);
casVerifies++;
}
}
Console.WriteLine($"{casVerifies} cas de référence vérifiés sur {modelesCharges} modèle(s).");
}
/// <summary>
/// Vérifie un cas de référence : les identifiants d'encodage, puis le texte
/// reconstruit à partir de ces <b>mêmes</b> identifiants.
/// </summary>
private static async Task VerifierCasAsync(
SentencePieceTokenizer tokeniseur, CasFixture cas, string nomFixture, string? versionReference)
{
string texte = cas.Texte ?? string.Empty;
IReadOnlyList<int> attendus = cas.Ids ?? [];
string decodeAttendu = cas.Decode ?? string.Empty;
IReadOnlyList<int> obtenus = await tokeniseur.EncodeAsync(texte);
int index = PremierEcart(attendus, obtenus);
if (index >= 0)
{
// Comparaison détaillée plutôt que Assert.Equal sur les séquences :
// « expected [1, 2, 3], actual [1, 2, 7] » sur des dizaines
// d'identifiants ne dit ni OÙ le découpage diverge, ni sur quelle
// pièce — c'est pourtant la seule information exploitable.
Assert.True(false,
$"Découpage divergent pour {DecrireTexte(texte)} "
+ $"(fixture {nomFixture}, référence sentencepiece {versionReference}).{Environment.NewLine}"
+ $"Premier écart à l'index {index} : référence {Valeur(attendus, index)} "
+ $"{DecrirePiece(tokeniseur, attendus, index)}, tokeniseur {Valeur(obtenus, index)} "
+ $"{DecrirePiece(tokeniseur, obtenus, index)}.{Environment.NewLine}"
+ $"Identifiants de référence ({attendus.Count}) : {AbregerListe(attendus)}{Environment.NewLine}"
+ $"Identifiants obtenus ({obtenus.Count}) : {AbregerListe(obtenus)}{Environment.NewLine}"
+ $"Texte reconstruit par la référence : « {decodeAttendu} ».");
}
// Le décodage est comparé sur les identifiants de la RÉFÉRENCE : c'est la
// seule façon de tester la reconstruction sans que l'encodage maison
// puisse masquer une erreur de décodage.
string decode = tokeniseur.Decode(attendus);
if (!string.Equals(decodeAttendu, decode, StringComparison.Ordinal))
{
Assert.True(false,
$"Décodage divergent pour {DecrireTexte(texte)} "
+ $"(fixture {nomFixture}, référence sentencepiece {versionReference}).{Environment.NewLine}"
+ $"Référence : « {decodeAttendu} »{Environment.NewLine}"
+ $"Obtenu : « {decode} »{Environment.NewLine}"
+ $"Identifiants décodés : {AbregerListe(attendus)}.");
}
}
// ===== Idempotence de l'aller-retour (aucune fixture requise) =====
[Fact]
public async Task AllerRetour_DecoderDeuxFois_DonneToujoursLeMemeTexte()
{
// Ce test ne lit AUCUNE fixture : il lui faut seulement un modèle .spm
// pour construire un tokeniseur. Il vérifie la seule propriété de
// l'aller-retour qui ne dépende pas du vocabulaire : l'idempotence, soit
// f(f(x)) = f(x) avec f = Decode ∘ Encode.
//
// Il serait FAUX d'exiger f(x) == x : un modèle peut légitimement replier
// une casse, réduire des espaces multiples ou rendre <unk> pour un
// caractère hors vocabulaire. Exiger l'égalité au texte d'entrée ferait
// échouer le test sur un tokeniseur pourtant correct.
string? cheminModele = TrouverPremierModele(TrouverDossierFixtures());
if (cheminModele is null)
{
Console.WriteLine(
"Aucun modèle .spm dans le dossier des fixtures : test d'idempotence de l'aller-retour ignoré "
+ "(voir tests/fixtures/sentencepiece/LISEZMOI.md).");
return;
}
SentencePieceTokenizer tokeniseur = SentencePieceTokenizer.Charger(cheminModele);
Console.WriteLine($"Idempotence de l'aller-retour vérifiée sur le modèle « {Path.GetFileName(cheminModele)} ».");
foreach (string texte in TextesTemoins)
{
IReadOnlyList<int> ids = await tokeniseur.EncodeAsync(texte);
string premier = tokeniseur.Decode(ids);
IReadOnlyList<int> idsReencodes = await tokeniseur.EncodeAsync(premier);
string second = tokeniseur.Decode(idsReencodes);
if (!string.Equals(premier, second, StringComparison.Ordinal))
{
Assert.True(false,
$"Aller-retour non idempotent pour {DecrireTexte(texte)} : "
+ $"premier décodage « {premier} », décodage du texte réencodé « {second} ».");
}
}
}
// ===== Diagnostic de chargement (aucune fixture requise) =====
[Fact]
public void Chargement_UnFichierInexploitable_LeveUneErreurEnFrancais()
{
string dossier = Path.Combine(_racineTemporaire, "invalides");
Directory.CreateDirectory(dossier);
// Octets DÉTERMINISTES plutôt qu'un tirage aléatoire : un vrai tirage
// pourrait, avec une probabilité faible mais non nulle, former un
// protobuf exploitable (une suite d'octets est un protobuf valide plus
// souvent qu'on ne le croit) et faire passer le test au vert pour de
// mauvaises raisons. Ici, le premier octet annonce un champ « pieces »
// dont la longueur dépasse le fichier : la troncature est certaine.
byte[] bruit = [0x0A, 0xFF, 0xFF, 0xFF, 0x7F, 0xC3, 0x28, 0x9A, 0x51];
string cheminBruit = Path.Combine(dossier, "bruit.spm");
File.WriteAllBytes(cheminBruit, bruit);
SentencePieceException erreurBruit = Assert.Throws<SentencePieceException>(
() => { _ = SentencePieceTokenizer.Charger(cheminBruit); });
// Le message est destiné à l'utilisateur : il doit être en français et
// nommer le fichier, jamais exposer un détail binaire ni un chemin de
// sandbox. Une erreur de lecture protobuf brute (IndexOutOfRange,
// ArgumentOutOfRange) signalerait une lecture hors bornes non maîtrisée.
Assert.Contains("vocabulaire", erreurBruit.Message, StringComparison.Ordinal);
// Un fichier qui n'est pas un protobuf du tout : la même erreur métier,
// pas un plantage du lecteur minimal.
string cheminTexte = Path.Combine(dossier, "texte.spm");
File.WriteAllBytes(cheminTexte, Encoding.UTF8.GetBytes("ceci n'est pas un modèle SentencePiece"));
SentencePieceException erreurTexte = Assert.Throws<SentencePieceException>(
() => { _ = SentencePieceTokenizer.Charger(cheminTexte); });
Assert.Contains("vocabulaire", erreurTexte.Message, StringComparison.Ordinal);
// Modèle absent : l'utilisateur doit lire qu'il faut réinstaller le pack
// de langue, pas voir remonter le FileNotFoundException du système (que
// l'application ne saurait pas traduire en conseil).
string cheminAbsent = Path.Combine(dossier, "absent.spm");
Assert.False(File.Exists(cheminAbsent));
SentencePieceException erreurAbsente = Assert.Throws<SentencePieceException>(
() => { _ = SentencePieceTokenizer.Charger(cheminAbsent); });
Assert.Contains("introuvable", erreurAbsente.Message, StringComparison.Ordinal);
}
// ===== Localisation des fichiers =====
/// <summary>
/// Dossier des fixtures : <c>OMIKA_SPM_FIXTURES</c> s'il est défini et
/// existe, sinon <c>tests/fixtures/sentencepiece</c> sous la racine du
/// dépôt. <see langword="null"/> quand aucun des deux n'existe.
/// </summary>
private static string? TrouverDossierFixtures()
{
string? variable = Environment.GetEnvironmentVariable(VariableDossierFixtures);
if (!string.IsNullOrWhiteSpace(variable) && Directory.Exists(variable))
{
return variable;
}
string? racine = TrouverRacineDepot();
if (racine is null)
{
return null;
}
string dossier = Path.Combine(racine, "tests", "fixtures", "sentencepiece");
return Directory.Exists(dossier) ? dossier : null;
}
/// <summary>
/// Racine du dépôt, trouvée en remontant depuis le dossier de l'assembly de
/// test jusqu'au premier dossier contenant <c>Omika.slnx</c>.
///
/// <para>
/// Le chemin n'est ni codé en dur ni pris dans le dossier courant :
/// <c>dotnet test</c> s'exécute depuis <c>bin\Debug\net10.0</c>, un
/// exécuteur d'IDE depuis la racine, et un test lancé depuis un autre dossier
/// ne doit pas changer le résultat. La marche ascendante est la seule
/// méthode qui fonctionne dans tous ces cas.
/// </para>
/// </summary>
private static string? TrouverRacineDepot()
{
DirectoryInfo? dossier = new(AppContext.BaseDirectory);
while (dossier is not null)
{
if (File.Exists(Path.Combine(dossier.FullName, "Omika.slnx")))
{
return dossier.FullName;
}
dossier = dossier.Parent;
}
return null;
}
/// <summary>
/// Chemin du <c>.spm</c> annoncé par une fixture : d'abord le nom tel quel
/// dans le dossier des fixtures (ce qui accepte un sous-dossier), puis le nom
/// simple à la racine de ce dossier.
/// </summary>
private static string? TrouverModele(string dossierFixtures, string? nomAnnonce)
{
if (string.IsNullOrWhiteSpace(nomAnnonce))
{
return null;
}
string nomSimple = Path.GetFileName(nomAnnonce);
if (string.IsNullOrEmpty(nomSimple))
{
return null;
}
// Un chemin annoncé qui remonterait hors du dossier des fixtures est
// refusé : ces JSON sont produits par notre propre script, mais ils ne
// constituent pas pour autant une source de confiance.
bool cheminInterne = !Path.IsPathRooted(nomAnnonce)
&& !nomAnnonce.Split('/', '\\').Contains("..");
if (cheminInterne)
{
string dansSousDossier = Path.Combine(dossierFixtures, nomAnnonce);
if (File.Exists(dansSousDossier))
{
return dansSousDossier;
}
}
string aLaRacine = Path.Combine(dossierFixtures, nomSimple);
return File.Exists(aLaRacine) ? aLaRacine : null;
}
/// <summary>Premier <c>.spm</c> trouvé sous le dossier des fixtures, ou <see langword="null"/>.</summary>
private static string? TrouverPremierModele(string? dossierFixtures)
{
if (dossierFixtures is null || !Directory.Exists(dossierFixtures))
{
return null;
}
string[] modeles = Directory.GetFiles(dossierFixtures, "*.spm", SearchOption.AllDirectories);
Array.Sort(modeles, StringComparer.OrdinalIgnoreCase);
return modeles.Length == 0 ? null : modeles[0];
}
/// <summary>
/// Lit une fixture. Un fichier illisible ou vide est une <b>anomalie</b>, pas
/// une absence : il est signalé comme un échec, car il ferait passer le test
/// au vert en n'ayant rien vérifié.
/// </summary>
private static FixtureSentencePiece LireFixture(string chemin)
{
string json = File.ReadAllText(chemin);
try
{
return JsonSerializer.Deserialize<FixtureSentencePiece>(json)
?? throw new XunitException(
$"La fixture « {Path.GetFileName(chemin)} » est vide : régénérez-la avec "
+ "scripts\\Generer-Fixtures-SentencePiece.ps1.");
}
catch (JsonException erreur)
{
throw new XunitException(
$"La fixture « {Path.GetFileName(chemin)} » n'est pas un JSON valide : {erreur.Message}");
}
}
// ===== Aides de diagnostic =====
/// <summary>
/// Index du premier identifiant qui diffère, ou -1 si les deux séquences sont
/// identiques. Une séquence plus courte diverge dès le premier identifiant
/// manquant : le retourner évite un accès hors bornes dans le message.
/// </summary>
private static int PremierEcart(IReadOnlyList<int> attendus, IReadOnlyList<int> obtenus)
{
int commun = Math.Min(attendus.Count, obtenus.Count);
for (int i = 0; i < commun; i++)
{
if (attendus[i] != obtenus[i])
{
return i;
}
}
return attendus.Count == obtenus.Count ? -1 : commun;
}
/// <summary>Identifiant à un index, ou un tiret quand la séquence est plus courte.</summary>
private static string Valeur(IReadOnlyList<int> ids, int index)
=> index < ids.Count ? ids[index].ToString(CultureInfo.InvariantCulture) : "—";
/// <summary>
/// Texte lisible d'un identifiant, pour le message d'échec.
///
/// <para>
/// <see cref="SentencePieceTokenizer.Decode"/> sur un identifiant isolé donne
/// une indication suffisante (le marqueur ▁ devient une espace et les bords
/// sont rognés) : c'est une aide au diagnostic, pas une restitution fidèle du
/// texte exact de la pièce, qui n'est pas exposé par l'interface publique.
/// </para>
/// </summary>
private static string DecrirePiece(
SentencePieceTokenizer tokeniseur, IReadOnlyList<int> ids, int index)
{
if (index >= ids.Count)
{
return "(aucun identifiant de ce côté)";
}
int id = ids[index];
if (id < 0 || id >= tokeniseur.TailleVocabulaire)
{
return "(hors vocabulaire)";
}
return $"« {tokeniseur.Decode([id])} »";
}
/// <summary>
/// Texte d'un cas, rendu lisible dans un message d'échec : retour à la ligne,
/// tabulation et longueur bornée, sinon une phrase de référence rendrait le
/// message illisible.
/// </summary>
private static string DecrireTexte(string? texte)
{
if (string.IsNullOrEmpty(texte))
{
return "le cas « chaîne vide »";
}
string lisible = texte
.Replace("\r", "\\r", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal)
.Replace("\t", "\\t", StringComparison.Ordinal);
string tronque = lisible.Length <= 80 ? lisible : lisible[..80] + "…";
return $"le cas « {tronque} »";
}
/// <summary>Début d'une longue séquence d'identifiants, suivi d'une ellipse.</summary>
private static string AbregerListe(IReadOnlyList<int> ids)
{
const int maximum = 24;
var texte = new StringBuilder();
for (int i = 0; i < Math.Min(maximum, ids.Count); i++)
{
if (i > 0)
{
texte.Append(", ");
}
texte.Append(ids[i].ToString(CultureInfo.InvariantCulture));
}
if (ids.Count > maximum)
{
texte.Append(", …");
}
return texte.ToString();
}
// ===== Forme du fixture écrit par le générateur =====
/// <summary>
/// Fixture de référence. Les noms JSON sont écrits explicitement en camelCase
/// plutôt que déduits d'une politique de nommage : le schéma est un contrat
/// partagé avec le script Python, il ne doit pas pouvoir changer en silence.
/// </summary>
private sealed class FixtureSentencePiece
{
[JsonPropertyName("modele")]
public string? Modele { get; set; }
[JsonPropertyName("sentencepieceVersion")]
public string? SentencePieceVersion { get; set; }
[JsonPropertyName("vocabSize")]
public int VocabSize { get; set; }
[JsonPropertyName("addDummyPrefix")]
public bool AddDummyPrefix { get; set; }
[JsonPropertyName("removeExtraWhitespaces")]
public bool RemoveExtraWhitespaces { get; set; }
[JsonPropertyName("escapeWhitespaces")]
public bool EscapeWhitespaces { get; set; }
[JsonPropertyName("normalizerName")]
public string? NormalizerName { get; set; }
[JsonPropertyName("cas")]
public List<CasFixture> Cas { get; set; } = [];
}
/// <summary>Un texte de référence, ses identifiants et la reconstruction de la référence.</summary>
private sealed class CasFixture
{
[JsonPropertyName("texte")]
public string? Texte { get; set; }
[JsonPropertyName("ids")]
public List<int>? Ids { get; set; }
[JsonPropertyName("decode")]
public string? Decode { get; set; }
}
}