431 lines
16 KiB
C#
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();
|
|
}
|
|
}
|