Skip to content

Recommend mixed-pack purchases (buy N of one pack and M of another) - #42

Merged
guplem merged 7 commits into
mainfrom
26-mixed-pack-combination
Jul 26, 2026
Merged

Recommend mixed-pack purchases (buy N of one pack and M of another)#42
guplem merged 7 commits into
mainfrom
26-mixed-pack-combination

Conversation

@guplem

@guplem guplem commented Jul 22, 2026

Copy link
Copy Markdown
Owner

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".

  • New recommendCombination solver returns a CombinationRecommendation (products + pack counts + waste). rankProducts and the per-product "best option" chips are untouched.
  • The solver searches the pack-count combinations across an ingredient's same-unit products and minimizes total bought while covering every cooking event with non-expired food. It is bounded (_maxCombinationVectors = 20000) and deterministic; above the bound it falls back to the best single product.
  • It uses per-cooking-event simulation (a mixed container pool), so the mix depends on individual events, not only the weekly total.
  • Falls back to a single product when that is already best (and prefers a single product over an equal-waste mix).
  • The copied list prints the recommended combination; the card shows a "Best value" banner for real mixes.
  • New ADR 0018 records the solver design; indexed in AGENTS.md.

Test plan

  • flutter analyze passes
  • flutter test test/ passes (629 tests)
  • An ingredient where 2 small + 1 large beats any single product recommends the mix
  • An ingredient one product covers best still recommends a single product
  • Copied list shows the combination

🤖 Generated with Claude Code

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>
@guplem guplem added the waiting-for-human-check No human has verified this yet -- direct AI output label Jul 22, 2026
@guplem guplem self-assigned this Jul 22, 2026
guplem and others added 2 commits July 22, 2026 23:11
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>
guplem and others added 2 commits July 22, 2026 23:12
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 guplem left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.isViable field.
  • 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 guplem left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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: _appendIngredientLines now nests the mixed-pack combination inside #34's per-trip amount buckets. It computes recommendCombination for each trip's rounded remaining and prints combinationPackLines, 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's recommendCombination solver coexist (different functions); ProductRecommendation keeps #29's underBuy/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 guplem left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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 recommendCombination to get the waste-minimal pack-size mix (#26), sums its PackSelection packs per productEquivalenceKey into packsByKey, then applies distributeEquivalentPacks to 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.

@guplem
guplem merged commit 5939a9a into main Jul 26, 2026
1 check passed
@guplem
guplem deleted the 26-mixed-pack-combination branch July 26, 2026 10:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

waiting-for-human-check No human has verified this yet -- direct AI output

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Recommend mixed-pack purchases (buy N of one pack and M of another)

1 participant