Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ ADRs capture **why** decisions were made, not just what was built. This includes
| [0015](adr/0015-freezable-products-and-freezer-aware-trips.md) | Product.canBeFrozen: freeze-required menu warnings and freezer-aware shopping trips |
| [0016](adr/0016-reference-guarded-deletion.md) | Reference-guarded deletion: warn-confirm-clean-undo cascade; MenuProvider mirrors the active menu |
| [0017](adr/0017-agent-docs-structure.md) | AGENTS.md map + Claude-Code-only skills/subagents/settings |
| [0018](adr/0018-mixed-pack-combination-solver.md) | Mixed-pack combination solver: bounded deterministic search recommending a waste-minimal mix of packs |

**One ADR per pattern, kept alive**: when a pattern changes, update its ADR in place; create a new ADR only for a genuinely new pattern. Do not create successor ADRs or "superseded by" chains; history lives in git. Most changes need no ADR. Conventions: `adr/AGENTS.md`.

Expand Down
45 changes: 45 additions & 0 deletions adr/0018-mixed-pack-combination-solver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# ADR 0018: Mixed-Pack Combination Solver for Shopping Recommendations

## Context

When an ingredient has several pack sizes (see ADR 0010), `rankProducts` (`lib/shopping/waste_optimizer.dart`) scored each product on its own and the UI marked the single lowest-waste product as the "best option". It never combined products. This wastes food whenever no single pack size fits the need well but a mix does. Two cases matter:

- **Pack granularity.** Need 600 g with 250 g and 400 g packs: three 250 g packs waste 150 g, two 400 g packs waste 200 g, but one 250 g + one 400 g wastes only 50 g.
- **Per cooking event.** The same weekly total split across cooking days far apart (beyond the opened shelf life) cannot be served from one big pack, because the leftover expires between events. Buying a small pack for the small event and a large pack for the large event avoids both the expiry and the over-buy.

Issue #3 proposed this combination solver but only the per-product ranking shipped. Issue #26 asks for it: recommend buying a mix of products for one ingredient when that covers the need with less total waste, based on the per-cooking-event amounts, and fall back to a single product when one already fits best.

## Decision

Add `recommendCombination` to `lib/shopping/waste_optimizer.dart` alongside the existing `rankProducts` (which is kept for the per-product "best option" chips). It returns a `CombinationRecommendation`: a list of `PackSelection`s (product + pack count) plus the over-buy and expiry waste of the chosen mix.

### Objective

For a fixed set of purchased packs, when the need is met, `totalWaste = totalBought - totalNeeded` and splits into over-buy (never-used surplus) and expiry (opened food past its opened shelf life before use). So the solver minimizes total bought subject to covering every cooking event with non-expired food. The over-buy vs expiry split is derived for display; it does not change the ranking.

### Bounded, deterministic search

The solver enumerates pack-count vectors `(n_1, ..., n_k)` for the `k` same-unit products, with each `n_i` from 0 to that product's solo pack count (`_simulateProduct(...).packsNeeded`, which already accounts for expiry). Buying more of one product than would cover the whole need alone is never less wasteful, so that is a safe upper bound. The search is the Cartesian product of those ranges. When the number of vectors would exceed `_maxCombinationVectors` (20000), the search is skipped and the best single product from `rankProducts` is returned. Products are sorted internally (ascending pack size, then link, then unit) so the result never depends on the caller's input order.

### Per-combination simulation

Each candidate is scored by `_simulateCombination`, a generalization of the single-product `_simulateProduct` to a heterogeneous container pool. Each pack contributes `itemsPerPack` containers of `quantityPerItem`. Events are processed in day order: open containers past their opened shelf life expire; the remaining need is consumed soonest-expiry-first; new containers are opened as needed, choosing the smallest container that fully covers the remaining need (else the largest available), which keeps leftover that must survive to a later event as small as possible. Both rules are deterministic, and for a single product the simulation reduces to `_simulateProduct`. A candidate is infeasible when the pool runs out before covering an event.

