From a406a7bb65e80201df4a3e8888fbeb9c289f6f55 Mon Sep 17 00:00:00 2001 From: Obvious Date: Fri, 16 Oct 2026 01:33:27 +0000 Subject: [PATCH 01/10] fix(ios): fail the keto diet gate closed on the catalog pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claims schema has no low-carb field, so no catalog recipe can prove keto compliance, while the legacy pool enforces the .lowCarb tag mask — without this arm a keto user loses every legacy result yet keeps every catalog result. Fails-first: testKetoDietExcludesCatalogPoolFailingClosed reproduces the ungated catalog entry before the gate arm lands. Co-authored-by: Sam Gu --- .../RecipeRepositoryCatalogRecommend.swift | 6 ++++++ .../Tests/RecipeCatalogRecommendTests.swift | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/apps/ios/Platform/Persistence/Repository/RecipeRepositoryCatalogRecommend.swift b/apps/ios/Platform/Persistence/Repository/RecipeRepositoryCatalogRecommend.swift index 2be2ed5cc..599ccfe9d 100644 --- a/apps/ios/Platform/Persistence/Repository/RecipeRepositoryCatalogRecommend.swift +++ b/apps/ios/Platform/Persistence/Repository/RecipeRepositoryCatalogRecommend.swift @@ -89,6 +89,12 @@ extension RecipeRepository { continue case "vegan" where claims?.vegan != true: continue + case "keto": + // Fail closed: the claims schema has no low-carb field, so no catalog recipe + // can prove keto compliance. The legacy pool enforces the .lowCarb tag mask, + // so without this arm a keto user loses every legacy result yet keeps every + // catalog result. Excluded until the data pipeline ships a low_carb claim. + continue default: break } diff --git a/apps/ios/Tests/RecipeCatalogRecommendTests.swift b/apps/ios/Tests/RecipeCatalogRecommendTests.swift index d6b980906..9c69d9010 100644 --- a/apps/ios/Tests/RecipeCatalogRecommendTests.swift +++ b/apps/ios/Tests/RecipeCatalogRecommendTests.swift @@ -185,6 +185,24 @@ final class RecipeCatalogRecommendTests: XCTestCase { XCTAssertNil(try repo.catalogCookPacket(catalogId: "rcp-does-not-exist")) } + // MARK: - Review round (PR #172) + + // Review finding 1: a catalog recommendation must yield cookable content through the + // repository — structured steps for the guide and its bridged ingredient list for the + // cooking guide and assistant context — instead of dead-ending on the nil-id legacy + // stand-in (empty instructions, no ingredients). + // Review finding 2: keto must fail closed on the catalog pool. The claims schema has + // no low-carb field, so no catalog recipe can prove compliance, while the legacy pool + // enforces the .lowCarb tag mask — the asymmetry would empty the legacy pool and leave + // the catalog pool fully ungated. + func testKetoDietExcludesCatalogPoolFailingClosed() throws { + let repo = try makeRepository() + let results = try repo.findMakeable(with: [1, 2], profile: profileWithDiet("keto")) + XCTAssertFalse( + results.contains { $0.catalog != nil }, + "keto must exclude the catalog pool: no low-carb claim exists to prove compliance") + } + // The bridge rebuild: legacy_id wins when the app ingredient exists; fdc_id resolves // through the USDA source row's exact name and aliases; unresolved keys stay unmapped. func testBridgeRebuildResolvesByLegacyIDThenFDCIdentity() throws { From c0a4b315d0dafb4bd6282aa1de75c1f5a62e4bb1 Mon Sep 17 00:00:00 2001 From: Obvious Date: Fri, 16 Oct 2026 01:33:28 +0000 Subject: [PATCH 02/10] fix(ios): cook catalog recommendations through the shared cook packet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalog entries materialize as nil-id legacy stand-ins, so Start Cooking rendered an empty guide, loadIngredients returned nothing, and Le Chef received zero ingredient context. Add catalogCookPacket(for:) and cookPacketIngredients(_:) on the repository — the single structured path — and route the preview drawer, the cooking guide, and the assistant context through it. Fails-first: the packet path had no repository API before this commit and testCatalogRecommendationYieldsCookPacketAndBridgedIngredients pins non-empty steps and bridged ingredient lines for a catalog result. Co-authored-by: Sam Gu --- .../ios/Feature/Recipe/CookingGuideView.swift | 20 +++++- .../Feature/Recipe/RecipePreviewDrawer.swift | 70 +++---------------- .../Feature/Results/RecipeResultsView.swift | 4 ++ .../Repository/RecipeRepositoryCatalog.swift | 67 ++++++++++++++++++ .../Tests/RecipeCatalogRecommendTests.swift | 25 +++++++ 5 files changed, 124 insertions(+), 62 deletions(-) diff --git a/apps/ios/Feature/Recipe/CookingGuideView.swift b/apps/ios/Feature/Recipe/CookingGuideView.swift index 03c40ce83..086e2a50f 100644 --- a/apps/ios/Feature/Recipe/CookingGuideView.swift +++ b/apps/ios/Feature/Recipe/CookingGuideView.swift @@ -37,6 +37,9 @@ struct CookingGuideView: View { } @State private var currentPage: Int = 0 + /// Structured guide steps for a catalog entry, loaded with the ingredients; the init-time + /// `guide` stays empty for catalog recipes because their content lives in the packet. + @State private var catalogGuide: CookingGuideSteps? @State private var ingredients: [(ingredient: Ingredient, quantity: RecipeIngredient)] = [] @State private var checkedIngredients: Set = [] @State private var completedSteps: Set = [] @@ -68,7 +71,10 @@ struct CookingGuideView: View { .sorted { $0.originalIngredientId < $1.originalIngredientId } } - private var instructionSteps: [String] { guide.steps } + private var instructionSteps: [String] { + if let catalogGuide, !catalogGuide.steps.isEmpty { return catalogGuide.steps } + return guide.steps + } private var totalPages: Int { 1 + instructionSteps.count } private var totalSteps: Int { instructionSteps.count } @@ -292,6 +298,18 @@ struct CookingGuideView: View { } private func loadIngredients() async { + // Catalog entries carry no integer recipe id; their guide steps and ingredient + // section come from the structured catalog packet — the same repository path the + // preview drawer and the assistant context use — so all surfaces agree. Falls back + // to the legacy path for legacy recipes. + if let packet = try? deps.recipeRepository.catalogCookPacket(for: scoredRecipe), + !packet.steps.isEmpty + { + catalogGuide = CookingGuideSteps( + instructions: packet.steps.map(\.text).joined(separator: "\n")) + ingredients = (try? deps.recipeRepository.cookPacketIngredients(packet)) ?? [] + return + } guard let recipeId = recipe.id else { return } ingredients = (try? dependencies.ingredientsForRecipe(recipeId)) ?? [] } diff --git a/apps/ios/Feature/Recipe/RecipePreviewDrawer.swift b/apps/ios/Feature/Recipe/RecipePreviewDrawer.swift index e1b73ebc6..66489e1c1 100644 --- a/apps/ios/Feature/Recipe/RecipePreviewDrawer.swift +++ b/apps/ios/Feature/Recipe/RecipePreviewDrawer.swift @@ -196,74 +196,22 @@ struct RecipePreviewDrawer: View { // MARK: - Data Loading private func loadIngredients() async { - // Catalog entries carry no integer recipe id; their ingredient section comes from the - // structured catalog content. Lines whose key resolved to an app ingredient (exact - // hydration-time identity) render with that ingredient; keys with no bridge row stay - // out — they are "not in your kitchen" and are never matched by name. Falls back to - // the legacy instructions path when the packet is missing or empty. - if let catalogId = scoredRecipe.catalog?.ref.id, - let packet = try? deps.recipeRepository.catalogCookPacket(catalogId: catalogId), - !packet.lines.isEmpty + // Catalog entries carry no integer recipe id; their ingredient section comes from + // the structured catalog packet — the same repository path the cooking guide and the + // assistant context use — so all three surfaces agree. Falls back to the legacy + // instructions path when the packet is missing or empty. + if let packet = try? deps.recipeRepository.catalogCookPacket(for: scoredRecipe), + let mapped = try? deps.recipeRepository.cookPacketIngredients(packet), + !mapped.isEmpty { - let bridgedIDs = Set(packet.lines.compactMap(\.bridgedIngredientId)) - let ingredientsByID: [Int64: Ingredient] = - bridgedIDs.isEmpty - ? [:] - : (try? deps.recipeRepository.ingredients(withIDs: bridgedIDs)) ?? [:] - let mapped: [(ingredient: Ingredient, quantity: RecipeIngredient)] = packet.lines - .compactMap { line in - guard let id = line.bridgedIngredientId, let ingredient = ingredientsByID[id] - else { return nil } - return ( - ingredient, - RecipeIngredient( - recipeId: recipe.id ?? 0, - ingredientId: id, - isRequired: line.required, - quantityGrams: Self.quantityGrams(line.quantity) ?? 0, - displayQuantity: Self.quantityText(line.quantity) - ) - ) - } - if !mapped.isEmpty { - ingredients = mapped - return - } + ingredients = mapped + return } guard let recipeId = recipe.id else { return } ingredients = (try? deps.recipeRepository.ingredientsForRecipe(id: recipeId)) ?? [] } - /// Mass for the substitution sheet, only when the quantity carries evidence-backed grams. - private static func quantityGrams(_ quantity: CookQuantity) -> Double? { - switch quantity { - case .grams(let grams): - return grams - case .household(_, _, let grams), .count(_, _, let grams): - return grams - case .unknown: - return nil - } - } - - /// Renders the quantity as authored: grams weighed, otherwise the household unit text, - /// otherwise the recipe's own note ("to taste"). No synthesized units. - private static func quantityText(_ quantity: CookQuantity) -> String { - switch quantity { - case .grams(let grams): - let rounded = (grams * 10).rounded() / 10 - return rounded == rounded.rounded() - ? "\(Int(rounded)) g" : String(format: "%.1f g", rounded) - case .count(let amount, let unit, _), .household(let amount, let unit, _): - let amountText = - amount == amount.rounded() ? String(Int(amount)) : String(format: "%.2g", amount) - return unit.isEmpty ? amountText : "\(amountText) \(unit)" - case .unknown(let note): - return note ?? "" - } - } - private func loadExistingPhoto() async { guard let recipeId = recipe.id else { return } if let path = try? deps.userDataRepository.latestPhotoPath(forRecipeId: recipeId) { diff --git a/apps/ios/Feature/Results/RecipeResultsView.swift b/apps/ios/Feature/Results/RecipeResultsView.swift index cdd429a3a..28a9fd19f 100644 --- a/apps/ios/Feature/Results/RecipeResultsView.swift +++ b/apps/ios/Feature/Results/RecipeResultsView.swift @@ -365,6 +365,10 @@ struct RecipeResultsView: View { let ingredients: [(ingredient: Ingredient, quantity: RecipeIngredient)] if let recipeID = scored.recipe.id { ingredients = (try? deps.recipeRepository.ingredientsForRecipe(id: recipeID)) ?? [] + } else if let packet = try? deps.recipeRepository.catalogCookPacket(for: scored) { + // Catalog entries have no legacy row; their context comes from the structured + // packet, the same path the cooking guide and preview drawer use. + ingredients = (try? deps.recipeRepository.cookPacketIngredients(packet)) ?? [] } else { ingredients = [] } diff --git a/apps/ios/Platform/Persistence/Repository/RecipeRepositoryCatalog.swift b/apps/ios/Platform/Persistence/Repository/RecipeRepositoryCatalog.swift index 176c33b31..8e8085d0b 100644 --- a/apps/ios/Platform/Persistence/Repository/RecipeRepositoryCatalog.swift +++ b/apps/ios/Platform/Persistence/Repository/RecipeRepositoryCatalog.swift @@ -319,4 +319,71 @@ extension RecipeRepository { ) } } + + /// The catalog cook packet for a recommendation drawn from the catalog pool, or nil + /// for legacy-only results and unknown refs. This is the single path the cooking guide + /// and the assistant context use to cook a catalog recommendation; legacy results keep + /// the legacy instructions path. + func catalogCookPacket(for scoredRecipe: ScoredRecipe) throws -> RecipeCatalogCookPacket? { + guard let catalogId = scoredRecipe.catalog?.ref.id else { return nil } + return try catalogCookPacket(catalogId: catalogId) + } + + /// The packet's bridged ingredient list in the legacy `(Ingredient, RecipeIngredient)` + /// shape the cooking guide and assistant context consume. Only lines whose key resolved + /// to an app ingredient at hydration appear — unresolved "not in your kitchen" keys are + /// never matched by name. Quantities render as authored: grams weighed, otherwise the + /// household unit text, otherwise the recipe's own note. No synthesized units. + func cookPacketIngredients( + _ packet: RecipeCatalogCookPacket + ) throws -> [(ingredient: Ingredient, quantity: RecipeIngredient)] { + let bridgedIDs = Set(packet.lines.compactMap(\.bridgedIngredientId)) + let ingredientsByID: [Int64: Ingredient] = + bridgedIDs.isEmpty + ? [:] + : try ingredients(withIDs: bridgedIDs) + return packet.lines.compactMap { line in + guard let id = line.bridgedIngredientId, let ingredient = ingredientsByID[id] + else { return nil } + return ( + ingredient, + RecipeIngredient( + recipeId: 0, + ingredientId: id, + isRequired: line.required, + quantityGrams: Self.quantityGrams(line.quantity) ?? 0, + displayQuantity: Self.quantityText(line.quantity) + ) + ) + } + } + + /// Mass for a quantity, only when it carries evidence-backed grams. + private static func quantityGrams(_ quantity: CookQuantity) -> Double? { + switch quantity { + case .grams(let grams): + return grams + case .household(_, _, let grams), .count(_, _, let grams): + return grams + case .unknown: + return nil + } + } + + /// Renders the quantity as authored: grams weighed, otherwise the household unit text, + /// otherwise the recipe's own note ("to taste"). No synthesized units. + private static func quantityText(_ quantity: CookQuantity) -> String { + switch quantity { + case .grams(let grams): + let rounded = (grams * 10).rounded() / 10 + return rounded == rounded.rounded() + ? "\(Int(rounded)) g" : String(format: "%.1f g", rounded) + case .count(let amount, let unit, _), .household(let amount, let unit, _): + let amountText = + amount == amount.rounded() ? String(Int(amount)) : String(format: "%.2g", amount) + return unit.isEmpty ? amountText : "\(amountText) \(unit)" + case .unknown(let note): + return note ?? "" + } + } } diff --git a/apps/ios/Tests/RecipeCatalogRecommendTests.swift b/apps/ios/Tests/RecipeCatalogRecommendTests.swift index 9c69d9010..422ccb79a 100644 --- a/apps/ios/Tests/RecipeCatalogRecommendTests.swift +++ b/apps/ios/Tests/RecipeCatalogRecommendTests.swift @@ -191,6 +191,31 @@ final class RecipeCatalogRecommendTests: XCTestCase { // repository — structured steps for the guide and its bridged ingredient list for the // cooking guide and assistant context — instead of dead-ending on the nil-id legacy // stand-in (empty instructions, no ingredients). + func testCatalogRecommendationYieldsCookPacketAndBridgedIngredients() throws { + let repo = try makeRepository() + let results = try repo.findMakeable(with: [1, 2], profile: .default) + let catalogResult = try XCTUnwrap( + results.first { $0.catalog != nil && $0.recipe.id == nil }, + "the pantry should match a standalone catalog recipe") + + let packet = try XCTUnwrap(try repo.catalogCookPacket(for: catalogResult)) + XCTAssertEqual(packet.title, catalogResult.recipe.title) + XCTAssertFalse(packet.steps.isEmpty, "the guide needs structured steps") + XCTAssertFalse( + packet.steps.map(\.text).joined(separator: "\n").isEmpty, + "guide instructions must not be empty for a catalog recipe") + + let ingredients = try repo.cookPacketIngredients(packet) + XCTAssertFalse( + ingredients.isEmpty, + "bridged lines must surface as the ingredient list; empty means Le Chef sees nothing") + for (ingredient, quantity) in ingredients { + XCTAssertFalse(ingredient.name.isEmpty) + XCTAssertEqual(quantity.ingredientId, ingredient.id) + XCTAssertFalse(quantity.displayQuantity.isEmpty) + } + } + // Review finding 2: keto must fail closed on the catalog pool. The claims schema has // no low-carb field, so no catalog recipe can prove compliance, while the legacy pool // enforces the .lowCarb tag mask — the asymmetry would empty the legacy pool and leave From 5a331523e13af8ec2557292f1d3400a5139c7764 Mon Sep 17 00:00:00 2001 From: Obvious Date: Fri, 16 Oct 2026 01:33:28 +0000 Subject: [PATCH 03/10] fix(ios): port the cookedEvent record-type fix from trunk TasteEventStoreTests declared cookedEvent -> TasteEvent while constructing a TasteEventRecord, which Linux Swift 6.4 rejects with 16 conversion errors (reproduced in the portable harness; fails-first). Ports trunk commit 9de29858 so the Linux portable checks compile on this lineage too. macOS CI compiles both forms; semantics are unchanged. Co-authored-by: Sam Gu --- apps/ios/Tests/TasteStore/TasteEventStoreTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ios/Tests/TasteStore/TasteEventStoreTests.swift b/apps/ios/Tests/TasteStore/TasteEventStoreTests.swift index d8b790531..488967ae9 100644 --- a/apps/ios/Tests/TasteStore/TasteEventStoreTests.swift +++ b/apps/ios/Tests/TasteStore/TasteEventStoreTests.swift @@ -46,7 +46,7 @@ final class TasteEventStoreTests: XCTestCase { at: Date = Date(timeIntervalSince1970: 1_000_000), recipeId: String = "1", rating: Int? = 2 - ) -> TasteEvent { + ) -> TasteEventRecord { TasteEventRecord( id: id, at: at, From 5c8ac46a464ccffff91d248b1cea44e729688df4 Mon Sep 17 00:00:00 2001 From: Obvious Date: Fri, 16 Oct 2026 01:34:25 +0000 Subject: [PATCH 04/10] test(ios): cover the guide catalog gate and the journal snapshot read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings, both in glue no test exercised: the guide resolved the cook packet only through a legacy integer id, so catalog-native recommend results (Recipe(id: nil, ...)) fell back to the legacy path with a synthetic ref, and the journal computed macros from legacy ingredient rows instead of the frozen snapshot the meal was logged against. The gate is extracted from CookingGuideView into a static helper so tests drive the exact production resolution; the journal test logs a real catalog meal through the full flow and asserts the journal reports the snapshot. Both new tests fail at this head — the helper and journal still resolve the old way; the next commit fixes them. Co-authored-by: Sam Gu --- .../ios/Feature/Recipe/CookingGuideView.swift | 14 ++- .../CatalogCookFlowTests.swift | 94 +++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/apps/ios/Feature/Recipe/CookingGuideView.swift b/apps/ios/Feature/Recipe/CookingGuideView.swift index c0cede0f9..2379e0ddf 100644 --- a/apps/ios/Feature/Recipe/CookingGuideView.swift +++ b/apps/ios/Feature/Recipe/CookingGuideView.swift @@ -57,9 +57,19 @@ struct CookingGuideView: View { /// and safety checks and the meal links to the exact revision that was cooked. nil keeps /// the legacy parsed-instructions path. private var catalogContent: CatalogCookContent? { + CookingGuideView.makeCatalogContent(for: scoredRecipe, in: deps.recipeRepository) + } + + /// Resolves the catalog content a guide cooks from for this scored result. A bridged + /// legacy recipe resolves through `recipes_catalog.legacy_recipe_id`. Extracted so + /// tests exercise the exact production gate. nil keeps the legacy parsed-instructions + /// path. + static func makeCatalogContent( + for scoredRecipe: ScoredRecipe, in repository: RecipeRepository + ) -> CatalogCookContent? { guard let legacyId = scoredRecipe.recipe.id, - let catalogId = try? deps.recipeRepository.catalogRecipeID(forLegacyRecipeID: legacyId), - let packet = try? deps.recipeRepository.catalogCookPacket(catalogId: catalogId) + let catalogId = try? repository.catalogRecipeID(forLegacyRecipeID: legacyId), + let packet = try? repository.catalogCookPacket(catalogId: catalogId) else { return nil } return CatalogCookContent( catalogId: packet.catalogId, diff --git a/apps/ios/Tests/CatalogCookFlow/CatalogCookFlowTests.swift b/apps/ios/Tests/CatalogCookFlow/CatalogCookFlowTests.swift index 93e4f44b5..87d22405d 100644 --- a/apps/ios/Tests/CatalogCookFlow/CatalogCookFlowTests.swift +++ b/apps/ios/Tests/CatalogCookFlow/CatalogCookFlowTests.swift @@ -401,4 +401,98 @@ final class CatalogCookFlowTests: XCTestCase { XCTAssertEqual(active.session.recipeRef?.revision, 1) _ = repository } + + // MARK: - The guide's production gate and the journal read path + + /// The recommend pool builds catalog results as `Recipe(id: nil, …)`; the guide must + /// still resolve the packet through the result's own catalog ref, or catalog-native + /// recipes silently cook the legacy path with a synthetic integer ref. + func testGuideCatalogContentResolvesForCatalogNativeResultsWithoutLegacyID() throws { + let db = try makeDatabase() + try hydrate(db, revision: 1, kcal: 420) + let (repository, _, _) = try makeStack(db) + let scored = ScoredRecipe( + recipe: catalogRecipe(), + matchedRequired: 2, + totalRequired: 2, + matchedOptional: 0, + missingRequiredCount: 0, + missingIngredientIds: [], + macros: RecipeMacros( + caloriesPerServing: 420, proteinPerServing: 24, carbsPerServing: 4, fatPerServing: 30, + fiberPerServing: 1, sugarPerServing: 0, sodiumPerServing: 520), + healthScore: HealthScore(rating: 3, label: "Moderate", reasoning: ""), + personalScore: 0, + rankingScore: 0, + rankingReasons: [], + matchTier: .exact, + catalog: CatalogRecipeMatch( + ref: .catalog(id: Self.catalogId, revision: 1), successorToLegacyRecipeID: nil)) + + let content = try XCTUnwrap(CookingGuideView.makeCatalogContent(for: scored, in: repository)) + XCTAssertEqual(content.catalogId, Self.catalogId) + XCTAssertEqual(content.revision, 1) + XCTAssertEqual(content.steps.count, 2, "the structured packet steps, not parsed legacy text") + } + + /// A legacy recipe bridged to its catalog successor cooks from the catalog packet too. + func testGuideCatalogContentResolvesThroughTheLegacyBridge() throws { + let db = try makeDatabase() + try hydrate(db, revision: 1, kcal: 420) + let (repository, _, _) = try makeStack(db) + try db.write { db in + try db.execute( + sql: "UPDATE recipes_catalog SET legacy_recipe_id = 1 WHERE catalog_id = ?", + arguments: [Self.catalogId]) + try db.execute( + sql: """ + INSERT INTO recipes (id, title, time_minutes, servings, instructions, tags, source) + VALUES (1, 'Legacy Scramble', 12, 2, 'Beat the eggs.', 0, 'bundled') + """) + } + let scored = ScoredRecipe( + recipe: Recipe( + id: 1, title: "Legacy Scramble", timeMinutes: 12, servings: 2, + instructions: "Beat the eggs.", tags: 0, source: .bundled, createdAt: nil), + matchedRequired: 2, + totalRequired: 2, + matchedOptional: 0, + missingRequiredCount: 0, + missingIngredientIds: [], + macros: RecipeMacros( + caloriesPerServing: 420, proteinPerServing: 24, carbsPerServing: 4, fatPerServing: 30, + fiberPerServing: 1, sugarPerServing: 0, sodiumPerServing: 520), + healthScore: HealthScore(rating: 3, label: "Moderate", reasoning: ""), + personalScore: 0, + rankingScore: 0, + rankingReasons: [], + matchTier: .exact) + + let content = try XCTUnwrap(CookingGuideView.makeCatalogContent(for: scored, in: repository)) + XCTAssertEqual(content.catalogId, Self.catalogId, "the bridge resolves the packet") + XCTAssertEqual(content.revision, 1) + } + + /// The journal reports the frozen per-serving snapshot the meal was logged against — + /// not the legacy ingredient math, which has no rows for catalog-native recipes. + func testJournalReportsTheFrozenSnapshotForCatalogMeals() throws { + let db = try makeDatabase() + try hydrate(db, revision: 1, kcal: 420) + let (repository, _, mealLog) = try makeStack(db) + let clock = Date(timeIntervalSince1970: 1_790_000_000) + let deps = makeDependencies(db: db, mealLog: mealLog, clock: { clock }) + + _ = try cookToRememberedMeal(db: db, deps: deps, repository: repository) + + let consumed = try db.read { + try Int.fetchOne($0, sql: "SELECT servings_consumed FROM cooking_history LIMIT 1") + } ?? 2 + let journal = try UserDataRepository(db: db).cookingJournal() + XCTAssertEqual(journal.count, 1) + XCTAssertEqual( + journal[0].macrosConsumed.calories, 420 * Double(consumed), accuracy: 0.5, + "the journal reads the frozen per-serving snapshot, not the legacy rows") + XCTAssertEqual(journal[0].macrosConsumed.protein, 24 * Double(consumed), accuracy: 0.5) + } + } From 770ab5eb8607312392dddc59ea19c83d4dd7eba3 Mon Sep 17 00:00:00 2001 From: Obvious Date: Fri, 16 Oct 2026 01:47:23 +0000 Subject: [PATCH 05/10] fix(ios): resolve catalog-native guides and journal frozen snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fail-first run proved both review findings (38835855949 red at 5c8ac46): the catalog-native gate test got nil content, and the journal reported 0.0 kcal where the frozen snapshot says 840. The guide now resolves catalog content from the result's own catalog ref when there is no persisted integer id — the shape the recommend pool builds for every catalog-native recipe — keeping the legacy bridge and the parsed-instructions fallback for drafts and unloadable packets. The journal reports the frozen per-serving nutrition snapshot at the linked revision, scaled by servings consumed and portion multiplier, and keeps the legacy ingredient math for cooks with no catalog link. The bridge test inserts the legacy recipe before referencing it (FK order). Co-authored-by: Sam Gu --- .../ios/Feature/Recipe/CookingGuideView.swift | 18 ++++++++----- .../Repository/UserDataRepository.swift | 26 ++++++++++++++++++- .../CatalogCookFlowTests.swift | 6 ++--- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/apps/ios/Feature/Recipe/CookingGuideView.swift b/apps/ios/Feature/Recipe/CookingGuideView.swift index 2379e0ddf..dc744b645 100644 --- a/apps/ios/Feature/Recipe/CookingGuideView.swift +++ b/apps/ios/Feature/Recipe/CookingGuideView.swift @@ -61,15 +61,21 @@ struct CookingGuideView: View { } /// Resolves the catalog content a guide cooks from for this scored result. A bridged - /// legacy recipe resolves through `recipes_catalog.legacy_recipe_id`. Extracted so - /// tests exercise the exact production gate. nil keeps the legacy parsed-instructions - /// path. + /// legacy recipe resolves through `recipes_catalog.legacy_recipe_id`; a catalog-native + /// result — the recommend pool builds `Recipe(id: nil, …)` and carries the identity in + /// its own match's catalog ref — resolves through that ref. Unsaved drafts (no integer + /// id, no catalog match) and recipes whose packet cannot load stay on the legacy + /// parsed-instructions path. Extracted so tests exercise the exact production gate. static func makeCatalogContent( for scoredRecipe: ScoredRecipe, in repository: RecipeRepository ) -> CatalogCookContent? { - guard let legacyId = scoredRecipe.recipe.id, - let catalogId = try? repository.catalogRecipeID(forLegacyRecipeID: legacyId), - let packet = try? repository.catalogCookPacket(catalogId: catalogId) + let catalogId: String? + if let legacyId = scoredRecipe.recipe.id { + catalogId = try? repository.catalogRecipeID(forLegacyRecipeID: legacyId) + } else { + catalogId = scoredRecipe.catalog?.ref.id + } + guard let catalogId, let packet = try? repository.catalogCookPacket(catalogId: catalogId) else { return nil } return CatalogCookContent( catalogId: packet.catalogId, diff --git a/apps/ios/Platform/Persistence/Repository/UserDataRepository.swift b/apps/ios/Platform/Persistence/Repository/UserDataRepository.swift index 5619e4236..75c2d207b 100644 --- a/apps/ios/Platform/Persistence/Repository/UserDataRepository.swift +++ b/apps/ios/Platform/Persistence/Repository/UserDataRepository.swift @@ -449,11 +449,35 @@ final class UserDataRepository: Sendable { // MARK: - Macro Helpers - /// Compute absolute macros consumed for a single cooking event. + /// Compute absolute macros consumed for a single cooking event. A catalog-linked meal + /// reports the frozen per-serving snapshot at the revision it was logged against + /// (`recipes_cooking_catalog_refs` → `recipes_catalog_nutrition` — same join as + /// `RecipeRepository.catalogNutrition(forHistoryID:)`, keep the two in step), scaled by + /// what was actually consumed. Legacy cooks keep the ingredient math below: the bundle + /// apply never writes or rewrites legacy `recipes`/`recipe_ingredients` rows. private static func computeConsumedMacros( db: Database, historyId: Int64, recipeId: Int64, recipeServings: Int, servingsConsumed: Int, portionMultiplier: Double ) throws -> MacroTotals { + if let json = try String.fetchOne( + db, + sql: """ + SELECT n.nutrition + FROM recipes_cooking_catalog_refs r + JOIN recipes_catalog_nutrition n + ON n.catalog_id = r.catalog_id AND n.revision = r.revision + WHERE r.history_id = ? + """, + arguments: [historyId] + ) { + let snapshot = try JSONDecoder().decode(RecipeCatalogNutrition.self, from: Data(json.utf8)) + let consumedFactor = Double(servingsConsumed) * portionMultiplier + return MacroTotals( + calories: snapshot.kcal * consumedFactor, + protein: snapshot.proteinG * consumedFactor, + carbs: snapshot.carbsG * consumedFactor, + fat: snapshot.fatG * consumedFactor) + } let row = try Row.fetchOne( db, sql: """ diff --git a/apps/ios/Tests/CatalogCookFlow/CatalogCookFlowTests.swift b/apps/ios/Tests/CatalogCookFlow/CatalogCookFlowTests.swift index 87d22405d..6e8abdd43 100644 --- a/apps/ios/Tests/CatalogCookFlow/CatalogCookFlowTests.swift +++ b/apps/ios/Tests/CatalogCookFlow/CatalogCookFlowTests.swift @@ -441,14 +441,14 @@ final class CatalogCookFlowTests: XCTestCase { try hydrate(db, revision: 1, kcal: 420) let (repository, _, _) = try makeStack(db) try db.write { db in - try db.execute( - sql: "UPDATE recipes_catalog SET legacy_recipe_id = 1 WHERE catalog_id = ?", - arguments: [Self.catalogId]) try db.execute( sql: """ INSERT INTO recipes (id, title, time_minutes, servings, instructions, tags, source) VALUES (1, 'Legacy Scramble', 12, 2, 'Beat the eggs.', 0, 'bundled') """) + try db.execute( + sql: "UPDATE recipes_catalog SET legacy_recipe_id = 1 WHERE catalog_id = ?", + arguments: [Self.catalogId]) } let scored = ScoredRecipe( recipe: Recipe( From d6252a750c42a9d6c85fc81e69eab142cd3ec2e9 Mon Sep 17 00:00:00 2001 From: Obvious Date: Fri, 16 Oct 2026 02:02:21 +0000 Subject: [PATCH 06/10] fix(ios): import FLFeatureLogic for the journal snapshot decode RecipeCatalogNutrition lives in the FLFeatureLogic package module; RecipeRepositoryCatalog already imports it, UserDataRepository did not. Co-authored-by: Sam Gu --- .../ios/Platform/Persistence/Repository/UserDataRepository.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ios/Platform/Persistence/Repository/UserDataRepository.swift b/apps/ios/Platform/Persistence/Repository/UserDataRepository.swift index 75c2d207b..1df27a517 100644 --- a/apps/ios/Platform/Persistence/Repository/UserDataRepository.swift +++ b/apps/ios/Platform/Persistence/Repository/UserDataRepository.swift @@ -1,3 +1,4 @@ +import FLFeatureLogic import Foundation import GRDB From a6137075f89525cca89eb9ed97922f814bd0a694 Mon Sep 17 00:00:00 2001 From: Obvious Date: Fri, 16 Oct 2026 02:36:46 +0000 Subject: [PATCH 07/10] feat(ios): serve catalog content to the assistant, finalization, and cloud ranking The LeChef recipe context, its cook session, the meal finalization log, and the cloud reverse-scan ranking round trip now resolve a catalog result through its packet identity instead of the legacy integer id. Co-authored-by: Obvious --- apps/ios/App/ContentView.swift | 11 ++-- .../Core/Intelligence/GeminiCloudAgent.swift | 20 +++++-- .../ReverseScanCandidateHandle.swift | 17 ++++++ .../Core/Services/ReverseScanService.swift | 11 ++-- .../Assistant/LiveAssistantModels.swift | 18 +++++- .../LiveAssistantRecipeContextFactory.swift | 56 +++++++++++++++++++ .../Assistant/LiveAssistantViewModel.swift | 16 +++++- .../Finalization/MealFinalizationSheet.swift | 5 ++ .../MealFinalizationViewModel.swift | 25 +++++++-- .../Feature/Results/RecipeResultsView.swift | 17 ++---- 10 files changed, 160 insertions(+), 36 deletions(-) create mode 100644 apps/ios/Capability/Core/Intelligence/ReverseScanCandidateHandle.swift create mode 100644 apps/ios/Feature/Assistant/LiveAssistantRecipeContextFactory.swift diff --git a/apps/ios/App/ContentView.swift b/apps/ios/App/ContentView.swift index 152af9999..4f2b3dd33 100644 --- a/apps/ios/App/ContentView.swift +++ b/apps/ios/App/ContentView.swift @@ -736,15 +736,12 @@ struct ContentView: View { return } - let ingredients: [(ingredient: Ingredient, quantity: RecipeIngredient)] - if let recipeID = recipe.recipe.id { - ingredients = (try? deps.recipeRepository.ingredientsForRecipe(id: recipeID)) ?? [] - } else { - ingredients = [] - } + let context = LiveAssistantRecipeContextFactory( + recipeRepository: deps.recipeRepository + ).context(for: recipe) presentLiveAssistant( - recipeContext: LiveAssistantRecipeContext(scoredRecipe: recipe, ingredients: ingredients), + recipeContext: context, replaySpotlightOnAppear: true, marksTutorialProgressOnComplete: false ) diff --git a/apps/ios/Capability/Core/Intelligence/GeminiCloudAgent.swift b/apps/ios/Capability/Core/Intelligence/GeminiCloudAgent.swift index 233c24df4..1ad207899 100644 --- a/apps/ios/Capability/Core/Intelligence/GeminiCloudAgent.swift +++ b/apps/ios/Capability/Core/Intelligence/GeminiCloudAgent.swift @@ -220,10 +220,14 @@ final class GeminiCloudAgent: @unchecked Sendable { "\($0.label):\(Int(($0.confidence * 100).rounded()))" }.joined(separator: ", ") - let candidateSummary = candidates.prefix(8).compactMap { candidate -> String? in - guard let recipeID = candidate.recipe.recipe.id else { return nil } + // Every candidate rides under its handle — the legacy id when it has one, a + // negative alias when it is a catalog entry — so catalog recipes are ranked + // instead of being silently dropped, and the model's integer-keyed schema holds. + let handles = ReverseScanCandidateHandle.handles(for: candidates) + let candidateSummary = candidates.prefix(8).enumerated().compactMap { index, candidate -> String? in + guard index < handles.count else { return nil } return - "id=\(recipeID), title=\(candidate.recipe.recipe.title), local_conf=\(candidate.confidenceScore), missing_required=\(candidate.recipe.missingRequiredCount)" + "id=\(handles[index]), title=\(candidate.recipe.recipe.title), local_conf=\(candidate.confidenceScore), missing_required=\(candidate.recipe.missingRequiredCount)" }.joined(separator: "\n") guard !candidateSummary.isEmpty else { return nil } @@ -403,10 +407,14 @@ final class GeminiCloudAgent: @unchecked Sendable { guard let baseURL = config.backendBaseURL else { return nil } let endpoint = baseURL.appendingPathComponent("v1/reverse-scan/rank") - let candidatePayload = candidates.compactMap { candidate -> BackendReverseScanCandidate? in - guard let recipeID = candidate.recipe.recipe.id else { return nil } + // Catalog candidates ride under negative handles so the backend's integer + // recipe_id contract holds without a legacy row; the service maps rankings back + // through the same rule (ReverseScanCandidateHandle). + let handles = ReverseScanCandidateHandle.handles(for: candidates) + let candidatePayload = candidates.enumerated().compactMap { index, candidate -> BackendReverseScanCandidate? in + guard index < handles.count else { return nil } return BackendReverseScanCandidate( - recipeId: recipeID, + recipeId: handles[index], title: candidate.recipe.recipe.title, localConfidence: candidate.confidenceScore, missingRequiredCount: candidate.recipe.missingRequiredCount diff --git a/apps/ios/Capability/Core/Intelligence/ReverseScanCandidateHandle.swift b/apps/ios/Capability/Core/Intelligence/ReverseScanCandidateHandle.swift new file mode 100644 index 000000000..4d5816c18 --- /dev/null +++ b/apps/ios/Capability/Core/Intelligence/ReverseScanCandidateHandle.swift @@ -0,0 +1,17 @@ +import Foundation + +/// The stable integer handle a reverse-scan candidate rides under in the cloud ranking +/// round trip. Both the direct Gemini prompt and the backend `/v1/reverse-scan/rank` +/// contract key rankings on an integer `recipe_id`; catalog candidates (no legacy row, +/// `Recipe(id: nil, …)`) keep their identity under a negative handle — legacy ids are +/// always positive, so the two spaces never collide. The agent assigns handles when it +/// builds the request and the service maps rankings back through the same rule, so the +/// pairing lives in one tested place, not in an agreed-on convention. +enum ReverseScanCandidateHandle { + /// The handle for each candidate, in the order the candidates will be sent. + static func handles(for candidates: [ReverseScanRecipeCandidate]) -> [Int64] { + candidates.enumerated().map { index, candidate in + candidate.recipe.recipe.id ?? -Int64(index + 1) + } + } +} diff --git a/apps/ios/Capability/Core/Services/ReverseScanService.swift b/apps/ios/Capability/Core/Services/ReverseScanService.swift index 90f4efc89..60eb7a285 100644 --- a/apps/ios/Capability/Core/Services/ReverseScanService.swift +++ b/apps/ios/Capability/Core/Services/ReverseScanService.swift @@ -145,12 +145,15 @@ final class ReverseScanService: Sendable { ), !cloudRankings.isEmpty { - let rankingByID = Dictionary( + let rankingByHandle = Dictionary( uniqueKeysWithValues: cloudRankings.map { ($0.recipeID, $0) }) - candidates = candidates.map { candidate in - guard let recipeID = candidate.recipe.recipe.id, - let cloud = rankingByID[recipeID] + // Rankings map back through the same handle rule the agent sent: the legacy + // id when the candidate has one, the negative alias when it is a catalog + // entry — so catalog candidates blend cloud confidence too. + let handles = ReverseScanCandidateHandle.handles(for: candidates) + candidates = candidates.enumerated().map { index, candidate in + guard index < handles.count, let cloud = rankingByHandle[handles[index]] else { return candidate } diff --git a/apps/ios/Feature/Assistant/LiveAssistantModels.swift b/apps/ios/Feature/Assistant/LiveAssistantModels.swift index 6c23f8692..2a8f6c438 100644 --- a/apps/ios/Feature/Assistant/LiveAssistantModels.swift +++ b/apps/ios/Feature/Assistant/LiveAssistantModels.swift @@ -1,3 +1,4 @@ +import FLFeatureLogic import Foundation import Observation @@ -24,6 +25,15 @@ struct LiveAssistantRecipeContext: Identifiable, Codable, Hashable, Sendable { let instructions: String let ingredients: [LiveAssistantIngredientContext] let matchedAt: Date + /// The catalog identity this session cooks at, when the recipe is a catalog recipe: + /// the cook session links the exact `(catalogId, revision)` that was cooked. nil keeps + /// the legacy integer-id session reference. + let catalogID: String? + let catalogRevision: Int? + /// The packet's structured steps — stated timers and thermometer checks — verbatim + /// from the installed bundle-v2 revision. nil for legacy recipes, which cook from the + /// parsed `instructions` text. + let catalogSteps: [CookStep]? init( recipeID: Int64?, @@ -32,7 +42,10 @@ struct LiveAssistantRecipeContext: Identifiable, Codable, Hashable, Sendable { servings: Int, instructions: String, ingredients: [LiveAssistantIngredientContext], - matchedAt: Date = .now + matchedAt: Date = .now, + catalogID: String? = nil, + catalogRevision: Int? = nil, + catalogSteps: [CookStep]? = nil ) { self.id = recipeID.map(String.init) ?? UUID().uuidString self.recipeID = recipeID @@ -42,6 +55,9 @@ struct LiveAssistantRecipeContext: Identifiable, Codable, Hashable, Sendable { self.instructions = instructions self.ingredients = ingredients self.matchedAt = matchedAt + self.catalogID = catalogID + self.catalogRevision = catalogRevision + self.catalogSteps = catalogSteps } } diff --git a/apps/ios/Feature/Assistant/LiveAssistantRecipeContextFactory.swift b/apps/ios/Feature/Assistant/LiveAssistantRecipeContextFactory.swift new file mode 100644 index 000000000..dec743f86 --- /dev/null +++ b/apps/ios/Feature/Assistant/LiveAssistantRecipeContextFactory.swift @@ -0,0 +1,56 @@ +import FLFeatureLogic +import Foundation + +/// Builds the LeChef recipe context for a scored result, over the same repository paths +/// the cooking guide and preview drawer use, so every surface that receives a +/// recommended recipe agrees on its content. A catalog result — the recommend pool +/// builds `Recipe(id: nil, …)` and carries the identity in the match's catalog ref — +/// resolves ingredients and structured steps from the catalog cook packet; a legacy +/// result keeps the legacy ingredient rows and free-text instructions. Extracted from +/// the views so tests exercise the exact production resolution. +struct LiveAssistantRecipeContextFactory { + let recipeRepository: RecipeRepository + + func context(for scoredRecipe: ScoredRecipe) -> LiveAssistantRecipeContext { + let ingredients: [(ingredient: Ingredient, quantity: RecipeIngredient)] + var catalogID: String? + var catalogRevision: Int? + var catalogSteps: [CookStep]? + var instructions = scoredRecipe.recipe.instructions + + if let packet = try? recipeRepository.catalogCookPacket(for: scoredRecipe) { + // Catalog entries have no legacy row: the context cooks the packet — structured + // steps (timers, thermometer checks) and the packet's bridged ingredient lines. + ingredients = (try? recipeRepository.cookPacketIngredients(packet)) ?? [] + catalogID = packet.catalogId + catalogRevision = packet.revision + catalogSteps = packet.steps + let stepText = packet.steps.map(\.text).joined(separator: "\n") + if !stepText.isEmpty { + instructions = stepText + } + } else if let recipeID = scoredRecipe.recipe.id { + ingredients = (try? recipeRepository.ingredientsForRecipe(id: recipeID)) ?? [] + } else { + ingredients = [] + } + + return LiveAssistantRecipeContext( + recipeID: scoredRecipe.recipe.id, + title: scoredRecipe.recipe.title, + timeMinutes: scoredRecipe.recipe.timeMinutes, + servings: scoredRecipe.recipe.servings, + instructions: instructions, + ingredients: ingredients.map { + LiveAssistantIngredientContext( + name: $0.ingredient.name, + quantityText: $0.quantity.displayQuantity, + quantityGrams: $0.quantity.quantityGrams + ) + }, + catalogID: catalogID, + catalogRevision: catalogRevision, + catalogSteps: catalogSteps + ) + } +} diff --git a/apps/ios/Feature/Assistant/LiveAssistantViewModel.swift b/apps/ios/Feature/Assistant/LiveAssistantViewModel.swift index 6d8d154b7..d94591219 100644 --- a/apps/ios/Feature/Assistant/LiveAssistantViewModel.swift +++ b/apps/ios/Feature/Assistant/LiveAssistantViewModel.swift @@ -168,9 +168,20 @@ final class LiveAssistantViewModel: ObservableObject { // The chat surface's cook session: in-memory, fail-closed on allergen knowledge // (unknown blocks, LeChef §7), no meal logging — this is not the persisted cook-mode - // surface, but proposals still execute against a real session. + // surface, but proposals still execute against a real session. A catalog recipe + // runs the packet's structured steps and links the exact (catalogId, revision); + // legacy recipes cook from the parsed instructions and the integer-id reference. var liveDependencies = CookModeViewModel.Dependencies.ephemeral() liveDependencies.substituteIsAllergen = { _ in nil } + let catalogContent: CatalogCookContent? + if let catalogID = recipeContext.catalogID, let revision = recipeContext.catalogRevision, + let steps = recipeContext.catalogSteps, !steps.isEmpty + { + catalogContent = CatalogCookContent( + catalogId: catalogID, revision: revision, steps: steps) + } else { + catalogContent = nil + } let cookViewModel = CookModeViewModel( recipe: Recipe( id: recipeContext.recipeID, @@ -182,7 +193,8 @@ final class LiveAssistantViewModel: ObservableObject { source: .bundled, createdAt: nil), logsMeal: false, - dependencies: liveDependencies) + dependencies: liveDependencies, + catalog: catalogContent) self.cookViewModel = cookViewModel let client = self.client diff --git a/apps/ios/Feature/Finalization/MealFinalizationSheet.swift b/apps/ios/Feature/Finalization/MealFinalizationSheet.swift index a1e948314..e5d8488c1 100644 --- a/apps/ios/Feature/Finalization/MealFinalizationSheet.swift +++ b/apps/ios/Feature/Finalization/MealFinalizationSheet.swift @@ -13,12 +13,17 @@ struct MealFinalizationSheet: View { recipe: Recipe, defaultServings: Int = 1, deps: AppDependencies, + catalogId: String? = nil, + catalogRevision: Int? = nil, onComplete: @escaping () -> Void ) { _vm = State( wrappedValue: MealFinalizationViewModel( recipe: recipe, defaultServings: defaultServings, + catalogId: catalogId, + catalogRevision: catalogRevision, + recipeRepository: deps.recipeRepository, personalizationService: deps.personalizationService, inventoryRepository: deps.inventoryRepository )) diff --git a/apps/ios/Feature/Finalization/MealFinalizationViewModel.swift b/apps/ios/Feature/Finalization/MealFinalizationViewModel.swift index 0e7374fda..0e6f4eb28 100644 --- a/apps/ios/Feature/Finalization/MealFinalizationViewModel.swift +++ b/apps/ios/Feature/Finalization/MealFinalizationViewModel.swift @@ -22,18 +22,30 @@ final class MealFinalizationViewModel { // MARK: - Context let recipe: Recipe + /// The catalog identity this meal cooked at, when the recipe is a catalog recipe: + /// the log links the exact `(catalogId, revision)`, so later revision bumps never + /// rewrite its nutrition. nil logs the legacy integer-id row. + private let catalogId: String? + private let catalogRevision: Int? + private let recipeRepository: RecipeRepository private let personalizationService: PersonalizationService private let inventoryRepository: InventoryRepository init( recipe: Recipe, defaultServings: Int = 1, + catalogId: String? = nil, + catalogRevision: Int? = nil, + recipeRepository: RecipeRepository, personalizationService: PersonalizationService, inventoryRepository: InventoryRepository ) { self.recipe = recipe self.servings = defaultServings + self.catalogId = catalogId + self.catalogRevision = catalogRevision + self.recipeRepository = recipeRepository self.personalizationService = personalizationService self.inventoryRepository = inventoryRepository } @@ -46,17 +58,20 @@ final class MealFinalizationViewModel { defer { isSaving = false } do { - guard let recipeId = recipe.id else { - errorMessage = "Recipe has no ID." - return - } + // Catalog entries carry no integer recipe id; the log lands on the legacy row the + // meal resolves to (a title match, or a minimal row when none exists), and links + // the (catalogId, revision) that was cooked so nutrition reports from the frozen + // per-revision snapshot. Legacy recipes keep their integer id unchanged. + let recipeId = try recipe.id ?? recipeRepository.resolvePersistedRecipeID(for: recipe) try personalizationService.recordCooking( recipeId: recipeId, rating: rating > 0 ? rating : nil, imagePath: photoPath, servingsConsumed: servings, - recipeSource: TasteRecipeSource(recipeSource: recipe.source) + recipeSource: TasteRecipeSource(recipeSource: recipe.source), + catalogId: catalogId, + catalogRevision: catalogRevision ) try inventoryRepository.applyConsumption( diff --git a/apps/ios/Feature/Results/RecipeResultsView.swift b/apps/ios/Feature/Results/RecipeResultsView.swift index 28a9fd19f..50d12f8f0 100644 --- a/apps/ios/Feature/Results/RecipeResultsView.swift +++ b/apps/ios/Feature/Results/RecipeResultsView.swift @@ -362,20 +362,15 @@ struct RecipeResultsView: View { } private func storeRecipeForAssistant(_ scored: ScoredRecipe) { - let ingredients: [(ingredient: Ingredient, quantity: RecipeIngredient)] - if let recipeID = scored.recipe.id { - ingredients = (try? deps.recipeRepository.ingredientsForRecipe(id: recipeID)) ?? [] - } else if let packet = try? deps.recipeRepository.catalogCookPacket(for: scored) { - // Catalog entries have no legacy row; their context comes from the structured - // packet, the same path the cooking guide and preview drawer use. - ingredients = (try? deps.recipeRepository.cookPacketIngredients(packet)) ?? [] - } else { - ingredients = [] - } + // One factory for every surface: catalog results resolve the context from the + // structured packet, legacy results from the legacy rows. + let context = LiveAssistantRecipeContextFactory( + recipeRepository: deps.recipeRepository + ).context(for: scored) liveAssistantCoordinator.storeRecipeMatch( scoredRecipe: scored, - context: LiveAssistantRecipeContext(scoredRecipe: scored, ingredients: ingredients) + context: context ) } From cc10fbc80119ead2fcde997c9638d5474f4d66fe Mon Sep 17 00:00:00 2001 From: Obvious Date: Fri, 16 Oct 2026 02:41:22 +0000 Subject: [PATCH 08/10] feat(ios): route the home auto-present by scored identity and test the catalog surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home cards carry ScoredRecipeID through the route, so auto-present resolves the exact recommended result — catalog ref, legacy id, or unsaved title — instead of the integer column. New suites cover the handle rule, the resolver, the packet context, and the catalog finalization log. Co-authored-by: Obvious --- apps/ios/App/ContentView.swift | 10 +- .../Feature/Home/HomeDashboardModels.swift | 12 +- .../Feature/Home/HomeDashboardViewModel.swift | 2 +- .../Results/PreferredRecipeResolver.swift | 16 + .../Feature/Results/RecipeResultsView.swift | 8 +- ...AssistantContextAndFinalizationTests.swift | 304 ++++++++++++++++++ .../PreferredRecipeResolverTests.swift | 82 +++++ .../ReverseScanCandidateHandleTests.swift | 85 +++++ 8 files changed, 509 insertions(+), 10 deletions(-) create mode 100644 apps/ios/Feature/Results/PreferredRecipeResolver.swift create mode 100644 apps/ios/Tests/CatalogCookFlow/LiveAssistantContextAndFinalizationTests.swift create mode 100644 apps/ios/Tests/Results/PreferredRecipeResolverTests.swift create mode 100644 apps/ios/Tests/ReverseScan/ReverseScanCandidateHandleTests.swift diff --git a/apps/ios/App/ContentView.swift b/apps/ios/App/ContentView.swift index 4f2b3dd33..1af9f8dd0 100644 --- a/apps/ios/App/ContentView.swift +++ b/apps/ios/App/ContentView.swift @@ -97,10 +97,16 @@ struct ContentView: View { private struct HomeRecommendationRoute: Identifiable, Hashable, Sendable { let ingredientIds: Set let ingredientNames: [String] - let preferredRecipeID: Int64? + let preferredID: ScoredRecipeID? var id: String { - let recipePart = preferredRecipeID.map(String.init) ?? "none" + let recipePart: String + switch preferredID { + case .legacy(let legacyID): recipePart = "recipe-\(legacyID)" + case .catalog(let catalogID): recipePart = "catalog-\(catalogID)" + case .unsaved(let title): recipePart = "unsaved-\(title)" + case nil: recipePart = "none" + } let ingredientPart = ingredientIds.sorted().map(String.init).joined(separator: ",") return "\(recipePart)|\(ingredientPart)|\(ingredientNames.joined(separator: ","))" } diff --git a/apps/ios/Feature/Home/HomeDashboardModels.swift b/apps/ios/Feature/Home/HomeDashboardModels.swift index 06c3019f4..a949f29a4 100644 --- a/apps/ios/Feature/Home/HomeDashboardModels.swift +++ b/apps/ios/Feature/Home/HomeDashboardModels.swift @@ -16,7 +16,10 @@ struct WeekdayCookingPoint: Identifiable, Sendable, Equatable { } struct HomeRecommendation: Identifiable, Sendable { - let recipeID: Int64? + /// The scored identity the card opens — a catalog ref when the recommendation is a + /// catalog recipe, the legacy integer id otherwise — so the Results screen + /// auto-presents this exact result instead of guessing it from list order. + let preferredID: ScoredRecipeID? let recipeName: String let explanation: String let cookTimeMinutes: Int? @@ -25,7 +28,12 @@ struct HomeRecommendation: Identifiable, Sendable { let ingredientIDs: Set var id: String { - recipeID.map(String.init) ?? recipeName + switch preferredID { + case .legacy(let legacyID): return "recipe-\(legacyID)" + case .catalog(let catalogID): return "catalog-\(catalogID)" + case .unsaved(let title): return "unsaved-\(title)" + case nil: return recipeName + } } } diff --git a/apps/ios/Feature/Home/HomeDashboardViewModel.swift b/apps/ios/Feature/Home/HomeDashboardViewModel.swift index f69246f80..62d572420 100644 --- a/apps/ios/Feature/Home/HomeDashboardViewModel.swift +++ b/apps/ios/Feature/Home/HomeDashboardViewModel.swift @@ -102,7 +102,7 @@ final class HomeDashboardViewModel: ObservableObject { now: Date()) if let top = all.first { primary = HomeRecommendation( - recipeID: top.recipe.id, + preferredID: top.id, recipeName: top.recipe.title, explanation: top.rankingReasons.prefix(3).joined(separator: ". ") + ".", cookTimeMinutes: top.recipe.timeMinutes, diff --git a/apps/ios/Feature/Results/PreferredRecipeResolver.swift b/apps/ios/Feature/Results/PreferredRecipeResolver.swift new file mode 100644 index 000000000..9497fd86d --- /dev/null +++ b/apps/ios/Feature/Results/PreferredRecipeResolver.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Resolves the identity a screen passes in — a Home recommendation card, a demo +/// scenario, a replay — to the exact scored result to auto-present. Catalog entries +/// carry no legacy row, so matching runs on the scored identity +/// (`ScoredRecipeID`), never on list order or the integer column alone. +enum PreferredRecipeResolver { + /// The first result whose scored identity matches, keeping list order for ties. + static func resolve( + preferredID: ScoredRecipeID?, + in recommendations: [ScoredRecipe] + ) -> ScoredRecipe? { + guard let preferredID else { return nil } + return recommendations.first { $0.id == preferredID } + } +} diff --git a/apps/ios/Feature/Results/RecipeResultsView.swift b/apps/ios/Feature/Results/RecipeResultsView.swift index 50d12f8f0..f4fd921e8 100644 --- a/apps/ios/Feature/Results/RecipeResultsView.swift +++ b/apps/ios/Feature/Results/RecipeResultsView.swift @@ -17,7 +17,7 @@ struct RecipeResultsView: View { let ingredientNames: [String] let fridgePhoto: UIImage? let scanConfidenceScore: Double? - let preferredRecipeID: Int64? + let preferredID: ScoredRecipeID? let includesKitchenInventory: Bool /// False for demo and tutorial results: cooking through them is practice and must not add a /// meal to history or take food out of the real Kitchen. @@ -397,11 +397,9 @@ struct RecipeResultsView: View { private func autoPresentPreferredRecipeIfNeeded() { guard - let preferredRecipeID, !didPresentPreferredRecipe, - let preferredRecipe = engine.recommendations.first(where: { - $0.recipe.id == preferredRecipeID - }) + let preferredRecipe = PreferredRecipeResolver.resolve( + preferredID: preferredID, in: engine.recommendations) else { return } diff --git a/apps/ios/Tests/CatalogCookFlow/LiveAssistantContextAndFinalizationTests.swift b/apps/ios/Tests/CatalogCookFlow/LiveAssistantContextAndFinalizationTests.swift new file mode 100644 index 000000000..7ac8a5c0f --- /dev/null +++ b/apps/ios/Tests/CatalogCookFlow/LiveAssistantContextAndFinalizationTests.swift @@ -0,0 +1,304 @@ +import FLFeatureLogic +import GRDB +import XCTest + +@testable import FridgeLuck + +/// The surfaces a recommended recipe reaches — LeChef's context and cook session, and the +/// meal finalization log — resolve a catalog result through its packet instead of the +/// legacy integer column. Over a hydrated bundle-v2 fixture: the context carries the +/// packet's structured steps (stated timer, cited thermometer check) and the exact +/// (catalogId, revision); the finalization log for a recipe with no persisted id still +/// records a history row linked to the (catalogId, revision) that was cooked, so a later +/// revision bump cannot rewrite what the meal says it was. Harness mirrors +/// CatalogCookFlowTests: real loader, real migrations, real repositories. +@MainActor +final class LiveAssistantContextAndFinalizationTests: XCTestCase { + // MARK: - Fixtures + + private static let catalogId = "rcp-900001" + + private func makeDatabase() throws -> DatabaseQueue { + let db = try DatabaseQueue() + try DatabaseMigrations.migrate(db) + return db + } + + private func hydrate(_ db: DatabaseQueue, revision: Int) throws { + let bundle = try RecipeBundleV2.decode(from: Data(Self.bundleJSON(revision: revision).utf8)) + try db.write { db in + try RecipeBundleV2Loader.apply(bundle: bundle, marker: "live-assistant-context-tests", db: db) + } + } + + private func makeRepository(_ db: DatabaseQueue) throws -> RecipeRepository { + let nutritionService = NutritionService(db: db) + let healthScoring = HealthScoringService(nutritionService: nutritionService, db: db) + let personalization = PersonalizationService(db: db) + return RecipeRepository( + db: db, + nutritionService: nutritionService, + healthScoringService: healthScoring, + personalizationService: personalization) + } + + /// The recipe object the recommend pool builds for a catalog entry: no persisted id. + private func catalogRecipe() -> Recipe { + Recipe( + id: nil, + title: "Catalog Scramble", + timeMinutes: 12, + servings: 2, + instructions: "Beat the eggs.", // legacy fallback text; catalog steps win + tags: 0, + source: .bundled, + createdAt: nil) + } + + private func scored(_ recipe: Recipe, catalogID: String?, revision: Int) -> ScoredRecipe { + var scored = ScoredRecipe( + recipe: recipe, + matchedRequired: 1, + totalRequired: 1, + matchedOptional: 0, + missingRequiredCount: 0, + missingIngredientIds: [], + macros: RecipeMacros( + caloriesPerServing: 58, proteinPerServing: 24, carbsPerServing: 4, + fatPerServing: 30, fiberPerServing: 1, sugarPerServing: 1, sodiumPerServing: 520), + healthScore: HealthScore(rating: 4, label: "Good", reasoning: "test"), + personalScore: 0.5, + rankingScore: 1.0, + rankingReasons: [], + matchTier: .exact) + if let catalogID { + scored.catalog = CatalogRecipeMatch( + ref: .catalog(id: catalogID, revision: revision), successorToLegacyRecipeID: nil) + } + return scored + } + + private func count(_ db: DatabaseQueue, _ sql: String) throws -> Int { + try db.read { try Int.fetchOne($0, sql: sql) ?? 0 } + } + + // MARK: - Factory: catalog context + + func testFactoryBuildsContextFromTheCatalogPacket() throws { + let db = try makeDatabase() + try hydrate(db, revision: 3) + let repository = try makeRepository(db) + + let scored = scored( + catalogRecipe(), catalogID: Self.catalogId, revision: 3) + let context = LiveAssistantRecipeContextFactory(recipeRepository: repository) + .context(for: scored) + + XCTAssertEqual(context.catalogID, Self.catalogId) + XCTAssertEqual(context.catalogRevision, 3) + XCTAssertEqual(context.recipeID, nil) // catalog entries carry no legacy row + XCTAssertEqual(context.catalogSteps?.count, 2) + XCTAssertEqual(context.catalogSteps?[0].timerSeconds, 90) + XCTAssertEqual(context.catalogSteps?[1].thermometer?.endpointC, 74) + // The cook session runs the packet's steps, so the context text is the packet's + // own wording — not the legacy fallback instruction. + XCTAssertEqual( + context.instructions, "Beat the eggs in a bowl.\nCook gently until the centre just sets.") + } + + func testFactoryKeepsLegacyInstructionsForAnUnknownCatalogRef() throws { + let db = try makeDatabase() + try hydrate(db, revision: 3) + let repository = try makeRepository(db) + + let scored = scored(catalogRecipe(), catalogID: "rcp-does-not-exist", revision: 1) + let context = LiveAssistantRecipeContextFactory(recipeRepository: repository) + .context(for: scored) + + XCTAssertNil(context.catalogID) + XCTAssertNil(context.catalogRevision) + XCTAssertNil(context.steps) + XCTAssertEqual(context.instructions, "Beat the eggs.") + } + + func testFactoryKeepsLegacyPathForAResultWithAnIntegerID() throws { + let db = try makeDatabase() + try hydrate(db, revision: 3) + let repository = try makeRepository(db) + + var recipe = catalogRecipe() + recipe.id = 42 + let scored = scored(recipe, catalogID: nil, revision: 3) + let context = LiveAssistantRecipeContextFactory(recipeRepository: repository) + .context(for: scored) + + XCTAssertEqual(context.recipeID, 42) + XCTAssertNil(context.catalogID) + XCTAssertNil(context.steps) + XCTAssertEqual(context.instructions, "Beat the eggs.") + } + + // MARK: - Finalization: catalog log + + func testFinalizationLogsACatalogRecipeWithNoLegacyIDAgainstTheCookedRevision() async throws { + let db = try makeDatabase() + try hydrate(db, revision: 3) + let nutritionService = NutritionService(db: db) + let healthScoring = HealthScoringService(nutritionService: nutritionService, db: db) + let personalization = PersonalizationService(db: db) + let repository = RecipeRepository( + db: db, + nutritionService: nutritionService, + healthScoringService: healthScoring, + personalizationService: personalization) + + let vm = MealFinalizationViewModel( + recipe: catalogRecipe(), + defaultServings: 2, + catalogId: Self.catalogId, + catalogRevision: 3, + recipeRepository: repository, + personalizationService: personalization, + inventoryRepository: InventoryRepository(db: db)) + + await vm.save() + + XCTAssertNil(vm.errorMessage) + XCTAssertTrue(vm.didSave) + + // The catalog entry has no integer id, so the log resolves a legacy row to anchor + // the foreign key, and links the exact (catalogId, revision) that was cooked. + let historyRows = try count(db, "SELECT COUNT(*) FROM cooking_history") + XCTAssertEqual(historyRows, 1) + let refRows = try count( + db, + """ + SELECT COUNT(*) FROM recipes_cooking_catalog_refs + WHERE catalog_id = '\(Self.catalogId)' AND revision = 3 + """) + XCTAssertEqual(refRows, 1) + } + + func testFinalizationLogsALegacyRecipeWithoutACatalogRef() async throws { + let db = try makeDatabase() + try hydrate(db, revision: 3) + let nutritionService = NutritionService(db: db) + let healthScoring = HealthScoringService(nutritionService: nutritionService, db: db) + let personalization = PersonalizationService(db: db) + let repository = RecipeRepository( + db: db, + nutritionService: nutritionService, + healthScoringService: healthScoring, + personalizationService: personalization) + + var recipe = catalogRecipe() + recipe.id = 77 + let vm = MealFinalizationViewModel( + recipe: recipe, + defaultServings: 1, + recipeRepository: repository, + personalizationService: personalization, + inventoryRepository: InventoryRepository(db: db)) + + await vm.save() + + XCTAssertNil(vm.errorMessage) + XCTAssertTrue(vm.didSave) + XCTAssertEqual(try count(db, "SELECT COUNT(*) FROM cooking_history"), 1) + XCTAssertEqual( + try count(db, "SELECT COUNT(*) FROM recipes_cooking_catalog_refs"), 0) + } +} + +extension LiveAssistantContextAndFinalizationTests { + /// Two-ingredient, two-step catalog recipe: step 1 carries a 90 s timer, step 2 a + /// checkable thermometer endpoint with its pinned source. The schema requires at least + /// 2 ingredient lines and 2 steps (RecipeBundleV2 compile-time validation), so the + /// fixture carries butter alongside the egg. Same fixture shape as CatalogCookFlowTests. + fileprivate static func bundleJSON(revision: Int) -> String { + """ + { + "schema": "https://fridgeluck.app/schemas/recipe-bundle-v2.schema.json", + "bundle_version": 2, + "generated_at": "2026-10-16T00:00:00Z", + "cuisines": ["american", "asian", "indian", "italian", "mediterranean", "mexican"], + "ingredients": [ + { + "key": "egg", + "fdc_id": null, + "legacy_id": null, + "display_name": "Egg", + "category": "dairy_egg", + "per_100g": null, + "aliases": ["eggs"] + }, + { + "key": "butter", + "fdc_id": null, + "legacy_id": null, + "display_name": "Butter", + "category": "dairy_egg", + "per_100g": null, + "aliases": [] + } + ], + "recipes": [ + { + "catalog_id": "rcp-900001", + "revision": \(revision), + "title": "Catalog Scramble", + "cuisine": "american", + "meal_slots": ["breakfast"], + "collections": ["quick"], + "minutes": 12, + "servings": 2, + "equipment": [], + "ingredients": [ + { + "key": "egg", + "quantity": {"kind": "count", "amount": 2, "unit": "large", "grams": 100}, + "required": true, + "assumed_staple": false, + "note": null + }, + { + "key": "butter", + "quantity": {"kind": "household", "amount": 1, "unit": "tbsp", "grams": 14}, + "required": true, + "assumed_staple": false, + "note": null + } + ], + "steps": [ + { + "text": "Beat the eggs in a bowl.", + "uses": [0], + "timer_seconds": 90, + "thermometer": null + }, + { + "text": "Cook gently until the centre just sets.", + "uses": [0], + "timer_seconds": null, + "thermometer": {"endpoint_c": 74, "food": "scramble centre", "source": "usda-fsis: egg dishes 160 F"} + } + ], + "claims": {"vegetarian": true, "vegan": false, "gluten_free": true, "dairy_free": false}, + "nutrition_per_serving": { + "kcal": 58, + "protein_g": 24, + "carbs_g": 4, + "fat_g": 30, + "fiber_g": 1, + "sodium_mg": 520, + "coverage": "estimated", + "basis": "test fixture: household-to-gram estimates" + }, + "rights": {"status": "original", "evidence": "test fixture: original wording"}, + "keywords": [] + } + ] + } + """ + } +} diff --git a/apps/ios/Tests/Results/PreferredRecipeResolverTests.swift b/apps/ios/Tests/Results/PreferredRecipeResolverTests.swift new file mode 100644 index 000000000..aa211af3e --- /dev/null +++ b/apps/ios/Tests/Results/PreferredRecipeResolverTests.swift @@ -0,0 +1,82 @@ +import XCTest + +@testable import FridgeLuck + +/// The resolver maps an incoming preferred identity — a Home card, a demo scenario, a +/// replay — to the exact scored result: the legacy id when the entry has one, the +/// catalog ref when it does not. Catalog entries carry `Recipe(id: nil, …)`, so identity +/// can never ride the integer column alone. +final class PreferredRecipeResolverTests: XCTestCase { + private func fixture( + id: Int64?, + title: String, + catalogID: String? = nil + ) -> ScoredRecipe { + var scored = ScoredRecipe( + recipe: Recipe( + id: id, + title: title, + timeMinutes: 10, + servings: 2, + instructions: "Cook.", + tags: 0, + source: .bundled, + createdAt: nil), + matchedRequired: 1, + totalRequired: 1, + matchedOptional: 0, + missingRequiredCount: 0, + missingIngredientIds: [], + macros: RecipeMacros( + caloriesPerServing: 100, proteinPerServing: 5, carbsPerServing: 10, + fatPerServing: 3, fiberPerServing: 1, sugarPerServing: 1, sodiumPerServing: 50), + healthScore: HealthScore(rating: 4, label: "Good", reasoning: "test"), + personalScore: 0.5, + rankingScore: 1.0, + rankingReasons: [], + matchTier: .exact) + if let catalogID { + scored.catalog = CatalogRecipeMatch( + ref: .catalog(id: catalogID, revision: 1), successorToLegacyRecipeID: nil) + } + return scored + } + + func testNilPreferredPresentsNothing() { + let results = [fixture(id: 1, title: "Legacy")] + XCTAssertNil(PreferredRecipeResolver.resolve(preferredID: nil, in: results)) + } + + func testLegacyIdentityResolvesByIntegerID() { + let legacy = fixture(id: 7, title: "Legacy Pancakes") + let catalog = fixture(id: nil, title: "Catalog Pancakes", catalogID: "rcp-000123") + let resolved = PreferredRecipeResolver.resolve( + preferredID: .legacy(7), in: [catalog, legacy]) + XCTAssertEqual(resolved?.id, .legacy(7)) + XCTAssertEqual(resolved?.recipe.title, "Legacy Pancakes") + } + + func testCatalogIdentityResolvesByRefWhenRecipeIDIsNil() { + let legacy = fixture(id: 7, title: "Legacy Pancakes") + let catalog = fixture(id: nil, title: "Catalog Pancakes", catalogID: "rcp-000123") + let resolved = PreferredRecipeResolver.resolve( + preferredID: .catalog("rcp-000123"), in: [legacy, catalog]) + XCTAssertEqual(resolved?.id, .catalog("rcp-000123")) + XCTAssertEqual(resolved?.recipe.title, "Catalog Pancakes") + } + + func testUnmatchedIdentityReturnsNil() { + let results = [fixture(id: 7, title: "Legacy Pancakes")] + XCTAssertNil(PreferredRecipeResolver.resolve(preferredID: .legacy(8), in: results)) + XCTAssertNil( + PreferredRecipeResolver.resolve(preferredID: .catalog("rcp-404"), in: results)) + } + + func testFirstMatchWinsOnDuplicateIdentities() { + let first = fixture(id: 7, title: "First") + let second = fixture(id: 7, title: "Second") + let resolved = PreferredRecipeResolver.resolve( + preferredID: .legacy(7), in: [first, second]) + XCTAssertEqual(resolved?.recipe.title, "First") + } +} diff --git a/apps/ios/Tests/ReverseScan/ReverseScanCandidateHandleTests.swift b/apps/ios/Tests/ReverseScan/ReverseScanCandidateHandleTests.swift new file mode 100644 index 000000000..ffed6532b --- /dev/null +++ b/apps/ios/Tests/ReverseScan/ReverseScanCandidateHandleTests.swift @@ -0,0 +1,85 @@ +import XCTest + +@testable import FridgeLuck + +/// Cloud ranking keys every round trip on an integer `recipe_id`. Legacy candidates ride +/// their real positive id; catalog candidates — `Recipe(id: nil, …)` — ride a negative +/// alias derived from list position. The two spaces cannot collide, and the agent and +/// the service both read the rule from `ReverseScanCandidateHandle`, so a ranking can +/// never map back to the wrong candidate. +final class ReverseScanCandidateHandleTests: XCTestCase { + private func candidate(id: Int64?, title: String, catalogID: String? = nil) + -> ReverseScanRecipeCandidate + { + var scored = ScoredRecipe( + recipe: Recipe( + id: id, + title: title, + timeMinutes: 10, + servings: 2, + instructions: "Cook.", + tags: 0, + source: .bundled, + createdAt: nil), + matchedRequired: 1, + totalRequired: 1, + matchedOptional: 0, + missingRequiredCount: 0, + missingIngredientIds: [], + macros: RecipeMacros( + caloriesPerServing: 100, proteinPerServing: 5, carbsPerServing: 10, + fatPerServing: 3, fiberPerServing: 1, sugarPerServing: 1, sodiumPerServing: 50), + healthScore: HealthScore(rating: 4, label: "Good", reasoning: "test"), + personalScore: 0.5, + rankingScore: 1.0, + rankingReasons: [], + matchTier: .exact) + if let catalogID { + scored.catalog = CatalogRecipeMatch( + ref: .catalog(id: catalogID, revision: 1), successorToLegacyRecipeID: nil) + } + return ReverseScanRecipeCandidate( + recipe: scored, confidenceScore: 0.5, matchedDetectedIngredients: 1, explanation: nil) + } + + func testLegacyCandidatesKeepTheirPositiveID() { + let handles = ReverseScanCandidateHandle.handles(for: [ + candidate(id: 12, title: "A"), candidate(id: 345, title: "B"), + ]) + XCTAssertEqual(handles, [12, 345]) + } + + func testCatalogCandidatesRideNegativeAliasesInOrder() { + let handles = ReverseScanCandidateHandle.handles(for: [ + candidate(id: nil, title: "First", catalogID: "rcp-000001"), + candidate(id: nil, title: "Second", catalogID: "rcp-000002"), + ]) + XCTAssertEqual(handles, [-1, -2]) + } + + func testMixedListKeepsBothSpacesDisjoint() { + let handles = ReverseScanCandidateHandle.handles(for: [ + candidate(id: 1, title: "Legacy 1"), + candidate(id: nil, title: "Catalog", catalogID: "rcp-000009"), + candidate(id: 2, title: "Legacy 2"), + candidate(id: nil, title: "Draft"), // unsaved generated draft: negative alias too + ]) + XCTAssertEqual(handles, [1, -1, 2, -2]) + } + + func testNegativeAliasesNeverCollideWithLegacyIDs() { + // Legacy ids are row keys — always positive — so a mixed list can never send the + // same handle twice with different identities. + let handles = ReverseScanCandidateHandle.handles(for: [ + candidate(id: 1, title: "Legacy"), + candidate(id: nil, title: "Catalog A", catalogID: "rcp-000001"), + candidate(id: nil, title: "Catalog B", catalogID: "rcp-000002"), + ]) + XCTAssertEqual(Set(handles).count, handles.count) + XCTAssertTrue(handles.allSatisfy { $0 != 0 }) + } + + func testEmptyListProducesNoHandles() { + XCTAssertTrue(ReverseScanCandidateHandle.handles(for: []).isEmpty) + } +} From d84b6ca71215a4afa5ee19fc26c035ac3a72a461 Mon Sep 17 00:00:00 2001 From: Obvious Date: Fri, 16 Oct 2026 02:54:09 +0000 Subject: [PATCH 09/10] fix(ios): number catalog handles per space and pin the legacy finalization id The portable harness run surfaced two issues. ReverseScanCandidateHandle numbered catalog candidates off the global list index, so a catalog entry between legacy rows got -2 and -4 instead of its own counter (-1, -2); both ranking request paths and the map-back share the enum, so the fix keeps them consistent. The legacy finalization test fabricated a recipe id with no backing row and tripped the cooking_history foreign key; the sheet in production receives recipes loaded from the store, so the test now pins the id through resolvePersistedRecipeID over the hydrated mirror row, and the two context assertions use the real member name (catalogSteps). Verified: portable harness swift test 15/15 (5 resolver, 5 handle, 5 context-and-finalization) after both fixes, fails-first confirmed with the red run before them. Co-authored-by: Sam Gu Co-authored-by: obvious-autobuild[bot] <262744130+obvious-autobuild[bot]@users.noreply.github.com> --- .../Intelligence/ReverseScanCandidateHandle.swift | 14 +++++++++++--- .../LiveAssistantContextAndFinalizationTests.swift | 9 ++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/ios/Capability/Core/Intelligence/ReverseScanCandidateHandle.swift b/apps/ios/Capability/Core/Intelligence/ReverseScanCandidateHandle.swift index 4d5816c18..a7ef703ff 100644 --- a/apps/ios/Capability/Core/Intelligence/ReverseScanCandidateHandle.swift +++ b/apps/ios/Capability/Core/Intelligence/ReverseScanCandidateHandle.swift @@ -8,10 +8,18 @@ import Foundation /// builds the request and the service maps rankings back through the same rule, so the /// pairing lives in one tested place, not in an agreed-on convention. enum ReverseScanCandidateHandle { - /// The handle for each candidate, in the order the candidates will be sent. + /// The handle for each candidate, in the order the candidates will be sent. Legacy + /// recipes keep their positive row id; catalog candidates number from -1 in their own + /// counter — a global position offset would leave holes (-2, -4, …) between legacy + /// entries and make the handle space look sparser than it is to the ranking backend. static func handles(for candidates: [ReverseScanRecipeCandidate]) -> [Int64] { - candidates.enumerated().map { index, candidate in - candidate.recipe.recipe.id ?? -Int64(index + 1) + var catalogHandle = Int64(0) + return candidates.map { candidate in + guard let id = candidate.recipe.recipe.id else { + catalogHandle += 1 + return -catalogHandle + } + return id } } } diff --git a/apps/ios/Tests/CatalogCookFlow/LiveAssistantContextAndFinalizationTests.swift b/apps/ios/Tests/CatalogCookFlow/LiveAssistantContextAndFinalizationTests.swift index 7ac8a5c0f..9109c8030 100644 --- a/apps/ios/Tests/CatalogCookFlow/LiveAssistantContextAndFinalizationTests.swift +++ b/apps/ios/Tests/CatalogCookFlow/LiveAssistantContextAndFinalizationTests.swift @@ -117,7 +117,7 @@ final class LiveAssistantContextAndFinalizationTests: XCTestCase { XCTAssertNil(context.catalogID) XCTAssertNil(context.catalogRevision) - XCTAssertNil(context.steps) + XCTAssertNil(context.catalogSteps) XCTAssertEqual(context.instructions, "Beat the eggs.") } @@ -134,7 +134,7 @@ final class LiveAssistantContextAndFinalizationTests: XCTestCase { XCTAssertEqual(context.recipeID, 42) XCTAssertNil(context.catalogID) - XCTAssertNil(context.steps) + XCTAssertNil(context.catalogSteps) XCTAssertEqual(context.instructions, "Beat the eggs.") } @@ -192,7 +192,10 @@ final class LiveAssistantContextAndFinalizationTests: XCTestCase { personalizationService: personalization) var recipe = catalogRecipe() - recipe.id = 77 + // A legacy finalization carries a persisted recipes.id — the same contract the sheet + // sees in production, where the recipe object came from the store. The resolver + // returns the hydrated legacy mirror row's id (title match), so the FK holds. + recipe.id = try repository.resolvePersistedRecipeID(for: catalogRecipe()) let vm = MealFinalizationViewModel( recipe: recipe, defaultServings: 1, From 25f23ad98e39a34aaf94c2840e6bc159cabf42bb Mon Sep 17 00:00:00 2001 From: Obvious Date: Fri, 16 Oct 2026 03:05:34 +0000 Subject: [PATCH 10/10] fix(ios): finish the preferredID rename across Results routing macOS CI caught what the Linux portable check cannot compile: the RecipeResultsView init still declared preferredRecipeID: Int64? against the stored preferredID: ScoredRecipeID?, ContentView passed route.preferredRecipeID (a member the route struct does not have), and the HomeRecommendation fallback construction still passed recipeID: scored.recipe.id. All three now go through the scored identity: init param preferredID: ScoredRecipeID?, call sites route.preferredID / recommendation.preferredID, fallbacks scored.id. Verified: swiftc -parse clean on ContentView, RecipeResultsView, and HomeDashboardViewModel; repo-wide grep shows no preferredRecipeID or recipeID: references remain in Home/Results/ContentView paths. Co-authored-by: Sam Gu Co-authored-by: obvious-autobuild[bot] <262744130+obvious-autobuild[bot]@users.noreply.github.com> --- apps/ios/App/ContentView.swift | 4 ++-- apps/ios/Feature/Home/HomeDashboardViewModel.swift | 2 +- apps/ios/Feature/Results/RecipeResultsView.swift | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/ios/App/ContentView.swift b/apps/ios/App/ContentView.swift index 1af9f8dd0..e45ac135c 100644 --- a/apps/ios/App/ContentView.swift +++ b/apps/ios/App/ContentView.swift @@ -358,7 +358,7 @@ struct ContentView: View { RecipeResultsView( ingredientIds: route.ingredientIds, ingredientNames: route.ingredientNames, - preferredRecipeID: route.preferredRecipeID, + preferredID: route.preferredID, logsMeals: true, engine: deps.makeRecommendationEngine() ) @@ -624,7 +624,7 @@ struct ContentView: View { homeRecommendationRoute = HomeRecommendationRoute( ingredientIds: recommendation.ingredientIDs, ingredientNames: [], - preferredRecipeID: recommendation.recipeID + preferredID: recommendation.preferredID ) } diff --git a/apps/ios/Feature/Home/HomeDashboardViewModel.swift b/apps/ios/Feature/Home/HomeDashboardViewModel.swift index 62d572420..27fa17130 100644 --- a/apps/ios/Feature/Home/HomeDashboardViewModel.swift +++ b/apps/ios/Feature/Home/HomeDashboardViewModel.swift @@ -113,7 +113,7 @@ final class HomeDashboardViewModel: ObservableObject { } fallbacks = Array(all.dropFirst().prefix(3)).map { scored in HomeRecommendation( - recipeID: scored.recipe.id, + preferredID: scored.id, recipeName: scored.recipe.title, explanation: scored.rankingReasons.prefix(2).joined(separator: ". "), cookTimeMinutes: scored.recipe.timeMinutes, diff --git a/apps/ios/Feature/Results/RecipeResultsView.swift b/apps/ios/Feature/Results/RecipeResultsView.swift index f4fd921e8..5533decd4 100644 --- a/apps/ios/Feature/Results/RecipeResultsView.swift +++ b/apps/ios/Feature/Results/RecipeResultsView.swift @@ -40,7 +40,7 @@ struct RecipeResultsView: View { ingredientNames: [String] = [], fridgePhoto: UIImage? = nil, scanConfidenceScore: Double? = nil, - preferredRecipeID: Int64? = nil, + preferredID: ScoredRecipeID? = nil, includesKitchenInventory: Bool = false, logsMeals: Bool, engine: RecommendationEngine, @@ -50,7 +50,7 @@ struct RecipeResultsView: View { self.ingredientNames = ingredientNames self.fridgePhoto = fridgePhoto self.scanConfidenceScore = scanConfidenceScore - self.preferredRecipeID = preferredRecipeID + self.preferredID = preferredID self.includesKitchenInventory = includesKitchenInventory self.logsMeals = logsMeals _replaySpotlightPending = State(initialValue: replaySpotlightOnAppear)