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

431 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Omika.App.Local.Services;
using Xunit;
namespace Omika.Tests.Mobile;
/// <summary>
/// Tokeniseur SentencePiece : vérifie que les réglages de normalisation et le repli octet sont bien
/// <b>lus dans le fichier</b>, et non supposés.
///
/// <para>
/// Ces tests construisent un modèle SentencePiece <b>à la main</b> (protobuf minimal écrit octet par
/// octet). C'est délibéré : ils s'exécutent donc sans télécharger le moindre modèle, et surtout ils
/// permettent de fabriquer des modèles <b>atypiques</b> — préfixe factice désactivé, espaces littéraux —
/// qu'aucun modèle courant ne fournit, alors que ce sont exactement ceux sur lesquels une implémentation
/// qui suppose les valeurs par défaut se trompe en silence.
/// </para>
/// </summary>
public sealed class SentencePieceTokenizerTests : IDisposable
{
private readonly string _dossier =
Path.Combine(Path.GetTempPath(), "omika-spm-tests", Guid.NewGuid().ToString("N"));
public void Dispose()
{
try
{
if (Directory.Exists(_dossier))
{
Directory.Delete(_dossier, recursive: true);
}
}
catch (IOException)
{
// Best-effort : un fichier encore verrouillé ne doit pas faire échouer la suite.
}
}
// ===== Réglages de normalisation lus dans le fichier =====
[Fact]
public void PrefixeFactice_Actif_AjouteLeMarqueurDEspaceInitial()
{
VocabulaireTest vocabulaire = Standard(prefixeFactice: true);
SentencePieceTokenizer tokenizer = Charger(vocabulaire);
// « Bonjour » sans préfixe ne correspond à AUCUNE pièce : la seule pièce commençant ainsi est
// « ▁Bonjour ». Le découpage obtenu prouve donc que le préfixe a bien été ajouté.
Assert.Equal<int>([vocabulaire.Id("▁Bonjour")], Encoder(tokenizer, "Bonjour"));
}
[Fact]
public void PrefixeFactice_Inactif_NajouteRien()
{
VocabulaireTest vocabulaire = Standard(prefixeFactice: false);
SentencePieceTokenizer tokenizer = Charger(vocabulaire, prefixeFactice: false);
// Le vocabulaire contient la pièce NUE « Bonjour ». Si le préfixe était ajouté d'office, la
// chaîne normalisée serait « ▁Bonjour » et cette pièce ne serait jamais atteinte.
Assert.Equal<int>([vocabulaire.Id("Bonjour")], Encoder(tokenizer, "Bonjour"));
}
[Fact]
public void EspacesSuperflus_Reduits_EtEspacesDeBordSupprimes()
{
VocabulaireTest vocabulaire = Standard(prefixeFactice: true);
SentencePieceTokenizer tokenizer = Charger(vocabulaire);
// Deux espaces entre les mots, un espace de tête et un de queue. Après réduction, la chaîne
// normalisée est exactement « ▁Bonjour▁le▁monde ». Sans réduction, le double espace produirait un
// marqueur ▁ de plus, qu'aucune pièce ne couvre.
Assert.Equal(
[vocabulaire.Id("▁Bonjour"), vocabulaire.Id("▁le"), vocabulaire.Id("▁monde")],
Encoder(tokenizer, " Bonjour le monde "));
}
[Fact]
public void EspacesNonEchappes_LesEspacesRestentLitteraux()
{
// Modèle dont les pièces contiennent de VRAIS espaces : si le tokeniseur remplaçait les espaces par
// ▁ sans tenir compte du réglage, aucune pièce ne serait trouvée.
var vocabulaire = new VocabulaireTest(
["<unk>", "<s>", "</s>", " Bonjour", " le", " monde"],
[TypeInconnu, TypeControle, TypeControle, TypeNormal, TypeNormal, TypeNormal]);
byte[] modele = vocabulaire.Modele(
NormalizerSpec(prefixeFactice: true, espacesSuperflus: true, echapperEspaces: false));
SentencePieceTokenizer tokenizer = Charger(modele);
IReadOnlyList<int> ids = Encoder(tokenizer, "Bonjour le monde");
Assert.Equal<int>([3, 4, 5], ids.ToArray());
Assert.Equal("Bonjour le monde", tokenizer.Decode(ids));
}
// ===== Repli octet =====
[Fact]
public void RepliOctet_Disponible_PreserveUnCaractereHorsVocabulaire()
{
VocabulaireTest vocabulaire = Standard(prefixeFactice: true, avecOctets: true);
SentencePieceTokenizer tokenizer = Charger(vocabulaire);
Assert.True(tokenizer.RepliOctetDisponible);
// « ñ » (U+00F1) n'est dans aucune pièce, mais ses deux octets UTF-8 (0xC3 0xB1) ont une pièce :
// le caractère doit survivre au lieu de devenir <unk>. Le marqueur ▁ initial vient du préfixe
// factice ajouté par la normalisation, pas du caractère.
IReadOnlyList<int> ids = Encoder(tokenizer, "ñ");
Assert.Equal<int>(
[vocabulaire.Id("▁"), vocabulaire.Id("<0xC3>"), vocabulaire.Id("<0xB1>")],
ids.ToArray());
Assert.Equal("ñ", tokenizer.Decode(ids));
}
[Fact]
public void RepliOctet_Absent_ProduitUnJetonInconnuParPointDeCode()
{
VocabulaireTest vocabulaire = Standard(prefixeFactice: true, avecOctets: false);
SentencePieceTokenizer tokenizer = Charger(vocabulaire);
Assert.False(tokenizer.RepliOctetDisponible);
Assert.Equal<int>(
[vocabulaire.Id("▁"), tokenizer.IdTokenInconnu],
Encoder(tokenizer, "ñ").ToArray());
}
[Fact]
public void RepliOctet_Incomplet_EstDesactive()
{
// Vocabulaire qui ne déclare QUE quelques pièces d'octet : découper un caractère en octets
// produirait une séquence tronquée, donc un texte corrompu au décodage. Le repli doit être refusé
// dans son ensemble plutôt qu'appliqué partiellement.
VocabulaireTest vocabulaire = Standard(
prefixeFactice: true, avecOctets: true, octetsComplets: false);
SentencePieceTokenizer tokenizer = Charger(vocabulaire);
Assert.False(tokenizer.RepliOctetDisponible);
Assert.Equal<int>(
[vocabulaire.Id("▁"), tokenizer.IdTokenInconnu],
Encoder(tokenizer, "ñ").ToArray());
}
[Fact]
public void RepliOctet_UnPointDeCodeSurrogate_EstReconstitueEntierement()
{
VocabulaireTest vocabulaire = Standard(prefixeFactice: true, avecOctets: true);
SentencePieceTokenizer tokenizer = Charger(vocabulaire);
// « 😀 » (U+1F600) occupe DEUX unités UTF-16 et QUATRE octets UTF-8. Le décodage doit restituer le
// caractère complet, jamais deux demi-caractères.
const string emoji = "😀";
IReadOnlyList<int> ids = Encoder(tokenizer, emoji);
Assert.Equal<int>(
[
vocabulaire.Id("▁"),
vocabulaire.Id("<0xF0>"), vocabulaire.Id("<0x9F>"),
vocabulaire.Id("<0x98>"), vocabulaire.Id("<0x80>")
],
ids.ToArray());
Assert.Equal(emoji, tokenizer.Decode(ids));
}
[Fact]
public void RepliOctet_NeCassePasLeDecodageDesPiecesNormales()
{
SentencePieceTokenizer tokenizer = Charger(Standard(prefixeFactice: true, avecOctets: true));
// Alternance pièce normale / octets : la reconstitution d'octets doit être déclenchée par un
// changement de nature de pièce, et non se poursuivre sur la pièce suivante.
IReadOnlyList<int> ids = Encoder(tokenizer, "Bonjour ñ monde");
Assert.Equal("Bonjour ñ monde", tokenizer.Decode(ids));
}
[Fact]
public void AllerRetour_EstIdempotent_SurDuTexteCouvert()
{
SentencePieceTokenizer tokenizer = Charger(Standard(prefixeFactice: true, avecOctets: true));
foreach (string texte in new[] { "Bonjour", "Bonjour le monde!", " Bonjour le monde " })
{
string decode = tokenizer.Decode(Encoder(tokenizer, texte));
// L'égalité au texte d'origine n'est pas exigée (elle dépend du vocabulaire du modèle) : ce qui
// est exigé, c'est qu'un second décodage ne change plus rien — signe qu'aucun octet ni pièce
// n'est perdu ni dupliqué à la reconstitution.
Assert.Equal(decode, tokenizer.Decode(Encoder(tokenizer, decode)));
}
}
// ===== Diagnostic =====
[Fact]
public void Diagnostic_SignaleLaTableDeNormalisationNonAppliquee()
{
var vocabulaire = new VocabulaireTest(["<unk>", "▁Bonjour"], [TypeInconnu, TypeNormal]);
byte[] modele = vocabulaire.Modele(
NormalizerSpec(prefixeFactice: true, espacesSuperflus: true, echapperEspaces: true,
charsmapPresent: true));
SentencePieceTokenizer tokenizer = Charger(modele);
// L'approximation est SIGNALÉE et non silencieuse : c'est la différence entre une limite connue et
// un bug latent.
Assert.Contains(
"precompiled_charsmap",
string.Join(" ", tokenizer.Avertissements),
StringComparison.Ordinal);
Assert.Contains("table compilée NON appliquée", tokenizer.ProfilNormalisation, StringComparison.Ordinal);
}
// ===== Fichiers en erreur =====
[Fact]
public void FichierIllisible_LeveUneErreurExploitable()
{
Directory.CreateDirectory(_dossier);
string chemin = Path.Combine(_dossier, "corrompu.spm");
File.WriteAllBytes(chemin, [0x01, 0x02, 0x03, 0x04, 0x05]);
SentencePieceException erreur = Assert.Throws<SentencePieceException>(
() => SentencePieceTokenizer.Charger(chemin));
// Le message remonte jusqu'à l'utilisateur : il ne doit contenir que le NOM du fichier, jamais le
// chemin complet de la sandbox.
Assert.Contains("corrompu.spm", erreur.Message, StringComparison.Ordinal);
Assert.DoesNotContain(_dossier, erreur.Message, StringComparison.Ordinal);
}
[Fact]
public void FichierAbsent_LeveUneErreurExploitable()
{
SentencePieceException erreur = Assert.Throws<SentencePieceException>(
() => SentencePieceTokenizer.Charger(Path.Combine(_dossier, "absent.spm")));
Assert.Contains("absent.spm", erreur.Message, StringComparison.Ordinal);
}
// ===== Fabrique de modèles protobuf =====
private const int TypeNormal = 1;
private const int TypeInconnu = 2;
private const int TypeControle = 3;
private const int TypeOctet = 6;
/// <summary>Vocabulaire de test standard : quelques pièces françaises, avec ou sans pièces d'octet.</summary>
private static VocabulaireTest Standard(
bool prefixeFactice, bool avecOctets = false, bool octetsComplets = true)
{
var noms = new List<string>
{
"<unk>", "<s>", "</s>", "<pad>", "▁", "▁Bonjour", "▁le", "▁monde", "!", "Bonjour"
};
var types = new List<int>
{
TypeInconnu, TypeControle, TypeControle, TypeControle,
TypeNormal, TypeNormal, TypeNormal, TypeNormal, TypeNormal, TypeNormal
};
if (avecOctets)
{
// Le repli octet exige les 256 valeurs. Un vocabulaire volontairement incomplet permet de
// vérifier que le repli est alors REFUSÉ plutôt qu'appliqué de façon tronquée.
int nombre = octetsComplets ? 256 : 8;
for (int valeur = 0; valeur < nombre; valeur++)
{
noms.Add($"<0x{valeur:X2}>");
types.Add(TypeOctet);
}
}
return new VocabulaireTest(noms, types);
}
/// <summary>Construit, écrit et charge un modèle de test.</summary>
private SentencePieceTokenizer Charger(VocabulaireTest vocabulaire, bool prefixeFactice = true)
=> Charger(vocabulaire.Modele(
NormalizerSpec(prefixeFactice, espacesSuperflus: true, echapperEspaces: true)));
private SentencePieceTokenizer Charger(byte[] modele)
{
Directory.CreateDirectory(_dossier);
string chemin = Path.Combine(_dossier, $"modele-{Guid.NewGuid():N}.spm");
File.WriteAllBytes(chemin, modele);
return SentencePieceTokenizer.Charger(chemin);
}
private static IReadOnlyList<int> Encoder(SentencePieceTokenizer tokenizer, string texte)
=> tokenizer.EncodeAsync(texte).GetAwaiter().GetResult();
/// <summary>
/// Vocabulaire de test : mémorise l'identifiant de chaque pièce par sa POSITION de déclaration. Les
/// identifiants sont ainsi connus des tests sans avoir à décoder le vocabulaire (un décodage ne
/// permettrait pas de retrouver une pièce d'octet, qui n'est pas du texte).
/// </summary>
private sealed class VocabulaireTest
{
private readonly List<string> _noms;
private readonly List<int> _types;
private readonly Dictionary<string, int> _ids;
public VocabulaireTest(IEnumerable<string> noms, IEnumerable<int> types)
{
_noms = [.. noms];
_types = [.. types];
Assert.Equal(_noms.Count, _types.Count);
_ids = new Dictionary<string, int>(StringComparer.Ordinal);
for (int i = 0; i < _noms.Count; i++)
{
_ids.TryAdd(_noms[i], i);
}
}
/// <summary>Identifiant d'une pièce, avec un échec explicite si elle n'est pas déclarée.</summary>
public int Id(string piece) => _ids.TryGetValue(piece, out int id)
? id
: throw new InvalidOperationException($"La pièce « {piece} » n'est pas dans le vocabulaire de test.");
/// <summary>Sérialise le <c>ModelProto</c> correspondant.</summary>
public byte[] Modele(byte[] normalizerSpec)
{
var champs = new List<byte[]>();
for (int i = 0; i < _noms.Count; i++)
{
// Score décroissant avec la position : un score distinct par pièce évite les égalités qui
// rendraient le découpage ambigu et le test instable.
champs.Add(ChampMessage(1, Piece(_noms[i], -1f - i, _types[i])));
}
// trainer_spec.model_type = 1 (UNIGRAM)
champs.Add(ChampMessage(2, ChampVarint(3, 1)));
champs.Add(ChampMessage(3, normalizerSpec));
return Concat(champs.ToArray());
}
}
// ===== Écriture protobuf minimale =====
private static byte[] NormalizerSpec(
bool prefixeFactice,
bool espacesSuperflus,
bool echapperEspaces,
bool charsmapPresent = false)
{
var champs = new List<byte[]> { ChampChaine(1, "nmt_nfkc") };
if (charsmapPresent)
{
// Contenu de la table sans importance : seule sa PRÉSENCE est exploitée.
champs.Add(ChampMessage(2, [0x00, 0x01, 0x02]));
}
champs.Add(ChampVarint(3, prefixeFactice ? 1 : 0));
champs.Add(ChampVarint(4, espacesSuperflus ? 1 : 0));
champs.Add(ChampVarint(5, echapperEspaces ? 1 : 0));
return Concat(champs.ToArray());
}
private static byte[] Piece(string texte, float score, int type)
=> Concat(ChampChaine(1, texte), ChampFlottant32(2, score), ChampVarint(3, type));
private static byte[] ChampMessage(int numero, byte[] contenu)
=> Concat(Cle(numero, 2), Varint(contenu.Length), contenu);
private static byte[] ChampChaine(int numero, string texte)
{
byte[] contenu = Encoding.UTF8.GetBytes(texte);
return Concat(Cle(numero, 2), Varint(contenu.Length), contenu);
}
private static byte[] ChampVarint(int numero, long valeur)
=> Concat(Cle(numero, 0), Varint(valeur));
private static byte[] ChampFlottant32(int numero, float valeur)
{
// protobuf encode les fixed32 en petit-boutiste quelle que soit la plateforme : l'écriture se fait
// octet par octet pour ne pas dépendre de l'ordre des octets de la machine de test.
uint brut = BitConverter.SingleToUInt32Bits(valeur);
byte[] contenu = [(byte)brut, (byte)(brut >> 8), (byte)(brut >> 16), (byte)(brut >> 24)];
return Concat(Cle(numero, 5), contenu);
}
private static byte[] Cle(int numero, int fil) => Varint(((long)numero << 3) | (uint)fil);
private static byte[] Varint(long valeur)
{
var octets = new List<byte>();
ulong reste = (ulong)valeur;
while (reste >= 0x80)
{
octets.Add((byte)(reste | 0x80));
reste >>= 7;
}
octets.Add((byte)reste);
return octets.ToArray();
}
private static byte[] Concat(params byte[][] blocs)
{
var resultat = new List<byte>();
foreach (byte[] bloc in blocs)
{
resultat.AddRange(bloc);
}
return resultat.ToArray();
}
}