Skip to content

feat(ios): weekly horizon for the LeChef plan — one allocator, repeats priced, leftovers carried - #178

Open
obvious-autobuild[bot] wants to merge 7 commits into
obv/burst6-fridgeluck-catalog-recommendfrom
obv/burst6-fridgeluck-weekly-plan
Open

obvious-autobuild[bot] wants to merge 7 commits into
obv/burst6-fridgeluck-catalog-recommendfrom
obv/burst6-fridgeluck-weekly-plan

Conversation

@obvious-autobuild

@obvious-autobuild obvious-autobuild Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

feat(ios): weekly horizon for the LeChef plan — one allocator, repeats priced, leftovers carried

Human author: Sam Gu (sgu07966@gmail.com)

Stacked on #172 (base obv/burst6-fridgeluck-catalog-recommend, merged at 832c5921).

What changed and why

SPEC.md §14 on #52 (marked "Adapt"): five-to-seven-dinner plans run the same allocator core across the whole week instead of a second planner.

  • PlanAllocating gains PlanWeeklyPolicy (eaters-per-dinner, repeat penalty, consecutive-main-ingredient penalty). nil — the default on PlanRequest — keeps three-dinner behavior byte-for-byte: repeats excluded outright, no surplus carry. With a policy, repeats become rankable but penalized (+1000g coverage-equivalent), the same main two days in a row is penalized (+500g), and a dinner cooked above eaters-per-dinner leaves surplus that can cover a later leftovers slot (no new reservations; the slot re-lists the earlier dinner's dish).
  • Persistence: new migration cook_plan_leftover_carry_v1 adds cook_plan_dinner.leftover_of_dinner_id; save writes it, load selects and restores it, and PlannedDinner carries it (nil for fresh dinners). A reload of a saved week distinguishes a leftovers night from a planned one.
  • PlanDataAdapter.loadSnapshot merges bundle-v2 catalog recipes alongside legacy ones through the ingredient-identity bridge (PR feat(ios): hydrate recipe bundle-v2 catalog with legacy fallback and revision history #151 convention: one key → smallest bridged app-ingredient id). Unresolved keys stay as stable negative-sentinel lines with unknown quantity (quantity_unknown attention, never a purchase); an unresolved staple line is dropped (households have it); a resolved line the bundle flags assumed_staple joins the staples set; and a catalog successor yields to its still-usable legacy recipe. Allergen filtering stays where it was — candidates remain a raw inventory, the blocked ids ride eaters, and allocation enforces the hard filter.
  • PlanSheetViewModel: five-to-seven dinners select the weekly policy with the shipped penalties; three-to-four keep the strict default.

How I checked it

  • New LeChefPlanWeeklyTests.swift (16 tests): weekly repeats and consecutive-main penalties, leftover carry, short-line cap in weekly mode, allergen exclusion through allocation, plan round-trip through the cook-plan tables, catalog merging/staples/sentinel/successor behavior.
  • Full suite on Linux (SwiftPM harness, GRDB 7.11.1, the repo's shipped substitutions-v1.json): 44 tests, 0 failures at merged head 30436d26 (swift test -Xswiftc -DSQLITE_DISABLE_SNAPSHOT). git status clean before push.
  • Hosted macOS CI on this PR: build-and-test passed at head 121244d5 (run 38818234422).
  • The base branch advanced during this work (9f5a701 → 832c592, reverse-scan/preview files only — no overlap with this delta); merged cleanly and re-ran the suite on the merged tree.

What I could not check

  • No Xcode here: the full iOS app build (xcodegen project, SwiftUI compile, macOS-hosted build-and-test and dead-code-advisory) was not run locally. CI on this PR covers it.
  • Allocator behavior beyond the unit fixtures (real pantry data, seven-slot saturation) is not exercised.

What I assumed

  • Leftovers nights reserve nothing new and list the earlier dinner's dish; their leftoverOfDinnerID points at the covering dinner, and the snapshot test pins the drop of an unresolved staple line rather than a sentinel for it.
  • Weekly penalties (1000/500 coverage-equivalent grams) are view-model constants per the shipped defaults; test controls zero them explicitly when asserting pure ordering.
  • The allergen hard filter is enforced at allocation via eaters (existing convention), so the adapter test asserts successor-return plus allocation-time exclusion, not snapshot filtering.

Review round (independent review — five findings, all accepted and fixed)

Fixed at head 1ca4ec2a; each finding is pinned by a regression test that fails before its fix and passes after (verified on the Linux harness, not just asserted).

# Finding Fix Failing-first test
1 Reviewed swaps ignored eater-blocked allergens — a chicken→tofu swap could cover a chicken shortfall for a household blocking tofu. (Both surfaces: the snapshot offered the swap, and the allocator applied it.) PlanDataAdapter.loadSnapshot drops any swap whose substitute is a household allergen; PlanAllocating.coveringSubstitution additionally refuses substitutes an eater blocks, so allocator-level callers are covered too. testSnapshotFiltersBlockedSubstitutes (pre-fix: the blocked swap was offered) and testSwapCandidateRespectsBlockedIngredients (pre-fix: line sufficient with the blocked swap applied)
2 Within one dinner, a line's swap read the full pool, double-counting substitute stock shared with another line of the same dinner — the substitute line looked sufficient and the real remainder never reached the shopping list. allocateDinner keeps a claimed ledger: every line (original or substitute consumption) reads the pool minus what earlier lines of the same dinner claimed; swap eligibility checks the claimed-adjusted substitute stock too. testSwapClaimReservesStockWithinADinner (pre-fix: tofu line sufficient, shopping list empty while 50 g was actually missing)
3 Leftover surplus was pooled across dinners: two dishes leaving one portion each could promise a leftovers night "covered" by a dish that had no portions left (the slot pointed at the most recent dinner, not a covering one). Surplus is tracked per cooked dinner (surplusByDinner); a leftovers night fires only when the referenced dinner's own surplus covers a full round of eaters, and it re-serves that dish. testLeftoverCarryNeedsAFullRoundOfEaters — rewritten from pooled to per-dinner semantics (pre-fix: a pooled leftovers night was promised); the existing positive leftovers tests still pass unchanged
4 Saved catalog dinners reloaded recipeDatabaseID == 0 (load parsed RecipeRef.id as a legacy integer only), so replacement and exclusion lost their identity after a reload. load reconstructs both conventions: positive integer ids pass through, rcp-… refs take the same negative digit-tail identity the snapshot assigns. testSavedCatalogDinnersReloadTheirDatabaseID (pre-fix: reloaded 0, expected -201)
5 Catalog assumed_staple defaults unioned over an explicit household only_if_confirmed tier, silently re-assuming an ingredient the household downgraded. The snapshot subtracts every household-tiered ingredient from the catalog staple defaults; the household's explicit tier wins either way. testHouseholdStapleChoiceSurvivesTheCatalog (pre-fix: egg re-assumed as staple)

One test was removed rather than fixed: the drafted testLeftoverNightIsCoveredByTheDishItReServes described a state the corrected per-dinner model cannot reach (a leftovers slot always fires while any dinner holds a full round, so "most recent cannot cover, an earlier one can" is unreachable); per-dinner coverage is already pinned by finding 3's test plus the two existing positive leftovers tests.

Verification for this round: failing-first run on the Linux harness — the six review-round tests failed 11 assertions reproducing the findings exactly; after the fixes, the full harness passes 48 tests, 0 failures (swift test -Xswiftc -DSQLITE_DISABLE_SNAPSHOT at 1ca4ec2a). Hosted macOS CI on this head: build-and-test and dead-code-advisory passed (run 38836748879 at 1ca4ec2a). One review-fix commit, PR state otherwise unchanged (not merged, workflows untouched).

Inherited CI note

The base lineage carries two pre-existing test-target breakages, both in other threads' files and both masked behind each other in CI (each aborted compilation before the next file type-checked):

  1. RecipeCatalogRecommendTests.swift:179 cannot find 'CookThermometer' in scope — fixed on the base at 38263783 (explicit FLFeatureLogic import).
  2. TasteEventStoreTests.swift:50 — the cookedEvent helper declared -> TasteEvent while constructing TasteEventRecord (seven type errors; store.append takes the record type). Present verbatim at this branch's base point 9f5a7017 (verified with git show). Fixed here by cherry-picking the trunk integration's one-line fix 9de29858 (CI-green at trunk, run 38805706914) as 121244d5. If the base or trunk lands its own fix, this merge resolves to identical content.

Every earlier build-and-test failure visible from this PR was inherited, not introduced by this delta. Resolved: the rerun at 121244d5 passed (run 38818234422) — nothing else surfaced.

🔗 Obvious Project · 🧵 Obvious Thread · 🔀 Obvious PR Artifact

ObviousApp and others added 5 commits October 15, 2026 22:25
SPEC.md §14 on #52 (adapt): the same allocator core across five-to-seven
slots — repeats become rankable but penalized, the same main ingredient
two days in a row is penalized, and a dinner's surplus servings can cover
a later leftovers slot. A nil policy keeps the three-dinner behavior
byte-for-byte.

Co-authored-by: Sam Gu <sgu07966@gmail.com>
Persist the weekly horizon's leftovers marker (cook_plan_leftover_carry_v1)
through save/load, read the household's dietary restrictions to screen
reviewed substitutions, and let loadSnapshot merge bundle-v2 catalog recipes
alongside legacy ones through the ingredient-identity bridge: unresolved keys
stay as negative-sentinel unknown-quantity lines, assumed staples join the
staples set, and a catalog successor yields to its still-usable legacy recipe.

Co-authored-by: Sam Gu <sgu07966@gmail.com>
Five-to-seven dinners run the weekly policy with the shipped repeat and
consecutive-main penalties; three-to-four keep the strict no-repeat default.

Co-authored-by: Sam Gu <sgu07966@gmail.com>
Allocator coverage for repeats, consecutive mains, leftover carry, the
short-line cap in weekly mode, and allergen exclusion; persistence
round-trips through the cook-plan tables; adapter coverage for catalog
merging, staples, sentinel lines, and successor yielding.

Co-authored-by: Sam Gu <sgu07966@gmail.com>
The base branch advanced past 9f5a701 while its build-and-test remains red
(CookThermometer unresolved in RecipeCatalogRecommendTests.swift, owned by
the catalog-recommend thread); this work builds on the latest head per the
task's fallback.

Co-authored-by: Sam Gu <sgu07966@gmail.com>
@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 25c0ec98-2c8c-470b-af25-1f7ff788183b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The shared lineage's TasteEventStoreTests.swift declared the helper
'-> TasteEvent' while constructing 'TasteEventRecord', so the iOS test
target stopped compiling. Earlier base-branch CI runs aborted at the
CookThermometer error before type-checking this file, masking it; with
that import fixed, the seven type errors surface in this PR's merge
preview. One-line fix cherry-picked from the trunk integration's
9de2985 (CI-green there, run 38805706914).

(cherry picked from commit 9de2985)

Co-authored-by: Sam Gu <sgu07966@gmail.com>
@codemetrics-ai

Copy link
Copy Markdown

Note

Review skipped

Codemetrics is installed on this repository, but it hasn't been added to your team yet.

To enable AI code reviews for this repo, go to your dashboard and add FridgeLuck to your team's repositories.

@greptile-apps

greptile-apps Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 2/5

[High impact] Adds weekly meal-planning logic with database schema change.

Do not merge until swaps respect blocked allergens and available stock, and leftovers name portions that actually exist.

Fix All in Claude CodeFindings

  1. P1 Swaps bypass blocked allergens ▶
  2. P1 Swaps double-count pantry stock ▶
  3. P1 Leftovers promise the wrong dish ▶
  4. P2 Saved catalog IDs become zero ▶
  5. P2 Catalog overrides household staple choices ▶
Fix with agent prompt
### Issue 1
apps/ios/Platform/Persistence/KitchenPlan/PlanDataAdapter.swift:130-136
The new substitution list screens dietary restrictions but not blocked ingredient IDs. With `allergen_ingredient_ids = [23]`, no dietary restrictions, some chicken stock, and enough tofu stock, the shipped chicken→tofu swap remains available. `restrictionClean` checks only the original recipe, and `coveringSubstitution` never checks the blocked IDs. The plan can therefore mark the dinner covered using tofu that the household explicitly blocked.

Filter substitutes against every eater's blocked ingredients before allocation.

### Issue 2
apps/ios/Platform/Persistence/KitchenPlan/PlanDataAdapter.swift:133-136
Supplying swaps here exposes double-counted stock within a dinner. `allocateDinner` reads the same unchanged pool for every line and swap. For a bill needing 100 g chicken and 100 g tofu, with 50 g chicken and 100 g tofu available, the chicken line can claim 50 g tofu as a swap. The tofu line can then claim all 100 g again. Both lines become sufficient, and the shopping list misses 50 g.

Reserve stock in a temporary pool while trying each candidate, then commit only the chosen candidate's use.

### Issue 3
apps/ios/FeatureLogic/CookMode/Plan/PlanAllocating.swift:198-206
`pendingLeftoverServings` pools portions from different dishes, but the leftovers dinner lists only `lastCooked`. Cook two different dishes for three servings each with two eaters: each leaves one portion, yet slot three promises two portions of the second dish. Its `leftoverOfDinnerID` points only to that dinner, which has just one portion left.

Track surplus per dinner and require the referenced dinner to cover the slot, or list every dish contributing to a mixed leftovers dinner.

### Issue 4
apps/ios/Platform/Persistence/KitchenPlan/PlanDataAdapter.swift:227-228
Catalog dinners now use IDs such as `rcp-000201` and numeric identity `-201`, but `load(planID:)` still reconstructs their identity with `Int64(recipe.id) ?? 0`. Every such dinner reloads with `recipeDatabaseID = 0`. Loading is currently used only by tests, but future restore callers would lose the ID needed to exclude a dinner during replacement or regeneration.

Preserve the catalog identity when loading, and add a round-trip test using a real catalog ID. The new persistence test uses numeric strings and misses this case.

### Issue 5
apps/ios/Platform/Persistence/KitchenPlan/PlanDataAdapter.swift:113
The catalog staple union overrides an explicit `only_if_confirmed` household choice. If a bridged ingredient has that household tier but any catalog line marks it `assumed_staple`, it is added back to `allStaples`. With no stock, the allocator then marks it assumed and strikes through its shopping entry instead of treating it as missing.

Apply catalog defaults only when the household has not explicitly chosen a tier.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds weekly planning through the existing allocator.

  • Longer plans can reuse recipes and carry surplus into later dinners.
  • Plan snapshots include bridged catalog recipes and reviewed swaps.
  • Saved plans keep the link from a leftovers night to its cooked dinner.
  • The taste event test helper returns the record it builds.

Three blocking issues remain: swaps can use blocked allergens, swaps can double-count stock within a dinner, and pooled leftovers can promise portions of the wrong dish. Catalog ID round-tripping and household staple overrides also need attention.

Acknowledged behavior: obvious-autobuild[bot] explicitly documented dropping unresolved staple lines as intentional because households have them and no ingredient identity is available. obvious-autobuild[bot] also explicitly documented empty leftovers lines as intentional because leftovers nights reserve nothing new.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Pantry and household preferences] --> B[PlanDataAdapter snapshot]
  C[Legacy and catalog recipes] --> B
  D[Reviewed swaps] --> B
  B --> E[PlanAllocating]
  E --> F[Rank dinner with weekly penalties]
  F --> G[Reserve pantry stock]
  G --> H[Carry surplus servings]
  H --> F
  E --> I[Planned dinners and shopping list]
  I --> J[Save source dinner for leftovers]
Loading

Reviews (1) · Last reviewed commit: "fix(ios): cookedEvent helper returns the..." · Reviewed by Greptile

Comment on lines +130 to +136
for swap in substitutionService.substitutions(
for: ingredientID, dietaryRestrictions: dietaryRestrictions) {
guard swap.ratio > 0 else { continue }
substitutions.append(
PlanSubstitution(
originalID: swap.originalId, substituteID: swap.substituteId,
substituteGramsPerOriginalGram: swap.ratio, note: swap.note, reviewed: true))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Swaps bypass blocked allergens

The new substitution list screens dietary restrictions but not blocked ingredient IDs. With allergen_ingredient_ids = [23], no dietary restrictions, some chicken stock, and enough tofu stock, the shipped chicken→tofu swap remains available. restrictionClean checks only the original recipe, and coveringSubstitution never checks the blocked IDs. The plan can therefore mark the dinner covered using tofu that the household explicitly blocked.

Filter substitutes against every eater's blocked ingredients before allocation.

Knowledge Base Used: Recipe recommendation and cooking

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/ios/Platform/Persistence/KitchenPlan/PlanDataAdapter.swift
Line: 130-136

Comment:
**Swaps bypass blocked allergens**

The new substitution list screens dietary restrictions but not blocked ingredient IDs. With `allergen_ingredient_ids = [23]`, no dietary restrictions, some chicken stock, and enough tofu stock, the shipped chicken→tofu swap remains available. `restrictionClean` checks only the original recipe, and `coveringSubstitution` never checks the blocked IDs. The plan can therefore mark the dinner covered using tofu that the household explicitly blocked.

Filter substitutes against every eater's blocked ingredients before allocation.

**Knowledge Base Used:** [Recipe recommendation and cooking](https://app.greptile.com/s-gu/-/custom-context/knowledge-base/samgu-nrx/fridgeluck/-/docs/recipe-recommendation-and-cooking.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment on lines +133 to +136
substitutions.append(
PlanSubstitution(
originalID: swap.originalId, substituteID: swap.substituteId,
substituteGramsPerOriginalGram: swap.ratio, note: swap.note, reviewed: true))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Swaps double-count pantry stock

Supplying swaps here exposes double-counted stock within a dinner. allocateDinner reads the same unchanged pool for every line and swap. For a bill needing 100 g chicken and 100 g tofu, with 50 g chicken and 100 g tofu available, the chicken line can claim 50 g tofu as a swap. The tofu line can then claim all 100 g again. Both lines become sufficient, and the shopping list misses 50 g.

Reserve stock in a temporary pool while trying each candidate, then commit only the chosen candidate's use.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/ios/Platform/Persistence/KitchenPlan/PlanDataAdapter.swift
Line: 133-136

Comment:
**Swaps double-count pantry stock**

Supplying swaps here exposes double-counted stock within a dinner. `allocateDinner` reads the same unchanged pool for every line and swap. For a bill needing 100 g chicken and 100 g tofu, with 50 g chicken and 100 g tofu available, the chicken line can claim 50 g tofu as a swap. The tofu line can then claim all 100 g again. Both lines become sufficient, and the shopping list misses 50 g.

Reserve stock in a temporary pool while trying each candidate, then commit only the chosen candidate's use.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment on lines +198 to +206
if let eaters = request.weekly?.eatersPerDinner, eaters >= 1,
pendingLeftoverServings >= eaters, let carried = lastCooked {
pendingLeftoverServings -= eaters
let dinnerID = "\(request.planID)-d\(position)"
dinners.append(
PlannedDinner(
position: position, dinnerID: dinnerID, recipe: carried.ref,
recipeDatabaseID: carried.databaseID, title: carried.title, servings: eaters,
timeMinutes: carried.timeMinutes, lines: [], leftoverOfDinnerID: carried.dinnerID))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Leftovers promise the wrong dish

pendingLeftoverServings pools portions from different dishes, but the leftovers dinner lists only lastCooked. Cook two different dishes for three servings each with two eaters: each leaves one portion, yet slot three promises two portions of the second dish. Its leftoverOfDinnerID points only to that dinner, which has just one portion left.

Track surplus per dinner and require the referenced dinner to cover the slot, or list every dish contributing to a mixed leftovers dinner.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/ios/FeatureLogic/CookMode/Plan/PlanAllocating.swift
Line: 198-206

Comment:
**Leftovers promise the wrong dish**

`pendingLeftoverServings` pools portions from different dishes, but the leftovers dinner lists only `lastCooked`. Cook two different dishes for three servings each with two eaters: each leaves one portion, yet slot three promises two portions of the second dish. Its `leftoverOfDinnerID` points only to that dinner, which has just one portion left.

Track surplus per dinner and require the referenced dinner to cover the slot, or list every dish contributing to a mixed leftovers dinner.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment on lines +227 to +228
databaseID: Self.catalogDatabaseID(recipe.catalogID),
ref: RecipeRef(source: .catalog, id: recipe.catalogID, revision: recipe.revision),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Saved catalog IDs become zero

Catalog dinners now use IDs such as rcp-000201 and numeric identity -201, but load(planID:) still reconstructs their identity with Int64(recipe.id) ?? 0. Every such dinner reloads with recipeDatabaseID = 0. Loading is currently used only by tests, but future restore callers would lose the ID needed to exclude a dinner during replacement or regeneration.

Preserve the catalog identity when loading, and add a round-trip test using a real catalog ID. The new persistence test uses numeric strings and misses this case.

Knowledge Base Used: Mobile domain model and persistence

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/ios/Platform/Persistence/KitchenPlan/PlanDataAdapter.swift
Line: 227-228

Comment:
**Saved catalog IDs become zero**

Catalog dinners now use IDs such as `rcp-000201` and numeric identity `-201`, but `load(planID:)` still reconstructs their identity with `Int64(recipe.id) ?? 0`. Every such dinner reloads with `recipeDatabaseID = 0`. Loading is currently used only by tests, but future restore callers would lose the ID needed to exclude a dinner during replacement or regeneration.

Preserve the catalog identity when loading, and add a round-trip test using a real catalog ID. The new persistence test uses numeric strings and misses this case.

**Knowledge Base Used:** [Mobile domain model and persistence](https://app.greptile.com/s-gu/-/custom-context/knowledge-base/samgu-nrx/fridgeluck/-/docs/mobile-domain-and-persistence.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

let id: Int64 = row["ingredient_id"]
return stapleTiers.contains(tier) ? id : nil
})
let allStaples = staples.union(catalog.stapleIngredientIDs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Catalog overrides household staple choices

The catalog staple union overrides an explicit only_if_confirmed household choice. If a bridged ingredient has that household tier but any catalog line marks it assumed_staple, it is added back to allStaples. With no stock, the allocator then marks it assumed and strikes through its shopping entry instead of treating it as missing.

Apply catalog defaults only when the household has not explicitly chosen a tier.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/ios/Platform/Persistence/KitchenPlan/PlanDataAdapter.swift
Line: 113

Comment:
**Catalog overrides household staple choices**

The catalog staple union overrides an explicit `only_if_confirmed` household choice. If a bridged ingredient has that household tier but any catalog line marks it `assumed_staple`, it is added back to `allStaples`. With no stock, the allocator then marks it assumed and strikes through its shopping entry instead of treating it as missing.

Apply catalog defaults only when the household has not explicitly chosen a tier.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

@obvious-autobuild

Copy link
Copy Markdown
Author

Independent review — executed allocator probes + harness run

Verdict: changes needed. Three defects reproduced by execution in a scratch Linux harness, plus two lower-urgency findings. Context: all 37 repository tests pass in the same harness (0 skipped) — none of them covers these paths.

1. HIGH — a substitution can bypass a blocked allergen (executed repro)

allocateDinner's swap selection never receives the eaters' blocked-ID sets: PlanRestriction.clean(_:) screens recipe lines only, and the adapter screens reviewed substitutions for dietary restrictions, not blocked ingredient IDs. With chicken short, a reviewed 4→23 swap, and a household eater blocking tofu (23):

need 100 g chicken · stock 50 g chicken + 200 g tofu · swap 4→23 @1.0 · eater blocks [23]
→ PlanSwap(originalID: 4, substituteID: 23) offered, chicken line "sufficient"

Tofu lands on the plate for an allergic household. Suggested fix: thread blocked ingredient IDs into the allocator (or filter substitutions before allocation) and refuse any swap whose substitute is blocked for any eater in that dinner.

2. HIGH — substitute stock double-counted within one dinner (executed repro)

allocateDinner checks each line and each substitution against the same unmodified pool and records consumption afterwards, so two lines of one dinner can claim the same substitute grams:

lines: chicken 100 g + tofu 100 g · stock 50 g chicken + 100 g tofu · swap 4→23 @1.0
→ both lines "sufficient", shopping list asks 0 g tofu (correct answer: 50 g short)

The swap consumed 50 g tofu for the chicken line; the tofu line then counted the full 100 g pool again. Suggested fix: consume as you allocate (sequential pool mutation, the same pattern the lots path already uses) so later lines see what earlier lines took.

3. MEDIUM-HIGH — a leftovers night can promise another dish's surplus (executed repro)

Surplus servings are pooled across cooked dinners, but a leftovers slot references only lastCooked:

3 dinner slots @ 3 servings, 2 eaters, dishes A and B (each leaves 1 spare)
→ slot 3 becomes a leftovers night of B promising 2 servings; B left 1

Suggested fix: keep surplus per dinner and draw a leftovers promise from the referenced dinner's own surplus, falling back to cooking when it can't cover the slot.

4. LOW-MEDIUM — catalog identity doesn't survive plan persistence (code-read, callers test-only today)

load(planID:) rebuilds recipeDatabaseID with Int64(recipe.id) ?? 0, so a catalog ID like rcp-000201 reloads as 0. No production caller found yet, but any persisted catalog-linked plan silently loses its identity.

5. LOW — catalog assumed_staple overrides household intent (code-read)

Staple resolution unions catalog assumed_staple IDs with household staple tiers, so a pantry-only (only_if_confirmed) household staple can be auto-planned because the catalog flags it as assumed. Household choice should outrank the catalog flag.

Harness details (scratch, nothing committed)

  • Swift 6.4.0 Linux; GRDB 7.11.1 resolved from the repo's open range (repo pins 7.10.0 — consistent with the body's stated harness version); -DSQLITE_DISABLE_SNAPSHOT; Linux shims (CryptoKit → swift-crypto, CoreGraphics → Foundation re-export).
  • Repository suite in this harness: 37 passed / 0 failed / 0 skipped, including the substitutions-adapter test that normally skips when substitutions-v1.json isn't on Bundle.main.
  • Review probes: 3/3 fail with the outputs quoted above — executed, not inferred.
  • Not run here: the iOS target (no Xcode — hosted macOS CI covers it) and mutation checks against a patched allocator.
  • Assumption on probe semantics: I treat a swap as a pantry swap (substitute grams drawn from stock), matching allocateDinner's pool-based implementation. Issue 2's arithmetic changes if the intended semantics is a shopping swap; Issue 1 stands either way.

🔗 Obvious Project · 🧵 Obvious Thread · 🔀 Obvious PR Artifact

… staples

Review round on PR #178 (five findings), each pinned by a failing-first
regression test in LeChefPlanWeeklyTests:

- Blocked substitutes are never offered or applied: the snapshot drops
  swaps whose substitute is a household allergen, and the allocator
  refuses any swap whose substitute an eater blocks (previously only
  recipe lines were screened).
- Stock is claimed within a dinner: a line's swap sees the pool minus
  what earlier lines of the same dinner already claimed, so the
  substitute line reports its real remainder instead of hiding it.
- Leftover surplus is tracked per cooked dinner: a leftovers night
  re-serves one dish and must be covered by that dish's own surplus,
  never by portions pooled across different dishes.
- Saved catalog dinners reload their numeric identity (the negative
  digit tail of the rcp- id) instead of 0, keeping replacement and
  exclusion working after a plan reload.
- Catalog assumed_staple defaults yield to an explicit household
  pantry tier: an ingredient marked only_if_confirmed is never
  re-assumed.

Verified: Linux scratch harness 48/48 (the six new/updated tests fail
before the fixes and pass after). Hosted macOS CI runs the app-target
suite.

Co-authored-by: Sam Gu <sgu07966@gmail.com>
@obvious-autobuild

Copy link
Copy Markdown
Author

All five findings from the independent review are accepted and fixed at 1ca4ec2a (details and failing-first tests in the PR body's review-round section):

  1. Blocked substitutes — the snapshot no longer offers swaps whose substitute is a household allergen, and the allocator refuses blocked substitutes at application time (testSnapshotFiltersBlockedSubstitutes, testSwapCandidateRespectsBlockedIngredients).
  2. Within-dinner double counting — allocateDinner keeps a claimed ledger; swap eligibility and line availability see stock minus earlier claims of the same dinner (testSwapClaimReservesStockWithinADinner).
  3. Pooled leftover surplus — surplus is per cooked dinner; a leftovers night re-serves one dish and requires that dish's own surplus to cover a full round (testLeftoverCarryNeedsAFullRoundOfEaters, rewritten to per-dinner semantics).
  4. Catalog id reload — saved catalog dinners reconstruct the negative digit-tail identity instead of 0 (testSavedCatalogDinnersReloadTheirDatabaseID).
  5. Staple precedence — catalog assumed_staple defaults no longer override an explicit household tier (testHouseholdStapleChoiceSurvivesTheCatalog).

Each pinned test failed before its fix (11 failing assertions reproducing the findings) and the full Linux harness passes 48/48 after; hosted macOS CI is green on this head (run 38836748879). One drafted test for finding 3 was removed instead of landed — it described a state the corrected model cannot reach; reasoning is in the PR body.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants