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; /// /// Tokeniseur SentencePiece : vérifie que les réglages de normalisation et le repli octet sont bien /// lus dans le fichier, et non supposés. /// /// /// Ces tests construisent un modèle SentencePiece à la main (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 atypiques — 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. /// /// 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([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([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( ["", "", "", " 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 ids = Encoder(tokenizer, "Bonjour le monde"); Assert.Equal([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 . Le marqueur ▁ initial vient du préfixe // factice ajouté par la normalisation, pas du caractère. IReadOnlyList ids = Encoder(tokenizer, "ñ"); Assert.Equal( [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( [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( [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 ids = Encoder(tokenizer, emoji); Assert.Equal( [ 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 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(["", "▁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( () => 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( () => 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; /// Vocabulaire de test standard : quelques pièces françaises, avec ou sans pièces d'octet. private static VocabulaireTest Standard( bool prefixeFactice, bool avecOctets = false, bool octetsComplets = true) { var noms = new List { "", "", "", "", "▁", "▁Bonjour", "▁le", "▁monde", "!", "Bonjour" }; var types = new List { 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); } /// Construit, écrit et charge un modèle de test. 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 Encoder(SentencePieceTokenizer tokenizer, string texte) => tokenizer.EncodeAsync(texte).GetAwaiter().GetResult(); /// /// 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). /// private sealed class VocabulaireTest { private readonly List _noms; private readonly List _types; private readonly Dictionary _ids; public VocabulaireTest(IEnumerable noms, IEnumerable types) { _noms = [.. noms]; _types = [.. types]; Assert.Equal(_noms.Count, _types.Count); _ids = new Dictionary(StringComparer.Ordinal); for (int i = 0; i < _noms.Count; i++) { _ids.TryAdd(_noms[i], i); } } /// Identifiant d'une pièce, avec un échec explicite si elle n'est pas déclarée. 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."); /// Sérialise le ModelProto correspondant. public byte[] Modele(byte[] normalizerSpec) { var champs = new List(); 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 { 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(); 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(); foreach (byte[] bloc in blocs) { resultat.AddRange(bloc); } return resultat.ToArray(); } }