diff --git a/tests/TarninaHub.Tests/Controllers/InnovationsControllerTests.cs b/tests/TarninaHub.Tests/Controllers/InnovationsControllerTests.cs index 855201f..ad38b8a 100644 --- a/tests/TarninaHub.Tests/Controllers/InnovationsControllerTests.cs +++ b/tests/TarninaHub.Tests/Controllers/InnovationsControllerTests.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc; using TarninaHub.Application.Abstractions; using TarninaHub.Application.Dtos; using TarninaHub.Controllers; +using TarninaHub.Tests.Matchmaking; namespace TarninaHub.Tests.Controllers; @@ -11,7 +12,7 @@ public class InnovationsControllerTests { private static InnovationsController CreateController(IInnovationService service) { - var controller = new InnovationsController(service); + var controller = new InnovationsController(service, new StubMatchmakingService()); var identity = new ClaimsIdentity( [new Claim("sub", "user:tester"), new Claim("role", "admin")], authenticationType: "test"); diff --git a/tests/TarninaHub.Tests/Controllers/SubmissionsControllerTests.cs b/tests/TarninaHub.Tests/Controllers/SubmissionsControllerTests.cs index 0eac884..2e6f908 100644 --- a/tests/TarninaHub.Tests/Controllers/SubmissionsControllerTests.cs +++ b/tests/TarninaHub.Tests/Controllers/SubmissionsControllerTests.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc; using TarninaHub.Application.Abstractions; using TarninaHub.Application.Dtos; using TarninaHub.Controllers; +using TarninaHub.Tests.Matchmaking; namespace TarninaHub.Tests.Controllers; @@ -11,7 +12,7 @@ public class SubmissionsControllerTests { private static SubmissionsController CreateController(ISubmissionService service, string role = "operator") { - var controller = new SubmissionsController(service); + var controller = new SubmissionsController(service, new StubMatchmakingService()); var identity = new ClaimsIdentity( [new Claim("sub", "user:tester"), new Claim("role", role)], authenticationType: "test"); diff --git a/tests/TarninaHub.Tests/Matchmaking/EmbeddingAndRankingTests.cs b/tests/TarninaHub.Tests/Matchmaking/EmbeddingAndRankingTests.cs new file mode 100644 index 0000000..a4e2662 --- /dev/null +++ b/tests/TarninaHub.Tests/Matchmaking/EmbeddingAndRankingTests.cs @@ -0,0 +1,70 @@ +using TarninaHub.Application.Matchmaking; + +namespace TarninaHub.Tests.Matchmaking; + +public class EmbeddingAndRankingTests +{ + private static double Magnitude(float[] vector) => + Math.Sqrt(vector.Sum(value => (double)value * value)); + + [Fact] + public void HashingEmbeddingProvider_ProducesNormalizedVectorOfConfiguredDimension() + { + var provider = new HashingEmbeddingProvider(); + + var vector = provider.Embed("klub sąsiedzki dla seniorów"); + + Assert.Equal(EmbeddingConstants.Dimension, vector.Length); + Assert.Equal(1.0, Magnitude(vector), precision: 5); + } + + [Fact] + public void HashingEmbeddingProvider_IsDeterministic() + { + var provider = new HashingEmbeddingProvider(); + + var first = provider.Embed("praca wspierana niepełnosprawność"); + var second = provider.Embed("praca wspierana niepełnosprawność"); + + Assert.Equal(first, second); + } + + [Fact] + public void CosineSimilarity_IsHigherForSharedVocabulary() + { + var provider = new HashingEmbeddingProvider(); + + var query = provider.Embed("seniorzy spotkania samotność"); + var related = provider.Embed("klub sąsiedzki seniorzy spotkania samotność"); + var unrelated = provider.Embed("hostel młodzież bezdomność"); + + var relatedScore = VectorMath.CosineSimilarity(query, related); + var unrelatedScore = VectorMath.CosineSimilarity(query, unrelated); + + Assert.True(relatedScore > unrelatedScore); + } + + [Fact] + public void SemanticRanker_OrdersByScoreAndRespectsTopK() + { + var provider = new HashingEmbeddingProvider(); + var query = provider.Embed("cyfryzacja seniorzy internet"); + var candidates = new (string Id, IReadOnlyList Vector)[] + { + ("cyfryzacja", provider.Embed("cyfryzacja seniorzy internet szkolenie")), + ("praca", provider.Embed("praca wspierana doradca")), + ("hostel", provider.Embed("hostel młodzież bezdomność")), + }; + + var ranked = SemanticRanker.Rank(query, candidates, topK: 2); + + Assert.Equal(2, ranked.Count); + Assert.Equal("cyfryzacja", ranked[0].Item); + } + + [Fact] + public void CosineSimilarity_ReturnsZeroForMismatchedDimensions() + { + Assert.Equal(0, VectorMath.CosineSimilarity([1f, 0f], [1f])); + } +} diff --git a/tests/TarninaHub.Tests/Matchmaking/MatchmakingQualityTests.cs b/tests/TarninaHub.Tests/Matchmaking/MatchmakingQualityTests.cs new file mode 100644 index 0000000..4266ed8 --- /dev/null +++ b/tests/TarninaHub.Tests/Matchmaking/MatchmakingQualityTests.cs @@ -0,0 +1,73 @@ +using TarninaHub.Application.Matchmaking; + +namespace TarninaHub.Tests.Matchmaking; + +/// +/// Relevance test set for semantic matchmaking. Measures top-1 accuracy, +/// precision@3 and recall@3 over a small synthetic corpus (matches the +/// demo dataset vocabulary). +/// +public class MatchmakingQualityTests +{ + private static readonly (string Id, string Text)[] Documents = + [ + ("klub", "klub sąsiedzki seniorzy spotkania samotność izolacja"), + ("praca", "praca wspierana niepełnosprawność zatrudnienie doradca"), + ("hostel", "hostel aktywizacyjny młodzież bezdomność usamodzielnianie"), + ("psych", "wsparcie psychiczne młodzież szkoła profilaktyka"), + ("ubostwo", "ubóstwo energetyczne żywność spizarnia seniorzy"), + ("integracja", "asysta kulturowa cudzoziemcy integracja język"), + ("rodzina", "rodzina zastępcza rodzeństwo piecza wsparcie"), + ("cyfryzacja", "cyfryzacja seniorzy internet szkolenie obsługa"), + ]; + + private static readonly (string Query, string Expected)[] Queries = + [ + ("seniorzy spotkania samotność", "klub"), + ("praca niepełnosprawność doradca", "praca"), + ("hostel młodzież bezdomność", "hostel"), + ("psychiczne młodzież szkoła", "psych"), + ("ubóstwo energetyczne spizarnia", "ubostwo"), + ("asysta kulturowa integracja", "integracja"), + ("rodzina zastępcza rodzeństwo", "rodzina"), + ("cyfryzacja seniorzy internet", "cyfryzacja"), + ]; + + [Fact] + public void Matchmaking_MeetsRelevanceThresholds() + { + var provider = new HashingEmbeddingProvider(); + var vectors = Documents + .Select(document => (document.Id, Vector: (IReadOnlyList)provider.Embed(document.Text))) + .ToList(); + + var top1Correct = 0; + var recallAt3 = 0; + var precisionSum = 0.0; + + foreach (var (query, expected) in Queries) + { + var ranked = SemanticRanker.Rank(provider.Embed(query), vectors, topK: 3); + + if (ranked.Count > 0 && ranked[0].Item == expected) + { + top1Correct++; + } + + if (ranked.Any(item => item.Item == expected)) + { + recallAt3++; + } + + precisionSum += ranked.Count(item => item.Item == expected) / 3.0; + } + + var top1Accuracy = (double)top1Correct / Queries.Length; + var recall = (double)recallAt3 / Queries.Length; + var meanPrecision = precisionSum / Queries.Length; + + Assert.True(top1Accuracy >= 0.75, $"top-1 accuracy was {top1Accuracy:P0}"); + Assert.True(recall >= 0.9, $"recall@3 was {recall:P0}"); + Assert.True(meanPrecision >= 0.3, $"precision@3 was {meanPrecision:P0}"); + } +} diff --git a/tests/TarninaHub.Tests/Matchmaking/StubMatchmakingService.cs b/tests/TarninaHub.Tests/Matchmaking/StubMatchmakingService.cs new file mode 100644 index 0000000..c6ae9bc --- /dev/null +++ b/tests/TarninaHub.Tests/Matchmaking/StubMatchmakingService.cs @@ -0,0 +1,25 @@ +using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Tests.Matchmaking; + +public sealed class StubMatchmakingService : IMatchmakingService +{ + public Task IndexInnovationAsync(string innovationId, CancellationToken cancellationToken) => + Task.CompletedTask; + + public Task IndexProblemAsync(string problemId, CancellationToken cancellationToken) => + Task.CompletedTask; + + public Task IndexSubmissionAsync(string submissionId, CancellationToken cancellationToken) => + Task.CompletedTask; + + public Task ReindexAsync(CancellationToken cancellationToken) => + Task.FromResult(0); + + public Task MatchAsync( + string submissionId, + MatchRequest request, + CancellationToken cancellationToken) => + Task.FromResult(new MatchResponse([], [], true)); +}