### Tie-break

Best is chosen by: (1) lowest total waste, (2) fewest distinct products (so a single product beats an equal-waste mix, satisfying the fallback requirement), (3) fewest total packs, (4) lexicographically smaller count vector. All deterministic.

### Surfacing

- **Card UI** (`ShoppingIngredient`): computed per required unit with the full need and full events (same inputs as the "best option" chips), so the recommended mix reflects the individual cooking events. A "Best value" banner is shown only for real mixes (2+ products); single-product picks stay conveyed by the existing chip.
- **Copy output** (`buildIngredientCopyLines` in `shopping_page.dart`): the per-product independent listing was replaced by the recommended combination. Copy runs per trip and each trip is already a shelf-life-safe bucket (ADR 0014), so the copy passes empty events: within a trip the mix only needs to minimize pack-granularity over-buy. The combination is then composed with the equivalent-product cycle (issue #27): the packs the mix buys are summed per `productEquivalenceKey`, and where a key covers 2+ equivalent variants (same pack size, e.g. two pizza flavors) they are spread one-of-each via `distributeEquivalentPacks`, so identical variants list as "one of each" instead of all packs on one variant. Different pack sizes are different keys, so #26's size mix and #27's variety spread never collide.

**Rejected alternative:** an event-aware combination per trip in the copy. The multi-trip planner (ADR 0014) aggregates each trip to a single amount per unit and does not expose per-event breakdowns per trip. Reconstructing them would duplicate the planner's owned-amount and shelf-life logic. Since the trip is already expiry-safe by construction, a whole-trip amount with pure over-buy optimization is sufficient there; the per-event benefit is delivered on the ingredient card, which sees the full timeline.

## Consequences

- The list can now recommend "buy N of pack A and M of pack B" when it lowers waste, and falls back to a single product when one already fits best. The copy output reflects the recommended combination.
- The search is exhaustive within its bound: it evaluates every pack-count vector, and the ranking by total bought is exact. The per-vector expiry feasibility, however, uses the greedy container-opening heuristic below, which is not proven optimal for every vector. So the chosen mix is the best under the modeled waste, but it is not guaranteed to be the true global optimum in all cases (in rare cases a coverable vector could be marked infeasible). Above the bound the search degrades gracefully to the previous single-product recommendation.
- The container-opening rule is a deterministic heuristic, not a proof of minimum expiry for every fixed purchase. Because the outer search covers all pack-count vectors, a slightly suboptimal expiry estimate for one vector rarely changes the final pick; the reported waste for the chosen mix stays consistent with `_simulateProduct`.
- The card recommendation uses the whole-menu timeline while the copy is sectioned per trip (ADR 0014), so the two can differ for multi-week menus. This mirrors the existing design, where the card shows a whole-menu "best option" and the copy splits per trip.
- `rankProducts` is unchanged and still drives the per-product chips; the combination solver is additive. Owned-amount handling is unchanged: the card uses the full need (like the chips) and the copy uses the planner's owned-reduced per-trip amounts.
65 changes: 65 additions & 0 deletions menu_management/lib/shopping/shopping_ingredient.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import "package:menu_management/shopping/multi_trip_planner.dart";
import "package:menu_management/shopping/shopping_product_row.dart";
import "package:menu_management/shopping/ingredient_source.dart";
import "package:menu_management/shopping/waste_optimizer.dart";
import "package:menu_management/theme/theme_custom.dart";

/// Represents the unit the user picks in the "owned" dropdown.
/// [unit] is null when the user picks "packs" (product-relative).
Expand Down Expand Up @@ -80,13 +81,18 @@ class ShoppingIngredient extends StatefulWidget {
required this.onProductOwnedChanged,
required this.sources,
required this.plannedTrips,
this.combinationRecommendations = const [],
});

final Ingredient ingredient;
final List<Quantity> quantitiesDesired;
final List<Quantity> calculatedRemainingQuantities;
final List<ProductRecommendation> productRecommendations;

