From ccaa486619907d4035d0cb998fabcb1c8625b20c Mon Sep 17 00:00:00 2001 From: Gustavo Hidalgo Date: Sun, 14 Jun 2026 21:56:36 -0400 Subject: [PATCH] Move recommendation algorithm into the application layer The recommendation algorithm previously lived entirely in a single PostgreSQL function. This moves scoring, the overlap gate, weighting, and ranking into a new C# RecommendationService, leaving the database to expose only data primitives (candidate ingredient sets, favorites, cook dates, summaries) via migration 018. The pure ScoreCandidates method has no DB dependency, so the policy is directly unit-testable. Also adjusts the algorithm itself: - Remove the ownership signal entirely. - Require >=2 shared ingredients before a recipe is recommended (a hard gate for everyone; favorite/novelty only re-rank within that set). - Renormalize weights to sum to 1.0: similarity 0.70, favorite 0.18, novelty 0.12. Drops ownedByUser from the DTO, TS type, and admin score breakdown. Adds pure unit tests for the scoring policy and updates the integration tests for the new weights and gate. Co-Authored-By: Claude Sonnet 4.6 --- ...-cook-history-and-recommendations.markdown | 15 +- .../Contracts/RecipeRecommendationDto.cs | 15 +- src/CookTime/Program.cs | 31 ++- .../Scripts/018_recommendation_primitives.sql | 86 ++++++++ src/CookTime/Services/CookTimeDB.cs | 95 +++++++-- .../Services/RecommendationService.cs | 183 ++++++++++++++++++ .../components/Recipe/RecommendedRecipes.tsx | 1 - .../src/shared/CookTime/CookTime.types.ts | 1 - .../TestCookHistoryAndRecommendations.cs | 38 ++-- .../TestRecommendationService.cs | 117 +++++++++++ 10 files changed, 525 insertions(+), 57 deletions(-) create mode 100644 src/CookTime/Scripts/018_recommendation_primitives.sql create mode 100644 src/CookTime/Services/RecommendationService.cs create mode 100644 src/CookTimeTests/TestRecommendationService.cs diff --git a/src/CookTime/Blog/_posts/2026-06-14-cook-history-and-recommendations.markdown b/src/CookTime/Blog/_posts/2026-06-14-cook-history-and-recommendations.markdown index 44c39b9..ed4db74 100644 --- a/src/CookTime/Blog/_posts/2026-06-14-cook-history-and-recommendations.markdown +++ b/src/CookTime/Blog/_posts/2026-06-14-cook-history-and-recommendations.markdown @@ -32,23 +32,22 @@ We use [Jaccard similarity](https://en.wikipedia.org/wiki/Jaccard_index) over th score = |shared ingredients| / |union of all ingredients| ``` -A recipe with no overlapping ingredients scores zero and is never shown. +A recipe has to share **at least two** ingredients with the recipe you are looking at before it can be recommended at all. A single shared ingredient (everything uses salt) is not a real signal, so we gate it out. -For signed-in users we add three more signals on top of the similarity score: +For signed-in users we add two more signals on top of the similarity score: | Signal | Weight | Meaning | |---|---|---| -| Ingredient similarity | 60% | Jaccard similarity | -| Owned by you | 15% | You created the recipe | -| In your Favorites list | 15% | You have favorited it | -| Novelty | 10% | Not cooked in the last 7 days | +| Ingredient similarity | 70% | Jaccard similarity | +| In your Favorites list | 18% | You have favorited it | +| Novelty | 12% | Not cooked in the last 7 days | -All of this runs as a single PostgreSQL function using CTEs, so there is no round-tripping between the API and the database to build the ranked list. +The database stays out of the policy business. It exposes a few primitives — candidate recipes that share an ingredient with the source (and their ingredient sets), your favorites, and your cook dates — and the application does the scoring, gating, and ranking. Indexes and set lookups are what databases are great at; the recommendation *algorithm* is something we would rather keep in code where it is easy to read, unit-test, and change without a migration. ## Score breakdowns for admins If you have an Administrator account, each recommendation card shows the total score and an expandable breakdown of exactly how much each signal contributed. -This made it a lot easier to tune weights during development — we could see at a glance whether a card ranked highly because of ingredient overlap or because we happened to own the recipe. +This made it a lot easier to tune weights during development — we could see at a glance whether a card ranked highly because of ingredient overlap or because it was a favorite we had not cooked in a while. ## What we ruled out diff --git a/src/CookTime/Models/Contracts/RecipeRecommendationDto.cs b/src/CookTime/Models/Contracts/RecipeRecommendationDto.cs index e50ba98..7e03d85 100644 --- a/src/CookTime/Models/Contracts/RecipeRecommendationDto.cs +++ b/src/CookTime/Models/Contracts/RecipeRecommendationDto.cs @@ -33,9 +33,6 @@ public class RecommendationScoreBreakdownDto [JsonPropertyName("ingredientSimilarity")] public double IngredientSimilarity { get; set; } - [JsonPropertyName("ownedByUser")] - public double OwnedByUser { get; set; } - [JsonPropertyName("favoritedByUser")] public double FavoritedByUser { get; set; } @@ -60,3 +57,15 @@ public class RecipeRecommendationDto [JsonPropertyName("reasons")] public List Reasons { get; set; } = new(); } + +// A candidate recipe and its set of ingredient IDs, as returned by the +// cooktime.get_candidate_ingredient_sets primitive. The recommendation +// algorithm consumes these to compute ingredient similarity. +public class CandidateIngredientSet +{ + [JsonPropertyName("recipeId")] + public Guid RecipeId { get; set; } + + [JsonPropertyName("ingredientIds")] + public List IngredientIds { get; set; } = new(); +} diff --git a/src/CookTime/Program.cs b/src/CookTime/Program.cs index 1ad9ed3..d8e72cd 100644 --- a/src/CookTime/Program.cs +++ b/src/CookTime/Program.cs @@ -25,6 +25,7 @@ var connectionString = builder.Configuration.GetConnectionString("Postgres")!; builder.Services.AddSingleton(NpgsqlDataSource.Create(connectionString)); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddMemoryCache(); @@ -196,24 +197,18 @@ { return await cooktime.GetFeaturedRecipesAsync(); }); -app.MapGet("/api/multipartrecipe/{id:guid}/recommendations", async (HttpContext context, CookTimeDB cooktime, Guid id, int limit = 6) => -{ - var recipe = await cooktime.GetRecipeByIdAsync(id); - if (recipe == null) - { - return Results.NotFound(); - } - - Guid? userId = null; - var userIdClaim = context.User.FindFirst("db_user_id")?.Value; - if (userIdClaim != null && Guid.TryParse(userIdClaim, out var parsedUserId)) - { - userId = parsedUserId; - } - - var recommendations = await cooktime.GetRecipeRecommendationsAsync(id, userId, Math.Clamp(limit, 1, 24)); - return Results.Ok(recommendations); -}); +app.MapGet("/api/multipartrecipe/{id:guid}/recommendations", async (HttpContext context, RecommendationService recommendations, Guid id, int limit = 6) => +{ + Guid? userId = null; + var userIdClaim = context.User.FindFirst("db_user_id")?.Value; + if (userIdClaim != null && Guid.TryParse(userIdClaim, out var parsedUserId)) + { + userId = parsedUserId; + } + + var result = await recommendations.GetRecommendationsAsync(id, userId, Math.Clamp(limit, 1, 24)); + return result == null ? Results.NotFound() : Results.Ok(result); +}); app.MapGet("/api/multipartrecipe/{id:guid}", async (CookTimeDB cooktime, NutritionService nutrition, Guid id) => { var recipe = await cooktime.GetRecipeByIdAsync(id); diff --git a/src/CookTime/Scripts/018_recommendation_primitives.sql b/src/CookTime/Scripts/018_recommendation_primitives.sql new file mode 100644 index 0000000..7281872 --- /dev/null +++ b/src/CookTime/Scripts/018_recommendation_primitives.sql @@ -0,0 +1,86 @@ +-- Migration: Recommendation primitives +-- The recommendation algorithm now lives in the application layer. The database +-- only exposes primitives (set-membership lookups backed by indexes); scoring, +-- weighting, the minimum-overlap gate, and ranking happen in C#. +-- This replaces the monolithic cooktime.recommend_recipes function. + +DROP FUNCTION IF EXISTS cooktime.recommend_recipes(uuid, uuid, integer); + +-- Candidate recipes that share at least one ingredient with the source recipe, +-- each with its full set of ingredient IDs. The application computes Jaccard +-- similarity and applies the minimum-overlap gate. +CREATE OR REPLACE FUNCTION cooktime.get_candidate_ingredient_sets(p_source_recipe_id uuid) +RETURNS SETOF jsonb AS $$ +BEGIN + RETURN QUERY + WITH source_ingredients AS ( + SELECT DISTINCT ir.ingredient_id + FROM cooktime.recipe_components rc + JOIN cooktime.ingredient_requirements ir ON ir.recipe_component_id = rc.id + WHERE rc.recipe_id = p_source_recipe_id + AND ir.ingredient_id IS NOT NULL + ), + candidate_recipes AS ( + SELECT DISTINCT r.id AS recipe_id + FROM cooktime.recipes r + JOIN cooktime.recipe_components rc ON rc.recipe_id = r.id + JOIN cooktime.ingredient_requirements ir ON ir.recipe_component_id = rc.id + WHERE r.id <> p_source_recipe_id + AND ir.ingredient_id IN (SELECT ingredient_id FROM source_ingredients) + ) + SELECT jsonb_build_object( + 'recipeId', cr.recipe_id, + 'ingredientIds', COALESCE(( + SELECT jsonb_agg(DISTINCT ir.ingredient_id) + FROM cooktime.recipe_components rc + JOIN cooktime.ingredient_requirements ir ON ir.recipe_component_id = rc.id + WHERE rc.recipe_id = cr.recipe_id + AND ir.ingredient_id IS NOT NULL + ), '[]'::jsonb) + ) + FROM candidate_recipes cr; +END; +$$ LANGUAGE plpgsql; + +-- Recipe IDs in the user's "favorites" list. +CREATE OR REPLACE FUNCTION cooktime.get_user_favorite_recipe_ids(p_user_id uuid) +RETURNS jsonb AS $$ +BEGIN + RETURN COALESCE( + ( + SELECT jsonb_agg(rr.recipe_id) + FROM cooktime.recipe_lists rl + JOIN cooktime.recipe_requirements rr ON rr.recipe_list_id = rl.id + WHERE rl.owner_id = p_user_id + AND lower(rl.name) = 'favorites' + ), + '[]'::jsonb + ); +END; +$$ LANGUAGE plpgsql; + +-- The most recent cook date per recipe for the user (drives novelty scoring). +CREATE OR REPLACE FUNCTION cooktime.get_user_last_cooked(p_user_id uuid) +RETURNS SETOF jsonb AS $$ +BEGIN + RETURN QUERY + SELECT jsonb_build_object( + 'recipeId', e.recipe_id, + 'lastCookedAt', MAX(e.cooked_at) + ) + FROM cooktime.recipe_cook_events e + WHERE e.user_id = p_user_id + GROUP BY e.recipe_id; +END; +$$ LANGUAGE plpgsql; + +-- Recipe summaries for the final ranked set of recommendation winners. +CREATE OR REPLACE FUNCTION cooktime.get_recipe_summaries(p_recipe_ids uuid[]) +RETURNS SETOF jsonb AS $$ +BEGIN + RETURN QUERY + SELECT cooktime.recipe_to_summary(r) + FROM cooktime.recipes r + WHERE r.id = ANY(p_recipe_ids); +END; +$$ LANGUAGE plpgsql; diff --git a/src/CookTime/Services/CookTimeDB.cs b/src/CookTime/Services/CookTimeDB.cs index 570aa4f..f023734 100644 --- a/src/CookTime/Services/CookTimeDB.cs +++ b/src/CookTime/Services/CookTimeDB.cs @@ -295,35 +295,106 @@ public async Task DeleteCookHistoryEventAsync(Guid userId, Guid eventId) return (bool)result!; } - public async Task> GetRecipeRecommendationsAsync(Guid recipeId, Guid? userId = null, int limit = 6) + // Recommendation primitives. The recommendation algorithm itself lives in + // RecommendationService; these methods only fetch the data it needs. + + public async Task> GetCandidateIngredientSetsAsync(Guid sourceRecipeId) { await using var conn = await dataSource.OpenConnectionAsync(); - await using var cmd = new NpgsqlCommand("SELECT cooktime.recommend_recipes($1, $2, $3)", conn); + await using var cmd = new NpgsqlCommand("SELECT cooktime.get_candidate_ingredient_sets($1)", conn); - cmd.Parameters.AddWithValue(recipeId); - cmd.Parameters.Add(new NpgsqlParameter + cmd.Parameters.AddWithValue(sourceRecipeId); + + var results = new List(); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) { - NpgsqlDbType = NpgsqlDbType.Uuid, - Value = userId.HasValue ? (object)userId.Value : DBNull.Value - }); - cmd.Parameters.AddWithValue(limit); + var json = reader.GetString(0); + var candidate = JsonSerializer.Deserialize(json, JsonOptions); + if (candidate != null) + { + results.Add(candidate); + } + } + + return results; + } + + public async Task> GetFavoriteRecipeIdsAsync(Guid userId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var cmd = new NpgsqlCommand("SELECT cooktime.get_user_favorite_recipe_ids($1)", conn); + + cmd.Parameters.AddWithValue(userId); + + var result = await cmd.ExecuteScalarAsync(); + if (result == null || result == DBNull.Value) + { + return []; + } + + var ids = JsonSerializer.Deserialize>(result.ToString()!, JsonOptions); + return ids == null ? [] : [.. ids]; + } + + public async Task> GetLastCookedDatesAsync(Guid userId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var cmd = new NpgsqlCommand("SELECT cooktime.get_user_last_cooked($1)", conn); + + cmd.Parameters.AddWithValue(userId); - var results = new List(); + var results = new Dictionary(); await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var json = reader.GetString(0); + var entry = JsonSerializer.Deserialize(json, JsonOptions); + if (entry != null) + { + results[entry.RecipeId] = entry.LastCookedAt; + } + } + + return results; + } + + public async Task> GetRecipeSummariesAsync(IReadOnlyCollection recipeIds) + { + if (recipeIds.Count == 0) + { + return []; + } + + await using var conn = await dataSource.OpenConnectionAsync(); + await using var cmd = new NpgsqlCommand("SELECT cooktime.get_recipe_summaries($1)", conn); + + cmd.Parameters.AddWithValue(recipeIds.ToArray()); + var results = new Dictionary(); + await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { var json = reader.GetString(0); - var dto = JsonSerializer.Deserialize(json, JsonOptions); - if (dto != null) + var summary = JsonSerializer.Deserialize(json, JsonOptions); + if (summary != null) { - results.Add(dto); + results[summary.Id] = summary; } } return results; } + private sealed class LastCookedEntry + { + [JsonPropertyName("recipeId")] + public Guid RecipeId { get; set; } + + [JsonPropertyName("lastCookedAt")] + public DateOnly LastCookedAt { get; set; } + } + private static async Task> ReadRecipeSummaryListAsync(NpgsqlCommand cmd) { diff --git a/src/CookTime/Services/RecommendationService.cs b/src/CookTime/Services/RecommendationService.cs new file mode 100644 index 0000000..234148a --- /dev/null +++ b/src/CookTime/Services/RecommendationService.cs @@ -0,0 +1,183 @@ +using CookTime.Models.Contracts; + +namespace CookTime.Services; + +// Owns the recipe recommendation algorithm. The database supplies primitives +// (candidate ingredient sets, favorites, cook history, summaries) via CookTimeDB; +// all scoring, the minimum-overlap gate, weighting, and ranking happen here so the +// policy is easy to read, unit-test, and change without a migration. +public class RecommendationService(CookTimeDB db) +{ + // A recipe must share at least this many ingredients with the source to be + // recommended at all. Favorite and novelty only re-rank within that set. + public const int MinSharedIngredients = 2; + + // Weights sum to 1.0. Ingredient similarity dominates; favorite and novelty + // nudge the ranking for signed-in users. + public const double SimilarityWeight = 0.70; + public const double FavoriteWeight = 0.18; + public const double NoveltyWeight = 0.12; + + public async Task?> GetRecommendationsAsync(Guid sourceRecipeId, Guid? userId, int limit) + { + var source = await db.GetRecipeByIdAsync(sourceRecipeId); + if (source == null) + { + return null; + } + + var sourceIngredients = source.RecipeComponents + .SelectMany(c => c.Ingredients) + .Select(i => i.Ingredient?.Id) + .Where(id => id.HasValue && id.Value != Guid.Empty) + .Select(id => id!.Value) + .ToHashSet(); + + var candidates = await db.GetCandidateIngredientSetsAsync(sourceRecipeId); + + HashSet favorites = []; + Dictionary lastCooked = []; + if (userId.HasValue) + { + favorites = await db.GetFavoriteRecipeIdsAsync(userId.Value); + lastCooked = await db.GetLastCookedDatesAsync(userId.Value); + } + + var ranked = ScoreCandidates( + sourceIngredients, + candidates, + favorites, + lastCooked, + personalized: userId.HasValue, + today: DateOnly.FromDateTime(DateTime.UtcNow.Date), + limit: limit); + + if (ranked.Count == 0) + { + return []; + } + + var summaries = await db.GetRecipeSummariesAsync(ranked.Select(r => r.RecipeId).ToList()); + + var recommendations = new List(); + foreach (var scored in ranked) + { + if (!summaries.TryGetValue(scored.RecipeId, out var summary)) + { + continue; + } + + recommendations.Add(new RecipeRecommendationDto + { + Recipe = summary, + Score = scored.Score, + ScoreBreakdown = scored.Breakdown, + Reasons = scored.Reasons + }); + } + + return recommendations; + } + + // Pure scoring: no database access, so the recommendation policy is directly + // unit-testable. Applies the minimum-overlap gate, computes Jaccard similarity, + // layers favorite/novelty boosts for signed-in users, and ranks. + public static List ScoreCandidates( + IReadOnlySet sourceIngredients, + IReadOnlyList candidates, + IReadOnlySet favoriteRecipeIds, + IReadOnlyDictionary lastCookedByRecipe, + bool personalized, + DateOnly today, + int limit) + { + var scored = new List(); + + foreach (var candidate in candidates) + { + var candidateIngredients = candidate.IngredientIds.ToHashSet(); + var intersection = candidateIngredients.Count(sourceIngredients.Contains); + + // The hard gate: too little ingredient overlap, never recommend. + if (intersection < MinSharedIngredients) + { + continue; + } + + var union = sourceIngredients.Count + candidateIngredients.Count - intersection; + var jaccard = union == 0 ? 0.0 : (double)intersection / union; + + var reasons = new List { "Similar ingredients" }; + + double favorite = 0.0; + double novelty = 0.0; + if (personalized) + { + favorite = favoriteRecipeIds.Contains(candidate.RecipeId) ? 1.0 : 0.0; + novelty = NoveltyScore(candidate.RecipeId, lastCookedByRecipe, today); + + if (favorite > 0) + { + reasons.Add("Favorite"); + } + if (novelty > 0) + { + reasons.Add("Not cooked recently"); + } + } + + var similarityContribution = jaccard * SimilarityWeight; + var favoriteContribution = favorite * FavoriteWeight; + var noveltyContribution = novelty * NoveltyWeight; + var total = personalized + ? similarityContribution + favoriteContribution + noveltyContribution + : jaccard; + + scored.Add(new ScoredRecipe( + candidate.RecipeId, + total, + intersection, + new RecommendationScoreBreakdownDto + { + IngredientSimilarity = personalized ? similarityContribution : jaccard, + FavoritedByUser = favoriteContribution, + Novelty = noveltyContribution, + DietMatch = 0 + }, + reasons)); + } + + return scored + .OrderByDescending(s => s.Score) + .ThenByDescending(s => s.SharedIngredients) + .ThenBy(s => s.RecipeId) + .Take(Math.Max(0, limit)) + .ToList(); + } + + private static double NoveltyScore(Guid recipeId, IReadOnlyDictionary lastCookedByRecipe, DateOnly today) + { + if (!lastCookedByRecipe.TryGetValue(recipeId, out var lastCooked)) + { + return 1.0; + } + + var daysSince = today.DayNumber - lastCooked.DayNumber; + if (daysSince <= 7) + { + return 0.0; + } + if (daysSince <= 30) + { + return 0.5; + } + return 1.0; + } + + public sealed record ScoredRecipe( + Guid RecipeId, + double Score, + int SharedIngredients, + RecommendationScoreBreakdownDto Breakdown, + List Reasons); +} diff --git a/src/CookTime/client-app/src/components/Recipe/RecommendedRecipes.tsx b/src/CookTime/client-app/src/components/Recipe/RecommendedRecipes.tsx index 63229d5..acd0cf3 100644 --- a/src/CookTime/client-app/src/components/Recipe/RecommendedRecipes.tsx +++ b/src/CookTime/client-app/src/components/Recipe/RecommendedRecipes.tsx @@ -12,7 +12,6 @@ function formatScore(score: number) { function ScoreBreakdown({ scoreBreakdown }: { scoreBreakdown: RecommendationScoreBreakdown }) { const rows: Array<[string, number]> = [ ['Ingredient similarity', scoreBreakdown.ingredientSimilarity], - ['Your recipe', scoreBreakdown.ownedByUser], ['Favorite', scoreBreakdown.favoritedByUser], ['Novelty', scoreBreakdown.novelty], ['Diet match', scoreBreakdown.dietMatch], diff --git a/src/CookTime/client-app/src/shared/CookTime/CookTime.types.ts b/src/CookTime/client-app/src/shared/CookTime/CookTime.types.ts index 4726221..3252f5d 100644 --- a/src/CookTime/client-app/src/shared/CookTime/CookTime.types.ts +++ b/src/CookTime/client-app/src/shared/CookTime/CookTime.types.ts @@ -224,7 +224,6 @@ export type CookHistoryEventWithRecipe = CookHistoryEvent & { export type RecommendationScoreBreakdown = { ingredientSimilarity: number, - ownedByUser: number, favoritedByUser: number, novelty: number, dietMatch: number diff --git a/src/CookTimeTests/TestCookHistoryAndRecommendations.cs b/src/CookTimeTests/TestCookHistoryAndRecommendations.cs index a4fcdc6..091bc58 100644 --- a/src/CookTimeTests/TestCookHistoryAndRecommendations.cs +++ b/src/CookTimeTests/TestCookHistoryAndRecommendations.cs @@ -1,4 +1,5 @@ using CookTime.Models.Contracts; +using CookTime.Services; using Npgsql; namespace CookTime.Test; @@ -82,30 +83,38 @@ public async Task CookHistory_DeleteRecipeCascadesCookEvents() } [TestMethod] - public async Task Recommendations_ExcludeSourceAndPreferIngredientOverlap() + public async Task Recommendations_ExcludeSourceAndRequireMinimumOverlap() { - var sharedIngredientId = await CreateIngredient("Shared Recommendation Ingredient"); + var sharedA = await CreateIngredient("Shared Recommendation Ingredient A"); + var sharedB = await CreateIngredient("Shared Recommendation Ingredient B"); var unrelatedIngredientId = await CreateIngredient("Unrelated Recommendation Ingredient"); - var sourceRecipeId = await CreateRecipe("Source Recipe", TestUserId, sharedIngredientId); - var similarRecipeId = await CreateRecipe("Similar Recipe", TestUserId, sharedIngredientId); + var sourceRecipeId = await CreateRecipe("Source Recipe", TestUserId, sharedA, sharedB); + // Two shared ingredients clears the minimum-overlap gate. + var similarRecipeId = await CreateRecipe("Similar Recipe", TestUserId, sharedA, sharedB); + // Only one shared ingredient: below the gate, must not be recommended. + var oneOverlapRecipeId = await CreateRecipe("One Overlap Recipe", TestUserId, sharedA, unrelatedIngredientId); + // No shared ingredients: never recommended. var unrelatedRecipeId = await CreateRecipe("Unrelated Recipe", TestUserId, unrelatedIngredientId); - var recommendations = await Db.GetRecipeRecommendationsAsync(sourceRecipeId, userId: null, limit: 10); + var recommendations = await new RecommendationService(Db).GetRecommendationsAsync(sourceRecipeId, userId: null, limit: 10); + Assert.IsNotNull(recommendations); Assert.IsFalse(recommendations.Any(r => r.Recipe.Id == sourceRecipeId)); Assert.IsTrue(recommendations.Any(r => r.Recipe.Id == similarRecipeId)); + Assert.IsFalse(recommendations.Any(r => r.Recipe.Id == oneOverlapRecipeId)); Assert.IsFalse(recommendations.Any(r => r.Recipe.Id == unrelatedRecipeId)); Assert.AreEqual(similarRecipeId, recommendations[0].Recipe.Id); Assert.IsGreaterThan(0, recommendations[0].ScoreBreakdown.IngredientSimilarity); } [TestMethod] - public async Task Recommendations_IncludeFavoriteOwnedAndNoveltyBoosts() + public async Task Recommendations_IncludeFavoriteAndNoveltyBoosts() { - var sharedIngredientId = await CreateIngredient("Personalized Recommendation Ingredient"); - var sourceRecipeId = await CreateRecipe("Personalized Source", TestUserId, sharedIngredientId); - var neverCookedRecipeId = await CreateRecipe("Never Cooked Favorite", TestUserId, sharedIngredientId); - var recentlyCookedRecipeId = await CreateRecipe("Recently Cooked Favorite", TestUserId, sharedIngredientId); + var sharedA = await CreateIngredient("Personalized Recommendation Ingredient A"); + var sharedB = await CreateIngredient("Personalized Recommendation Ingredient B"); + var sourceRecipeId = await CreateRecipe("Personalized Source", TestUserId, sharedA, sharedB); + var neverCookedRecipeId = await CreateRecipe("Never Cooked Favorite", TestUserId, sharedA, sharedB); + var recentlyCookedRecipeId = await CreateRecipe("Recently Cooked Favorite", TestUserId, sharedA, sharedB); var favoritesListId = await Db.CreateRecipeListAsync(new RecipeListCreateDto { OwnerId = TestUserId, @@ -116,14 +125,15 @@ public async Task Recommendations_IncludeFavoriteOwnedAndNoveltyBoosts() await Db.AddRecipeToListAsync(favoritesListId, recentlyCookedRecipeId, 1); await Db.CreateCookHistoryEventAsync(TestUserId, recentlyCookedRecipeId, DateOnly.FromDateTime(DateTime.Today)); - var recommendations = await Db.GetRecipeRecommendationsAsync(sourceRecipeId, TestUserId, limit: 10); + var recommendations = await new RecommendationService(Db).GetRecommendationsAsync(sourceRecipeId, TestUserId, limit: 10); + Assert.IsNotNull(recommendations); var neverCooked = recommendations.Single(r => r.Recipe.Id == neverCookedRecipeId); var recentlyCooked = recommendations.Single(r => r.Recipe.Id == recentlyCookedRecipeId); - Assert.AreEqual(0.15, neverCooked.ScoreBreakdown.OwnedByUser, 0.001); - Assert.AreEqual(0.15, neverCooked.ScoreBreakdown.FavoritedByUser, 0.001); - Assert.AreEqual(0.10, neverCooked.ScoreBreakdown.Novelty, 0.001); + Assert.AreEqual(0.18, neverCooked.ScoreBreakdown.FavoritedByUser, 0.001); + Assert.AreEqual(0.12, neverCooked.ScoreBreakdown.Novelty, 0.001); Assert.AreEqual(0, recentlyCooked.ScoreBreakdown.Novelty, 0.001); + // The only difference is novelty, so the never-cooked recipe ranks higher. Assert.IsGreaterThan(recentlyCooked.Score, neverCooked.Score); } diff --git a/src/CookTimeTests/TestRecommendationService.cs b/src/CookTimeTests/TestRecommendationService.cs new file mode 100644 index 0000000..14e0cd3 --- /dev/null +++ b/src/CookTimeTests/TestRecommendationService.cs @@ -0,0 +1,117 @@ +using CookTime.Models.Contracts; +using CookTime.Services; + +namespace CookTime.Test; + +// Pure unit tests for the recommendation scoring policy. These need no database: +// moving the algorithm into the application is exactly what makes this possible. +[TestClass] +public class TestRecommendationService +{ + private static readonly DateOnly Today = new(2026, 6, 14); + + private static CandidateIngredientSet Candidate(Guid id, params Guid[] ingredients) => + new() { RecipeId = id, IngredientIds = [.. ingredients] }; + + [TestMethod] + public void ScoreCandidates_ExcludesRecipesBelowMinimumOverlap() + { + var a = Guid.NewGuid(); + var b = Guid.NewGuid(); + var c = Guid.NewGuid(); + var source = new HashSet { a, b, c }; + + var twoOverlap = Guid.NewGuid(); + var oneOverlap = Guid.NewGuid(); + var candidates = new List + { + Candidate(twoOverlap, a, b), // 2 shared -> included + Candidate(oneOverlap, a, Guid.NewGuid()), // 1 shared -> excluded + }; + + var result = RecommendationService.ScoreCandidates( + source, candidates, favoriteRecipeIds: new HashSet(), lastCookedByRecipe: new Dictionary(), + personalized: false, today: Today, limit: 10); + + Assert.HasCount(1, result); + Assert.AreEqual(twoOverlap, result[0].RecipeId); + } + + [TestMethod] + public void ScoreCandidates_AnonymousScoreIsRawJaccard() + { + var a = Guid.NewGuid(); + var b = Guid.NewGuid(); + var source = new HashSet { a, b }; + var candidateId = Guid.NewGuid(); + var candidates = new List { Candidate(candidateId, a, b) }; + + var result = RecommendationService.ScoreCandidates( + source, candidates, favoriteRecipeIds: new HashSet(), lastCookedByRecipe: new Dictionary(), + personalized: false, today: Today, limit: 10); + + // Identical ingredient sets -> Jaccard 1.0; no personalization weighting. + Assert.AreEqual(1.0, result[0].Score, 0.001); + Assert.AreEqual(1.0, result[0].Breakdown.IngredientSimilarity, 0.001); + Assert.AreEqual(0, result[0].Breakdown.FavoritedByUser, 0.001); + Assert.AreEqual(0, result[0].Breakdown.Novelty, 0.001); + } + + [TestMethod] + public void ScoreCandidates_PersonalizedAppliesWeightsAndRanksNoveltyHigher() + { + var a = Guid.NewGuid(); + var b = Guid.NewGuid(); + var source = new HashSet { a, b }; + + var neverCooked = Guid.NewGuid(); + var recentlyCooked = Guid.NewGuid(); + var candidates = new List + { + Candidate(neverCooked, a, b), + Candidate(recentlyCooked, a, b), + }; + + var favorites = new HashSet { neverCooked, recentlyCooked }; + var lastCooked = new Dictionary { [recentlyCooked] = Today.AddDays(-1) }; + + var result = RecommendationService.ScoreCandidates( + source, candidates, favorites, lastCooked, + personalized: true, today: Today, limit: 10); + + var never = result.Single(r => r.RecipeId == neverCooked); + var recent = result.Single(r => r.RecipeId == recentlyCooked); + + // Jaccard 1.0 * 0.70 + favorite 0.18 + novelty 0.12 = 1.0 + Assert.AreEqual(0.70, never.Breakdown.IngredientSimilarity, 0.001); + Assert.AreEqual(0.18, never.Breakdown.FavoritedByUser, 0.001); + Assert.AreEqual(0.12, never.Breakdown.Novelty, 0.001); + Assert.AreEqual(1.0, never.Score, 0.001); + + // Cooked yesterday -> novelty 0, so it ranks below the never-cooked recipe. + Assert.AreEqual(0, recent.Breakdown.Novelty, 0.001); + Assert.AreEqual(0.88, recent.Score, 0.001); + Assert.AreEqual(neverCooked, result[0].RecipeId); + } + + [TestMethod] + public void ScoreCandidates_RespectsLimit() + { + var a = Guid.NewGuid(); + var b = Guid.NewGuid(); + var c = Guid.NewGuid(); + var source = new HashSet { a, b, c }; + var candidates = new List + { + Candidate(Guid.NewGuid(), a, b, c), + Candidate(Guid.NewGuid(), a, b), + Candidate(Guid.NewGuid(), a, b), + }; + + var result = RecommendationService.ScoreCandidates( + source, candidates, favoriteRecipeIds: new HashSet(), lastCookedByRecipe: new Dictionary(), + personalized: false, today: Today, limit: 2); + + Assert.HasCount(2, result); + } +}