using Deal.Ml.Model; using Microsoft.Data.Sqlite; namespace Deal.Ml.Tests; /// /// Тесты персистентности модели (план Task 5): веса переживают перезапуск пула (второй инстанс /// на тот же SQLite-файл), журнал самооценки прунится до EVAL_KEEP=200, статус отдаёт окно /// EVAL_WINDOW=50 последних решений. /// public sealed class ModelPersistenceTests { /// /// Перезапуск пула сохраняет веса: обучение на первом пуле → закрытие → второй пул на тот же /// каталог отдаёт те же классы/learned/eval и те же предсказания. /// [Fact] public void SecondPoolOnSameFile_ReloadsWeightsAndEval() { string dataDir = NewDataDir(); try { MlStatusResult firstStatus; MlPredictResult firstPredict; using (var firstPool = new ModelPool(MlOptions.Create(dataDir))) { TenantModel model = firstPool.GetOrCreate("tenant-persist"); List items = LearningData.CanonicalTrainItems(); model.LearnBatch(items.Take(24).ToList()); model.LearnBatch(items.Skip(24).ToList()); firstStatus = model.Status(); firstPredict = model.Predict(LearningData.DevMessage); } using var secondPool = new ModelPool(MlOptions.Create(dataDir)); TenantModel reloaded = secondPool.GetOrCreate("tenant-persist"); MlStatusResult secondStatus = reloaded.Status(); MlPredictResult secondPredict = reloaded.Predict(LearningData.DevMessage); Assert.True(secondStatus.Ready); Assert.Equal(firstStatus.Learned, secondStatus.Learned); Assert.Equal(firstStatus.Classes, secondStatus.Classes); Assert.Equal(firstStatus.Eval, secondStatus.Eval); Assert.True(secondPredict.Take); Assert.Equal(firstPredict.Label, secondPredict.Label); Assert.Equal(firstPredict.Scores, secondPredict.Scores); } finally { TryDelete(dataDir); } } /// /// Окно самооценки: журнал хранит не больше EVAL_KEEP (200) строк (проверка в файле), статус /// считает последние EVAL_WINDOW (50) решений. Сценарий — 250 дополнительных «реальных» /// действий поверх канонического батча. /// [Fact] public void EvalLog_PrunesToKeepAndWindowIsFifty() { string dataDir = NewDataDir(); try { TenantModel model; using (var pool = new ModelPool(MlOptions.Create(dataDir))) { model = pool.GetOrCreate("tenant-eval-window"); List items = LearningData.CanonicalTrainItems(); model.LearnBatch(items.Take(24).ToList()); model.LearnBatch(items.Skip(24).ToList()); // 250 реальных действий (delta=1, метка совпадает с предсказанием) — самооценка пишется. model.LearnBatch( Enumerable.Repeat(LearningData.User(LearningData.ColumnDev, LearningData.DevMessage), 250).ToList()); MlStatusResult status = model.Status(); Assert.Equal(50, status.Eval.Count); // окно — последние EVAL_WINDOW Assert.Equal(50, status.Eval.Correct); Assert.Equal(1.0, status.Eval.Accuracy); Assert.Equal(200, CountEvalRows(model.DatabasePath)); // журнал прунится до EVAL_KEEP } } finally { TryDelete(dataDir); } } /// /// Прунинг переживает перезапуск: reload держит последние EVAL_KEEP и окно 50. /// [Fact] public void EvalLog_PrunePersistedAcrossPoolRestart() { string dataDir = NewDataDir(); try { using (var pool = new ModelPool(MlOptions.Create(dataDir))) { TenantModel model = pool.GetOrCreate("tenant-eval-restart"); List items = LearningData.CanonicalTrainItems(); model.LearnBatch(items.Take(24).ToList()); model.LearnBatch(items.Skip(24).ToList()); model.LearnBatch( Enumerable.Repeat(LearningData.User(LearningData.ColumnDev, LearningData.DevMessage), 250).ToList()); } using var secondPool = new ModelPool(MlOptions.Create(dataDir)); TenantModel reloaded = secondPool.GetOrCreate("tenant-eval-restart"); MlStatusResult status = reloaded.Status(); Assert.Equal(50, status.Eval.Count); Assert.Equal(1.0, status.Eval.Accuracy); Assert.Equal(200, CountEvalRows(reloaded.DatabasePath)); } finally { TryDelete(dataDir); } } // Читает число строк eval_log прямо из SQLite-файла модели. // dbPath: Путь к файлу модели. private static long CountEvalRows(string dbPath) { using var connection = new SqliteConnection($"Data Source={dbPath}"); connection.Open(); using SqliteCommand command = connection.CreateCommand(); command.CommandText = "SELECT count(*) FROM eval_log"; return (long)command.ExecuteScalar()!; } // Новая временная папка данных моделей. private static string NewDataDir() { string dataDir = Path.Combine(Path.GetTempPath(), "deal-ml-persist-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dataDir); return dataDir; } // Удаляет временную папку (мусор в temp допустим при сбое). // dataDir: Папка. private static void TryDelete(string dataDir) { try { Directory.Delete(dataDir, recursive: true); } catch (IOException) { // Занятый каталог — тестовый мусор в temp допустим. } } }