/// Waste-minimal mixed-pack recommendations (one per required unit). Only holds real mixes
/// (2+ products); single-product picks are conveyed by the per-product "best option" chip.
final List<CombinationRecommendation> combinationRecommendations;

/// Single owned input, used only when the ingredient has no products.
final double ownedAmount;
final OwnedUnit ownedUnit;
Expand Down Expand Up @@ -283,6 +289,62 @@ class _ShoppingIngredientState extends State<ShoppingIngredient> {
widget.onOwnedChanged(autoValue, selectedUnit);
}

/// Highlighted line describing a recommended mixed-pack purchase, e.g.
/// "Best value: 1x 250 grams/pack + 1x 600 grams/pack" with a waste note.
Widget _buildCombinationBanner(CombinationRecommendation combination) {
double waste = combination.totalWaste;
String wasteNote = waste <= 0 ? "no waste" : "${waste.toFormattedAmount()} ${combination.selections.first.product.unit.name} waste";
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: FilledCard(
outlined: true,
borderColor: ThemeCustom.colorScheme(context).tertiary,
color: ThemeCustom.colorScheme(context).tertiaryContainer,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
child: Row(
children: [
Icon(Icons.auto_awesome_rounded, size: 18, color: ThemeCustom.colorScheme(context).onTertiaryContainer),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: ThemeCustom.colorScheme(context).onTertiaryContainer),
children: [
const TextSpan(
text: "Best value: ",
style: TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: combinationInlineSummary(combination)),
TextSpan(
text: " ($wasteNote)",
style: TextStyle(color: ThemeCustom.colorScheme(context).onTertiaryContainer.withValues(alpha: 0.7)),
),
],
),
),
const SizedBox(height: 2),
// The copied list is split per shop trip (ADR 0014), while this banner covers the whole
// menu, so the two can differ. Warn the user so they trust the copied per-trip breakdown.
Text(
"This is the whole-menu best buy. The copied list splits the buy per shop trip, so its per-trip breakdown can differ.",
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: ThemeCustom.colorScheme(context).onTertiaryContainer.withValues(alpha: 0.7)),
),
],
),
),
],
),
),
),
);
}

