Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,575 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user