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 допустим.
}
}
}