/// Builds the product rows, grouping equivalent products (same [productEquivalenceKey],
/// e.g. two pizza flavors of the same size) so they render as a combined "buy one of each"
/// joined by "and" instead of mutually-exclusive "or" alternatives.
Expand Down Expand Up @@ -510,6 +572,9 @@ class _ShoppingIngredientState extends State<ShoppingIngredient> {
if (_freezeOnArrival) Padding(padding: const EdgeInsets.only(top: 4), child: _buildFreezeNote(context)),
const SizedBox(height: 8),

// Recommended mixed-pack combination(s), shown only when a mix beats every single product.
...widget.combinationRecommendations.map(_buildCombinationBanner),

// Product rows (only for products whose unit matches a required quantity)
if (widget.ingredient.products.isNotEmpty) ..._buildProductRows(context, bestWaste),
],
Expand Down
46 changes: 40 additions & 6 deletions menu_management/lib/shopping/shopping_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,24 @@ class _ShoppingPageState extends State<ShoppingPage> {

// Compute product recommendations per required unit
List<ProductRecommendation> recommendations = [];
// Waste-minimal mix of packs per required unit (may combine several products).
// Uses the same total need and events as the per-product ranking, so the recommended
// mix reflects the individual cooking events, not only the weekly total.
List<CombinationRecommendation> combinations = [];
if (ingredient.products.isNotEmpty && desired.isNotEmpty) {
for (Quantity quantity in desired) {
List<Product> matchingProducts = ingredient.products.where((p) => p.unit == quantity.unit).toList();
if (matchingProducts.isNotEmpty) {
recommendations.addAll(
rankProducts(totalNeeded: quantity.amount, events: events, ingredient: ingredient, products: matchingProducts),
);
CombinationRecommendation? combination = recommendCombination(
totalNeeded: quantity.amount,
events: events,
ingredient: ingredient,
products: matchingProducts,
);
if (combination != null && combination.selections.length > 1) combinations.add(combination);
}
}
}
Expand All @@ -158,6 +169,7 @@ class _ShoppingPageState extends State<ShoppingPage> {
quantitiesDesired: desired,
calculatedRemainingQuantities: remaining,
productRecommendations: recommendations,
combinationRecommendations: combinations,
ownedAmount: ownedAmounts[ingredientId] ?? 0,
ownedUnit: ownedUnits[ingredientId] ?? const OwnedUnit(unit: Unit.grams),
ownedProductCounts: ownedProductCounts[ingredientId] ?? const {},
Expand Down Expand Up @@ -303,10 +315,12 @@ class _ShoppingPageState extends State<ShoppingPage> {
/// (empty when nothing is needed). Amounts are rounded to whole units so sub-1-unit residuals
/// drop out instead of rendering as "0 teaspoons".
///
/// Equivalent products (same [productEquivalenceKey], e.g. two pizza flavors of the same size)
/// share the packs one-of-each via [distributeEquivalentPacks], so each shows its cycled share
/// instead of every variant showing the full solo count. A variant that ends up with 0 packs is
/// skipped. Non-equivalent products each keep their full solo count.
/// The lines show the waste-minimal pack mix from [recommendCombination] (issue #26), not every
/// product's solo count: a product the mix does not pick is not listed. Where that mix contains
/// two or more equivalent products (same [productEquivalenceKey], e.g. two pizza flavors of the
/// same size), the group's packs are spread one-of-each via [distributeEquivalentPacks] (issue
/// #27), so identical variants list as "one of each" instead of all packs on one variant. A
/// variant that ends up with 0 packs is skipped.
String buildIngredientCopyLines({required Ingredient ingredient, required List<Quantity> remaining, bool freezeOnArrival = false}) {
StringBuffer buffer = StringBuffer();

Expand Down Expand Up @@ -334,15 +348,35 @@ String buildIngredientCopyLines({required Ingredient ingredient, required List<Q
// Products matching the primary unit, in configured order.
List<Product> matching = ingredient.products.where((Product p) => p.unit == primaryRemaining.unit).toList();

// Per-equivalence-group cycled shares: the group's solo cover split one-of-each.
// Pick the waste-minimal mix of packs for this amount (issue #26): the copy shows that mix, not
// every product's solo count. Events are empty: each trip is already a shelf-life-safe bucket, so
// the mix only needs to minimize pack-granularity over-buy for the amount bought on this trip.
CombinationRecommendation? combination = recommendCombination(
totalNeeded: primaryRemaining.amount,
events: const [],
ingredient: ingredient,
products: matching,
);
if (combination == null) return buffer.toString();

// Total packs the mix buys per equivalence group. Equivalent variants share one key, so the
// solver may load them all onto one representative; summing per key recovers the group's total.
Map<String, int> packsByKey = {};
for (PackSelection selection in combination.selections) {
String key = productEquivalenceKey(selection.product);
packsByKey[key] = (packsByKey[key] ?? 0) + selection.packs;
}

// Spread each group's packs one-of-each across its equivalent variants (issue #27), so identical
// variants in the recommendation list as "one of each" instead of all packs on one variant.
Map<String, List<int>> sharesByKey = {};
Map<String, int> cursorByKey = {};
Map<String, List<Product>> groups = {};
for (Product product in matching) {
groups.putIfAbsent(productEquivalenceKey(product), () => <Product>[]).add(product);
}
for (MapEntry<String, List<Product>> group in groups.entries) {
int total = group.value.first.packsNeeded(primaryRemaining.amount);
int total = packsByKey[group.key] ?? 0;
sharesByKey[group.key] = distributeEquivalentPacks(totalPacks: total, groupSize: group.value.length);
}

Expand Down
Loading
Loading