Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
160 lines
6.7 KiB
C#
160 lines
6.7 KiB
C#
using Deal.Ml.Model;
|
|
using Microsoft.Data.Sqlite;
|
|
|
|
namespace Deal.Ml.Tests;
|
|
|
|
/// <summary>
|
|
/// Тесты персистентности модели (план Task 5): веса переживают перезапуск пула (второй инстанс
|
|
/// на тот же SQLite-файл), журнал самооценки прунится до EVAL_KEEP=200, статус отдаёт окно
|
|
/// EVAL_WINDOW=50 последних решений.
|
|
/// </summary>
|
|
public sealed class ModelPersistenceTests
|
|
{
|
|
/// <summary>
|
|
/// Перезапуск пула сохраняет веса: обучение на первом пуле → закрытие → второй пул на тот же
|
|
/// каталог отдаёт те же классы/learned/eval и те же предсказания.
|
|
/// </summary>
|
|
[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<LearnItem> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Окно самооценки: журнал хранит не больше EVAL_KEEP (200) строк (проверка в файле), статус
|
|
/// считает последние EVAL_WINDOW (50) решений. Сценарий — 250 дополнительных «реальных»
|
|
/// действий поверх канонического батча.
|
|
/// </summary>
|
|
[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<LearnItem> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Прунинг переживает перезапуск: reload держит последние EVAL_KEEP и окно 50.
|
|
/// </summary>
|
|
[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<LearnItem> 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 допустим.
|
|
}
|
|
}
|
|
}
|