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); + } +}