Recommend mixed-pack purchases (buy N of one pack and M of another) - #42
Conversation
The shopping list scored each pack size on its own and marked one "best option". It never combined products, so it wasted food whenever no single pack fit the need but a mix did (both pack granularity and cooking events far apart in time). Add recommendCombination in waste_optimizer.dart: a bounded, deterministic search over pack-count vectors that returns the mix of products covering the whole need with the least total waste. Each candidate is scored by a heterogeneous-pool simulation that reuses the existing event-based expiry model, so the chosen mix reflects the individual cooking events, not only the weekly total. It falls back to a single product when one already fits best, and to the best single product when the search space exceeds the bound. Surface it in the ingredient card as a "Best value" banner (for real mixes) and in the copy output, which now lists the recommended combination instead of each product independently. rankProducts is unchanged and still drives the per-product chips. Closes #26 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The isViable flag on CombinationRecommendation was set and asserted in tests but never read by any UI, so it was dead state that could drift from the real recommendation. Remove the field and its test assertions. ProductRecommendation.isViable stays: the shopping card still reads it to pick the auto-fill pack count. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Best value" banner on the ingredient card runs the mixed-pack solver over the whole menu timeline, so it can show a real 2-product mix. The copied shopping list runs the solver per shop trip, which can list a single pack or a different mix. A user who reads the banner and then copies the list could see the two disagree. Add a short note under the banner text saying the copied list splits the buy per shop trip, so its per-trip breakdown can differ. This is an honest UI note only; the card is not reworked to per-trip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add two tests for fragile branches of recommendCombination that had no direct coverage: - Tiny packs with a huge need push the search past its vector bound, so it must fall back to the best single product instead of enumerating. - A single pack with enough raw quantity is infeasible when its leftover expires before a later cooking event, forcing a larger purchase. Both assert real pack counts and waste values. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ADR 0018 said the chosen mix is "optimal" within the search bound. That overclaims: the outer search is exhaustive and its total-bought ranking is exact, but the per-vector expiry feasibility uses a greedy container-opening heuristic that is not proven optimal for every vector, so a coverable vector could rarely be marked infeasible. Reword the consequence to state exactly what is exact (the ranking) and what is heuristic (per-vector expiry feasibility). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
guplem
left a comment
There was a problem hiding this comment.
Reviewed by an automated pass (rigorous solver review + a polish round). No correctness bugs in the solver; the review's polish items were applied on the branch. Ready for a human check.
What is done well (verified)
- The solver is correct. The search-space bound (each product capped at its solo pack count) is provably safe and never excludes a better mix; enumeration is deterministic; a single product reduces exactly to the existing
_simulateProduct; and total-waste ranking is immune to the display-only expiry/over-buy split. The per-event expiry simulation matches the single-product one (same shelf-life boundary, soonest-expiry-first). - Falls back to a single product when best, and prefers a single product over an equal-waste mix. New ADR 0018 documents the design.
Applied from the review on this branch
- Card banner vs copied list could recommend different mixes (the banner uses the full menu timeline; the copy optimizes per shop trip). Added a short note under the banner so the user knows the copied list splits per trip and can differ. Two widget tests cover it.
- Removed the dead
CombinationRecommendation.isViablefield. - Softened ADR 0018's optimality wording: the search is exhaustive and total-bought ranking is exact, but per-vector expiry feasibility uses a documented greedy heuristic not proven optimal in every case.
- Added tests for the two fragile branches: the >20000-combination fallback to a single product, and an infeasible small purchase losing to a larger one due to expiry. Full suite green (633 tests).
Verdict: approved pending human review. CI analyze-and-test gate must be green before merge.
Note for the reviewer: shares waste_optimizer.dart with #37 and #41, so expect merge conflicts when landing more than one.
Bring the mixed-pack combination solver branch up to date with main, which added the under-buy option (#29), the copy-matches-page rewrite (#34), and the freeze-on-arrival note (#28). Preserve every feature. Conflicts resolved (both sides kept): - shopping_ingredient.dart: keep the "Best value" mix banner (#26) next to the new freeze-on-arrival note (#28). Both are new sibling widget methods; kept both, plus #29's under-buy chip wiring already merged cleanly. - shopping_ingredient_test.dart: keep both new test groups, the best-value banner group (#26) and the freeze-on-arrival group (#28). Auto-merged and verified, not just accepted: - shopping_page.dart _appendIngredientLines now runs #34's per-trip copy path (distributeRemainingAcrossTrips + computeRemainingQuantities, whole-unit rounding, freeze suffix) AND, within each trip's amounts, prints the solver's recommended pack mix via recommendCombination + combinationPackLines. Copy still matches the page per trip, and each ingredient shows its waste-minimal combination. - waste_optimizer.dart keeps #29's under-buy machinery (ProductRecommendation underBuy/shortfall, _considerBuyingOnePackLess) alongside #26's recommendCombination solver; the recommendCombination signature matches the shopping_page call. Verified together: flutter analyze clean, all 658 tests pass, dart format reports 0 changed. ADR 0018 preserved. No test expectations changed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
guplem
left a comment
There was a problem hiding this comment.
Rebased onto the updated main (which now has #34, #29, #28) and reconciled. CI is green and the PR is mergeable.
Reconciliation (verified)
- Copy path:
_appendIngredientLinesnow nests the mixed-pack combination inside #34's per-trip amount buckets. It computesrecommendCombinationfor each trip's rounded remaining and printscombinationPackLines, so the copy still matches the page per trip AND shows the waste-minimal pack mix. Confirmed by reading the merged function. waste_optimizer.dart: #29's under-buy machinery and this PR'srecommendCombinationsolver coexist (different functions);ProductRecommendationkeeps #29'sunderBuy/shortfall.- Card: the "Best value" banner (#26), the freeze note (#28), and the under-buy chip (#29) all render together.
- No test expectations were changed. Full suite passes together: 658 tests.
Verdict: approved pending human review. CI analyze-and-test is green and the PR is mergeable now. Reminder: this and #37 both add a multi-product presentation to the card/copy; if you merge both, reconcile the two into one display.
Reconcile the mixed-pack combination solver (#42 / issue #26) with the newly merged equivalent-product cycle (#37 / issue #27). Both features change the shopping card and the copied list, so compose them instead of dropping either. waste_optimizer.dart: auto-merged clean. Keeps both #37's cycling machinery (productEquivalenceKey, distributeEquivalentPacks, _cycleEquivalentProducts) and #42's recommendCombination solver. Shared helpers (ProductRecommendation, _NormalizedEvent, _normalizeEvents) stay single-definition. Copy (shopping_page.dart, buildIngredientCopyLines): #42's combination is now the primary output. The waste-minimal pack mix is picked first, its packs are summed per equivalence key, and where a key covers 2+ equivalent variants (same pack size, e.g. two pizza flavors) they are spread one-of-each via distributeEquivalentPacks. Different pack sizes are different keys, so #26's size mix and #27's variety spread never collide. Still passes empty events, so each trip's amount matches the page (#34). Card (shopping_ingredient.dart): keeps both #42's "Best value" banner (_buildCombinationBanner) and #37's equivalent-group rows (_buildProductRows), plus the freeze note (#28) and under-buy chip (#29). The banner shows the whole-menu mix; the rows show per-product solo counts per trip, so their numbers can differ for the same ingredient (a human can later fold them into one display). Tests: kept all of #37's cycle tests and #42's solver tests. Updated one copy expectation: "non-equivalent products each keep their full solo count" no longer holds, because the copy now shows the waste-minimal mix (#26), not every product. Reworked it to assert the mix lists each distinct pack size with its own count. ADR 0018: added a line describing how the copy composes with the #27 cycle. flutter analyze: no issues. flutter test: 698 passed. format check: clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
guplem
left a comment
There was a problem hiding this comment.
Re-merged after #37 landed on main. CI is green, the PR is mergeable, and the two overlapping features are now genuinely composed (not just merged).
Copy composition (the crux, verified) in buildIngredientCopyLines:
- Runs
recommendCombinationto get the waste-minimal pack-size mix (#26), sums itsPackSelectionpacks perproductEquivalenceKeyintopacksByKey, then appliesdistributeEquivalentPacksto spread each key's packs one-of-each across identical variants (#27). Different pack sizes are different keys, so they stay as the combination chose; only identical variants get the one-of-each spread. Both shared helpers are reused, not duplicated. The per-trip amount still drives it, so the copy still matches the page (#34). Nothing of #37's copy behavior was dropped.
Card: renders the freeze note (#28), the "Best value" combination banner (#26), and #37's equivalent-group rows (which host #29's under-buy chip) together.
- Known redundancy for a human to fold later: the banner shows the whole-menu waste-minimal mix, while the per-product rows show per-trip solo counts, so for a real mix the two can show different numbers for the same item. The banner already carries an in-card note that the copied list splits per shop trip, so it is not misleading, but unifying the two displays would be cleaner.
Test change (1, justified): the copy test "non-equivalent products each keep their full solo count" encoded pre-#26 behavior (listing every product at its solo count). #26 deliberately replaces that with a single waste-minimal mix, so it was reworked to "the mix lists each distinct pack size with its own count" - preserving the real intent (different sizes are not merged). Full suite passes together: 698 tests. ADR 0018 updated to describe the composition.
Verdict: approved pending human review. CI green and mergeable. This is the last of the shopping PRs; merging it plus the current #37/#40 completes issues #23-#29 and #34.
Summary
Closes #26
The list ranked each product on its own and marked one "best option". Now it can recommend a mix of products for one ingredient when that covers the need with less waste, e.g. "3 of A and 2 of B".
recommendCombinationsolver returns aCombinationRecommendation(products + pack counts + waste).rankProductsand the per-product "best option" chips are untouched._maxCombinationVectors = 20000) and deterministic; above the bound it falls back to the best single product.Test plan
flutter analyzepassesflutter test test/passes (629 tests)🤖 Generated with Claude Code