From c6e207496560d472df111cc179e331ad3174deac Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Wed, 22 Jul 2026 22:50:06 +0200 Subject: [PATCH 1/5] feat(shopping): recommend waste-minimal mixed-pack purchases 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 --- AGENTS.md | 1 + adr/0018-mixed-pack-combination-solver.md | 45 +++ .../lib/shopping/shopping_ingredient.dart | 51 +++ .../lib/shopping/shopping_page.dart | 33 +- .../lib/shopping/waste_optimizer.dart | 299 ++++++++++++++++++ .../test/waste_optimizer_test.dart | 201 ++++++++++++ 6 files changed, 623 insertions(+), 7 deletions(-) create mode 100644 adr/0018-mixed-pack-combination-solver.md diff --git a/AGENTS.md b/AGENTS.md index b310f29..4833998 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/adr/0018-mixed-pack-combination-solver.md b/adr/0018-mixed-pack-combination-solver.md new file mode 100644 index 0000000..a38c56f --- /dev/null +++ b/adr/0018-mixed-pack-combination-solver.md @@ -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** (`_appendIngredientLines` in `shopping_page.dart`): the per-product independent listing was replaced by the recommended combination's pack lines (`combinationPackLines`). 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. + +**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, so within the bound the chosen mix is optimal for the modeled waste. Above the bound it 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. diff --git a/menu_management/lib/shopping/shopping_ingredient.dart b/menu_management/lib/shopping/shopping_ingredient.dart index 0588a83..496a352 100644 --- a/menu_management/lib/shopping/shopping_ingredient.dart +++ b/menu_management/lib/shopping/shopping_ingredient.dart @@ -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). @@ -60,12 +61,17 @@ class ShoppingIngredient extends StatefulWidget { required this.onOwnedChanged, required this.sources, required this.plannedTrips, + this.combinationRecommendations = const [], }); final Ingredient ingredient; final List quantitiesDesired; final List calculatedRemainingQuantities; final List 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 combinationRecommendations; final double ownedAmount; final OwnedUnit ownedUnit; final void Function(double amount, OwnedUnit unit) onOwnedChanged; @@ -238,6 +244,48 @@ class _ShoppingIngredientState extends State { 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: 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)), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { // Determine best waste level among all recommendations. @@ -348,6 +396,9 @@ class _ShoppingIngredientState extends State { ), 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) ...() { diff --git a/menu_management/lib/shopping/shopping_page.dart b/menu_management/lib/shopping/shopping_page.dart index cf54c5a..1454c06 100644 --- a/menu_management/lib/shopping/shopping_page.dart +++ b/menu_management/lib/shopping/shopping_page.dart @@ -121,6 +121,10 @@ class _ShoppingPageState extends State { // Compute product recommendations per required unit List 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 combinations = []; if (ingredient.products.isNotEmpty && desired.isNotEmpty) { for (Quantity quantity in desired) { List matchingProducts = ingredient.products.where((p) => p.unit == quantity.unit).toList(); @@ -128,6 +132,13 @@ class _ShoppingPageState extends State { 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); } } } @@ -137,6 +148,7 @@ class _ShoppingPageState extends State { quantitiesDesired: desired, calculatedRemainingQuantities: remaining, productRecommendations: recommendations, + combinationRecommendations: combinations, ownedAmount: ownedAmounts[ingredientId] ?? 0, ownedUnit: ownedUnits[ingredientId] ?? const OwnedUnit(unit: Unit.grams), sources: ingredientSources[ingredientId] ?? [], @@ -249,13 +261,20 @@ class _ShoppingPageState extends State { return; } buffer.writeln("${ingredient.name}$freezeSuffix"); - for (Product product in ingredient.products) { - if (product.unit != primaryRemaining.unit) continue; - int packs = product.packsNeeded(primaryRemaining.amount); - if (packs <= 0) continue; - String label = product.packLabel() ?? "${product.totalQuantityPerPack.toFormattedAmount()} ${product.unit.name}/pack"; - String packWord = packs == 1 ? "pack" : "packs"; - buffer.writeln(" $label: $packs $packWord"); + // Recommend the waste-minimal mix of packs for this amount instead of listing each product + // on its own. Events are left empty here: 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 the trip. + List matchingProducts = ingredient.products.where((p) => p.unit == primaryRemaining.unit).toList(); + CombinationRecommendation? combination = recommendCombination( + totalNeeded: primaryRemaining.amount, + events: const [], + ingredient: ingredient, + products: matchingProducts, + ); + if (combination != null) { + for (String line in combinationPackLines(combination)) { + buffer.writeln(" $line"); + } } } else { String amounts = rounded.where((q) => q.amount > 0).map((q) => "${q.amount.toFormattedAmount()} ${q.unit.name}").join(" + "); diff --git a/menu_management/lib/shopping/waste_optimizer.dart b/menu_management/lib/shopping/waste_optimizer.dart index 52d6215..4b7b5de 100644 --- a/menu_management/lib/shopping/waste_optimizer.dart +++ b/menu_management/lib/shopping/waste_optimizer.dart @@ -1,5 +1,6 @@ import "dart:math"; +import "package:menu_management/flutter_essentials/library.dart"; import "package:menu_management/ingredients/models/ingredient.dart"; import "package:menu_management/ingredients/models/product.dart"; import "package:menu_management/recipes/models/quantity.dart"; @@ -125,6 +126,304 @@ ProductRecommendation _simulateProduct({ ); } +/// One product and how many packs of it to buy, as part of a [CombinationRecommendation]. +class PackSelection { + const PackSelection({required this.product, required this.packs}); + + final Product product; + final int packs; +} + +/// A recommended purchase for one ingredient (and one unit): buy the given packs of one or more +/// products so the whole need is covered with the least total waste (over-buy + expiry). +/// +/// When [selections] holds a single entry the recommendation is a single product; when it holds +/// more than one the recommendation is a mix (for example "1 small pack + 1 large pack"). +class CombinationRecommendation { + const CombinationRecommendation({required this.selections, required this.overBuyWaste, required this.expiryWaste, required this.isViable}); + + /// Selected products with pack counts > 0, sorted by ascending pack size then product link. + /// Empty only when nothing needs buying (need is zero). + final List selections; + final double overBuyWaste; + final double expiryWaste; + final bool isViable; + + double get totalWaste => overBuyWaste + expiryWaste; + bool get isSingleProduct => selections.length == 1; + int get totalPacks => selections.fold(0, (int sum, PackSelection s) => sum + s.packs); +} + +/// Upper bound on the number of pack-count vectors the combination search evaluates. +/// +/// The search is the Cartesian product of `0..soloPacks` for each product, where `soloPacks` is +/// the packs that product alone would need (already accounts for expiry). When the product of +/// those ranges would exceed this cap (huge need with tiny packs, or many products), the search +/// is skipped and the best single product is returned instead, keeping the work bounded. +const int _maxCombinationVectors = 20000; + +/// Recommends the waste-minimal way to buy one ingredient (in one unit) across [products], +/// allowing a mix of different packs when that beats every single product. +/// +/// The search is bounded and deterministic (see [_maxCombinationVectors] and the tie-break in +/// [_isBetterCombination]). It reuses the same event-based simulation as [rankProducts]: food is +/// consumed on cooking days and only expires between events, so the chosen mix reflects the +/// individual cooking events, not only the weekly total. +/// +/// [products] must all share the same unit (the caller groups them by unit). Returns null when +/// [products] is empty. Returns a recommendation with empty selections when nothing is needed. +CombinationRecommendation? recommendCombination({ + required double totalNeeded, + required List events, + required Ingredient ingredient, + required List products, +}) { + if (products.isEmpty) return null; + if (totalNeeded <= 0) { + return const CombinationRecommendation(selections: [], overBuyWaste: 0, expiryWaste: 0, isViable: true); + } + + // Deterministic product order: smallest pack first, then by link, then by unit. The search and + // tie-break both rely on this order, so the result never depends on the caller's input order. + List sorted = [...products] + ..sort((Product a, Product b) { + int bySize = a.totalQuantityPerPack.compareTo(b.totalQuantityPerPack); + if (bySize != 0) return bySize; + int byLink = a.link.compareTo(b.link); + if (byLink != 0) return byLink; + return a.unit.index.compareTo(b.unit.index); + }); + + // Normalize events to the shared unit once; synthesize a single event when there is no timeline + // (then the search only minimizes pack-granularity over-buy, with no expiry to model). + List<_NormalizedEvent> normalizedEvents = _normalizeEvents(events: events, product: sorted.first, ingredient: ingredient); + if (normalizedEvents.isEmpty) { + normalizedEvents = [_NormalizedEvent(dayIndex: 0, amount: totalNeeded)]; + } + + // Per-product cap = packs that product alone would need (already accounts for expiry). + List caps = sorted + .map((Product p) => _simulateProduct(product: p, totalNeeded: totalNeeded, events: events, ingredient: ingredient).packsNeeded) + .toList(); + + // Bound the search: if the Cartesian product of the ranges is too large, fall back to the best + // single product from the per-product ranking. + int vectorCount = 1; + bool tooLarge = false; + for (int cap in caps) { + vectorCount *= (cap + 1); + if (vectorCount > _maxCombinationVectors) { + tooLarge = true; + break; + } + } + if (tooLarge) { + return _bestSingleAsCombination(totalNeeded: totalNeeded, events: events, ingredient: ingredient, products: sorted); + } + + List? bestCounts; + double bestOverBuy = 0; + double bestExpiry = 0; + List counts = List.filled(sorted.length, 0); + + void evaluate(List candidate) { + if (candidate.every((int c) => c == 0)) return; + _CombinationSim sim = _simulateCombination(counts: candidate, products: sorted, events: normalizedEvents); + if (!sim.feasible) return; + + double bought = 0; + for (int i = 0; i < sorted.length; i++) { + bought += candidate[i] * sorted[i].totalQuantityPerPack; + } + double overBuy = max(0, bought - sim.consumed - sim.expiryWaste); + + if (bestCounts == null || _isBetterCombination(candidate, overBuy + sim.expiryWaste, bestCounts!, bestOverBuy + bestExpiry)) { + bestCounts = List.from(candidate); + bestOverBuy = overBuy; + bestExpiry = sim.expiryWaste; + } + } + + // Enumerate every pack-count vector in a fixed order (odometer over the product ranges). + void recurse(int index) { + if (index == sorted.length) { + evaluate(counts); + return; + } + for (int c = 0; c <= caps[index]; c++) { + counts[index] = c; + recurse(index + 1); + } + counts[index] = 0; + } + + recurse(0); + + if (bestCounts == null) return null; + + List selections = []; + for (int i = 0; i < sorted.length; i++) { + if (bestCounts![i] > 0) selections.add(PackSelection(product: sorted[i], packs: bestCounts![i])); + } + + return CombinationRecommendation(selections: selections, overBuyWaste: bestOverBuy, expiryWaste: bestExpiry, isViable: bestExpiry <= 0); +} + +/// Whether candidate combination [aCounts]/[aWaste] should beat the current best [bCounts]/[bWaste]. +/// +/// Tie-break order: (1) lower total waste, (2) fewer distinct products (prefer a single product +/// over an equal-waste mix), (3) fewer total packs, (4) lexicographically smaller count vector. +bool _isBetterCombination(List aCounts, double aWaste, List bCounts, double bWaste) { + const double epsilon = 1e-6; + if ((aWaste - bWaste).abs() > epsilon) return aWaste < bWaste; + + int aDistinct = aCounts.where((int c) => c > 0).length; + int bDistinct = bCounts.where((int c) => c > 0).length; + if (aDistinct != bDistinct) return aDistinct < bDistinct; + + int aTotal = aCounts.fold(0, (int s, int c) => s + c); + int bTotal = bCounts.fold(0, (int s, int c) => s + c); + if (aTotal != bTotal) return aTotal < bTotal; + + for (int i = 0; i < aCounts.length; i++) { + if (aCounts[i] != bCounts[i]) return aCounts[i] < bCounts[i]; + } + return false; +} + +/// Wraps the best single product (from [rankProducts]) as a [CombinationRecommendation]. +/// Used as the bounded-search fallback when the combination space is too large. +CombinationRecommendation _bestSingleAsCombination({ + required double totalNeeded, + required List events, + required Ingredient ingredient, + required List products, +}) { + List ranked = rankProducts(totalNeeded: totalNeeded, events: events, ingredient: ingredient, products: products); + ProductRecommendation best = ranked.first; + return CombinationRecommendation( + selections: [PackSelection(product: best.product, packs: best.packsNeeded)], + overBuyWaste: best.overBuyWaste, + expiryWaste: best.expiryWaste, + isViable: best.isViable, + ); +} + +/// Result of simulating one pack-count vector across the cooking events. +class _CombinationSim { + const _CombinationSim({required this.feasible, required this.expiryWaste, required this.consumed}); + + /// False when the purchased packs run out before covering every event. + final bool feasible; + final double expiryWaste; + final double consumed; +} + +/// Simulates consuming a heterogeneous pool of containers (from multiple products) across events. +/// +/// Each pack contributes `itemsPerPack` containers of `quantityPerItem`. Containers are consumed +/// soonest-expiry-first; when a new container must be opened, the smallest container that fully +/// covers the remaining need is opened (else the largest available), which keeps leftover that +/// must survive to a later event as small as possible. Both rules are deterministic. For a single +/// product this reduces to the same sequential consumption as [_simulateProduct]. +_CombinationSim _simulateCombination({required List counts, required List products, required List<_NormalizedEvent> events}) { + List poolContainers = [for (int i = 0; i < products.length; i++) counts[i] * products[i].itemsPerPack]; + List<_OpenContainer> open = []; + double expiryWaste = 0; + double consumed = 0; + + for (_NormalizedEvent event in events) { + double amountNeeded = event.amount; + + // Expire open containers whose opened shelf life has been exceeded by this event's day. + open.removeWhere((_OpenContainer c) { + int? shelfLife = c.shelfLife; + if (shelfLife != null && (event.dayIndex - c.openedDay) > shelfLife) { + expiryWaste += c.remaining; + return true; + } + return false; + }); + + // Consume from already-open containers, soonest-expiry first (null shelf life = never, last). + open.sort((_OpenContainer a, _OpenContainer b) => a.expiryKey.compareTo(b.expiryKey)); + for (_OpenContainer c in open) { + if (amountNeeded <= 0) break; + double take = min(c.remaining, amountNeeded); + c.remaining -= take; + amountNeeded -= take; + consumed += take; + } + open.removeWhere((_OpenContainer c) => c.remaining <= 0); + + // Open new containers until the event is covered or the pool is empty. + while (amountNeeded > 0) { + int? chosen = _chooseContainerToOpen(poolContainers: poolContainers, products: products, amountNeeded: amountNeeded); + if (chosen == null) return const _CombinationSim(feasible: false, expiryWaste: 0, consumed: 0); + + poolContainers[chosen]--; + double size = products[chosen].quantityPerItem; + double take = min(size, amountNeeded); + amountNeeded -= take; + consumed += take; + double remaining = size - take; + if (remaining > 0) { + open.add(_OpenContainer(remaining: remaining, openedDay: event.dayIndex, shelfLife: products[chosen].shelfLifeDaysOpened)); + } + } + } + + return _CombinationSim(feasible: true, expiryWaste: expiryWaste, consumed: consumed); +} + +/// Picks which product's container to open next: the smallest whose container size fully covers +/// [amountNeeded], else the largest available. Ties break by product index. Returns null when the +/// pool is empty. +int? _chooseContainerToOpen({required List poolContainers, required List products, required double amountNeeded}) { + int? smallestFitting; + int? largest; + for (int i = 0; i < products.length; i++) { + if (poolContainers[i] <= 0) continue; + double size = products[i].quantityPerItem; + if (size >= amountNeeded && (smallestFitting == null || size < products[smallestFitting].quantityPerItem)) { + smallestFitting = i; + } + if (largest == null || size > products[largest].quantityPerItem) { + largest = i; + } + } + return smallestFitting ?? largest; +} + +class _OpenContainer { + _OpenContainer({required this.remaining, required this.openedDay, required this.shelfLife}); + + double remaining; + final int openedDay; + final int? shelfLife; + + /// Day the container expires; a large sentinel when it never expires, so it is used last. + int get expiryKey => shelfLife == null ? 1 << 30 : openedDay + shelfLife!; +} + +/// Human-readable pack lines for a recommended combination, one per selected product. +/// Mirrors the copy-list wording, for example: "6x125grams: 2 packs" or "500 grams/pack: 1 pack". +List combinationPackLines(CombinationRecommendation recommendation) { + return recommendation.selections.map((PackSelection selection) { + String label = _packLabel(selection.product); + String packWord = selection.packs == 1 ? "pack" : "packs"; + return "$label: ${selection.packs} $packWord"; + }).toList(); +} + +/// Compact one-line description of a combination for inline UI, +/// for example: "1x 250 grams/pack + 1x 600 grams/pack". +String combinationInlineSummary(CombinationRecommendation recommendation) { + return recommendation.selections.map((PackSelection selection) => "${selection.packs}x ${_packLabel(selection.product)}").join(" + "); +} + +String _packLabel(Product product) => product.packLabel() ?? "${product.totalQuantityPerPack.toFormattedAmount()} ${product.unit.name}/pack"; + class _NormalizedEvent { const _NormalizedEvent({required this.dayIndex, required this.amount}); final int dayIndex; diff --git a/menu_management/test/waste_optimizer_test.dart b/menu_management/test/waste_optimizer_test.dart index 82f93ae..8915873 100644 --- a/menu_management/test/waste_optimizer_test.dart +++ b/menu_management/test/waste_optimizer_test.dart @@ -384,4 +384,205 @@ void main() { }); }); }); + + group("recommendCombination", () { + /// Returns a {packSize: packs} map so assertions do not depend on selection ordering. + Map packsBySize(CombinationRecommendation rec) { + return {for (PackSelection s in rec.selections) s.product.totalQuantityPerPack: s.packs}; + } + + test("recommends a mix of two products when it beats every single product (per-event)", () { + // Two cooking events beyond shelf life, so no food carries from the first to the second. + // Day 0 needs 250 g, day 20 needs 600 g. Both products last 5 days once opened. + // Small 250 g pack: perfect for day 0, but day 20 needs 3 packs (750 g -> 150 g surplus). + // Large 600 g pack: perfect for day 20, but on day 0 it opens and 350 g expires by day 20. + // Best combination: 1 small (day 0) + 1 large (day 20) = 850 g bought for 850 g needed, zero waste. + Product small = _product(quantityPerItem: 250, shelfLifeDays: 5); + Product large = _product(quantityPerItem: 600, shelfLifeDays: 5); + + CombinationRecommendation? rec = recommendCombination( + totalNeeded: 850, + events: [_event(day: 0, amount: 250), _event(day: 20, amount: 600)], + ingredient: _ingredient(), + products: [small, large], + ); + + expect(rec, isNotNull); + expect(rec!.selections.length, 2); + expect(packsBySize(rec), {250.0: 1, 600.0: 1}); + expect(rec.overBuyWaste, closeTo(0, 0.01)); + expect(rec.expiryWaste, closeTo(0, 0.01)); + expect(rec.totalWaste, closeTo(0, 0.01)); + expect(rec.isViable, isTrue); + expect(rec.isSingleProduct, isFalse); + }); + + test("combination accounts for individual cooking events, not only the weekly total", () { + // Same 600 g total need, but split across two events 20 days apart (beyond 5-day shelf life). + Product small = _product(quantityPerItem: 300, shelfLifeDays: 5); + Product large = _product(quantityPerItem: 600, shelfLifeDays: 5); + + // Per-event: two 300 g events. A single 600 g pack opened on day 0 loses 300 g by day 20, + // so the solver buys 2 small packs (300 g each), one per event -> zero waste. + CombinationRecommendation? perEvent = recommendCombination( + totalNeeded: 600, + events: [_event(day: 0, amount: 300), _event(day: 20, amount: 300)], + ingredient: _ingredient(), + products: [small, large], + ); + expect(perEvent, isNotNull); + expect(packsBySize(perEvent!), {300.0: 2}); + expect(perEvent.totalWaste, closeTo(0, 0.01)); + + // Weekly total only: a single 600 g event. Here the large pack is a perfect fit, + // so the solver picks 1 large pack. Different answer -> the solver used the per-event split. + CombinationRecommendation? lumped = recommendCombination( + totalNeeded: 600, + events: [_event(day: 0, amount: 600)], + ingredient: _ingredient(), + products: [small, large], + ); + expect(lumped, isNotNull); + expect(packsBySize(lumped!), {600.0: 1}); + }); + + test("falls back to a single product when one product covers the need with least waste", () { + // Need 500 g in one event. The 500 g pack is an exact fit (zero waste); no mix can beat it. + Product exact = _product(quantityPerItem: 500); + Product small = _product(quantityPerItem: 300); + + CombinationRecommendation? rec = recommendCombination( + totalNeeded: 500, + events: [_event(amount: 500)], + ingredient: _ingredient(), + products: [exact, small], + ); + + expect(rec, isNotNull); + expect(rec!.isSingleProduct, isTrue); + expect(packsBySize(rec), {500.0: 1}); + expect(rec.totalWaste, closeTo(0, 0.01)); + }); + + test("prefers a single product over an equal-waste mix", () { + // Need 750 g in one event. A single 750 g pack is an exact fit (zero waste). + // A 250 g + 500 g mix is also an exact fit (zero waste), but a single product is simpler, + // so the tie-break must pick the single 750 g pack. + Product big = _product(quantityPerItem: 750); + Product mid = _product(quantityPerItem: 500); + Product small = _product(quantityPerItem: 250); + + CombinationRecommendation? rec = recommendCombination( + totalNeeded: 750, + events: [_event(amount: 750)], + ingredient: _ingredient(), + products: [big, mid, small], + ); + + expect(rec, isNotNull); + expect(rec!.isSingleProduct, isTrue); + expect(packsBySize(rec), {750.0: 1}); + expect(rec.totalWaste, closeTo(0, 0.01)); + }); + + test("is deterministic: repeated calls give identical results", () { + Product small = _product(quantityPerItem: 250, shelfLifeDays: 5); + Product large = _product(quantityPerItem: 600, shelfLifeDays: 5); + List events = [_event(day: 0, amount: 250), _event(day: 20, amount: 600)]; + + CombinationRecommendation? first = recommendCombination(totalNeeded: 850, events: events, ingredient: _ingredient(), products: [small, large]); + CombinationRecommendation? second = recommendCombination(totalNeeded: 850, events: events, ingredient: _ingredient(), products: [small, large]); + + expect(packsBySize(first!), packsBySize(second!)); + expect(first.overBuyWaste, closeTo(second.overBuyWaste, 0.0001)); + expect(first.expiryWaste, closeTo(second.expiryWaste, 0.0001)); + }); + + test("is deterministic regardless of input product order", () { + // Need 600 g in one event. Mix of 250 g + 400 g (650 g, 50 g surplus) beats both singles + // (2x400 = 800 g -> 200 g surplus; 3x250 = 750 g -> 150 g surplus). + Product a = _product(quantityPerItem: 400); + Product b = _product(quantityPerItem: 250); + + CombinationRecommendation? forward = recommendCombination( + totalNeeded: 600, + events: [_event(amount: 600)], + ingredient: _ingredient(), + products: [a, b], + ); + CombinationRecommendation? reversed = recommendCombination( + totalNeeded: 600, + events: [_event(amount: 600)], + ingredient: _ingredient(), + products: [b, a], + ); + + expect(packsBySize(forward!), {250.0: 1, 400.0: 1}); + expect(packsBySize(reversed!), packsBySize(forward)); + expect(forward.overBuyWaste, closeTo(50, 0.01)); + expect(reversed.overBuyWaste, closeTo(50, 0.01)); + }); + + test("returns null for empty products", () { + expect(recommendCombination(totalNeeded: 500, events: [_event(amount: 500)], ingredient: _ingredient(), products: []), isNull); + }); + + test("returns an empty, viable recommendation when nothing is needed", () { + Product product = _product(quantityPerItem: 500); + CombinationRecommendation? rec = recommendCombination(totalNeeded: 0, events: const [], ingredient: _ingredient(), products: [product]); + + expect(rec, isNotNull); + expect(rec!.selections, isEmpty); + expect(rec.totalWaste, closeTo(0, 0.01)); + expect(rec.isViable, isTrue); + }); + }); + + group("combinationPackLines", () { + test("renders one pack line per selected product", () { + Product small = _product(quantityPerItem: 250); + Product large = _product(quantityPerItem: 600); + CombinationRecommendation rec = CombinationRecommendation( + selections: [ + PackSelection(product: small, packs: 1), + PackSelection(product: large, packs: 2), + ], + overBuyWaste: 0, + expiryWaste: 0, + isViable: true, + ); + + expect(combinationPackLines(rec), ["250 grams/pack: 1 pack", "600 grams/pack: 2 packs"]); + }); + + test("uses the multi-item pack label when the product has one", () { + Product cups = _product(quantityPerItem: 125, itemsPerPack: 6); + CombinationRecommendation rec = CombinationRecommendation( + selections: [PackSelection(product: cups, packs: 2)], + overBuyWaste: 0, + expiryWaste: 0, + isViable: true, + ); + + expect(combinationPackLines(rec), ["6x125grams: 2 packs"]); + }); + }); + + group("combinationInlineSummary", () { + test("joins the selected products into one line", () { + Product small = _product(quantityPerItem: 250); + Product large = _product(quantityPerItem: 600); + CombinationRecommendation rec = CombinationRecommendation( + selections: [ + PackSelection(product: small, packs: 1), + PackSelection(product: large, packs: 1), + ], + overBuyWaste: 0, + expiryWaste: 0, + isViable: true, + ); + + expect(combinationInlineSummary(rec), "1x 250 grams/pack + 1x 600 grams/pack"); + }); + }); } From 52e3e5b5459d3c447a25632b02fa1b81590b9bda Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Wed, 22 Jul 2026 23:11:11 +0200 Subject: [PATCH 2/5] refactor(shopping): drop unused CombinationRecommendation.isViable 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 --- menu_management/lib/shopping/waste_optimizer.dart | 8 +++----- menu_management/test/waste_optimizer_test.dart | 5 ----- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/menu_management/lib/shopping/waste_optimizer.dart b/menu_management/lib/shopping/waste_optimizer.dart index 4b7b5de..ba22671 100644 --- a/menu_management/lib/shopping/waste_optimizer.dart +++ b/menu_management/lib/shopping/waste_optimizer.dart @@ -140,14 +140,13 @@ class PackSelection { /// When [selections] holds a single entry the recommendation is a single product; when it holds /// more than one the recommendation is a mix (for example "1 small pack + 1 large pack"). class CombinationRecommendation { - const CombinationRecommendation({required this.selections, required this.overBuyWaste, required this.expiryWaste, required this.isViable}); + const CombinationRecommendation({required this.selections, required this.overBuyWaste, required this.expiryWaste}); /// Selected products with pack counts > 0, sorted by ascending pack size then product link. /// Empty only when nothing needs buying (need is zero). final List selections; final double overBuyWaste; final double expiryWaste; - final bool isViable; double get totalWaste => overBuyWaste + expiryWaste; bool get isSingleProduct => selections.length == 1; @@ -180,7 +179,7 @@ CombinationRecommendation? recommendCombination({ }) { if (products.isEmpty) return null; if (totalNeeded <= 0) { - return const CombinationRecommendation(selections: [], overBuyWaste: 0, expiryWaste: 0, isViable: true); + return const CombinationRecommendation(selections: [], overBuyWaste: 0, expiryWaste: 0); } // Deterministic product order: smallest pack first, then by link, then by unit. The search and @@ -266,7 +265,7 @@ CombinationRecommendation? recommendCombination({ if (bestCounts![i] > 0) selections.add(PackSelection(product: sorted[i], packs: bestCounts![i])); } - return CombinationRecommendation(selections: selections, overBuyWaste: bestOverBuy, expiryWaste: bestExpiry, isViable: bestExpiry <= 0); + return CombinationRecommendation(selections: selections, overBuyWaste: bestOverBuy, expiryWaste: bestExpiry); } /// Whether candidate combination [aCounts]/[aWaste] should beat the current best [bCounts]/[bWaste]. @@ -305,7 +304,6 @@ CombinationRecommendation _bestSingleAsCombination({ selections: [PackSelection(product: best.product, packs: best.packsNeeded)], overBuyWaste: best.overBuyWaste, expiryWaste: best.expiryWaste, - isViable: best.isViable, ); } diff --git a/menu_management/test/waste_optimizer_test.dart b/menu_management/test/waste_optimizer_test.dart index 8915873..3298a05 100644 --- a/menu_management/test/waste_optimizer_test.dart +++ b/menu_management/test/waste_optimizer_test.dart @@ -413,7 +413,6 @@ void main() { expect(rec.overBuyWaste, closeTo(0, 0.01)); expect(rec.expiryWaste, closeTo(0, 0.01)); expect(rec.totalWaste, closeTo(0, 0.01)); - expect(rec.isViable, isTrue); expect(rec.isSingleProduct, isFalse); }); @@ -534,7 +533,6 @@ void main() { expect(rec, isNotNull); expect(rec!.selections, isEmpty); expect(rec.totalWaste, closeTo(0, 0.01)); - expect(rec.isViable, isTrue); }); }); @@ -549,7 +547,6 @@ void main() { ], overBuyWaste: 0, expiryWaste: 0, - isViable: true, ); expect(combinationPackLines(rec), ["250 grams/pack: 1 pack", "600 grams/pack: 2 packs"]); @@ -561,7 +558,6 @@ void main() { selections: [PackSelection(product: cups, packs: 2)], overBuyWaste: 0, expiryWaste: 0, - isViable: true, ); expect(combinationPackLines(rec), ["6x125grams: 2 packs"]); @@ -579,7 +575,6 @@ void main() { ], overBuyWaste: 0, expiryWaste: 0, - isViable: true, ); expect(combinationInlineSummary(rec), "1x 250 grams/pack + 1x 600 grams/pack"); From 368d63bcc5484ccfd4a7643c9d91515d30de408a Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Wed, 22 Jul 2026 23:11:49 +0200 Subject: [PATCH 3/5] feat(shopping): note whole-menu vs per-trip split on best-value banner 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 --- .../lib/shopping/shopping_ingredient.dart | 42 ++++++++++++------- .../test/shopping_ingredient_test.dart | 38 ++++++++++++++++- 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/menu_management/lib/shopping/shopping_ingredient.dart b/menu_management/lib/shopping/shopping_ingredient.dart index 496a352..98efcec 100644 --- a/menu_management/lib/shopping/shopping_ingredient.dart +++ b/menu_management/lib/shopping/shopping_ingredient.dart @@ -262,21 +262,35 @@ class _ShoppingIngredientState extends State { Icon(Icons.auto_awesome_rounded, size: 18, color: ThemeCustom.colorScheme(context).onTertiaryContainer), const SizedBox(width: 8), Expanded( - child: 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)), + 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)), + ), + ], ), ), ], diff --git a/menu_management/test/shopping_ingredient_test.dart b/menu_management/test/shopping_ingredient_test.dart index b64e7d4..86c711b 100644 --- a/menu_management/test/shopping_ingredient_test.dart +++ b/menu_management/test/shopping_ingredient_test.dart @@ -6,6 +6,7 @@ import "package:menu_management/recipes/enums/unit.dart"; import "package:menu_management/recipes/models/quantity.dart"; import "package:menu_management/shopping/multi_trip_planner.dart"; import "package:menu_management/shopping/shopping_ingredient.dart"; +import "package:menu_management/shopping/waste_optimizer.dart"; // 100 grams per pack (2 items x 50 grams), so itemsPerPack > 1 keeps the "pack(s)" wording. Product _packProduct() => const Product(link: "", quantityPerItem: 50, itemsPerPack: 2, unit: Unit.grams); @@ -18,7 +19,12 @@ TripItem _item({String ingredientId = "beans", required double amount}) => TripI /// Pumps [ShoppingIngredient] in isolation with real [plannedTrips], so the assertions /// exercise `_tripPurchasesForProduct` (the split computation), not hand-built purchases. -Future _pumpIngredient(WidgetTester tester, {required double remainingGrams, required List plannedTrips}) async { +Future _pumpIngredient( + WidgetTester tester, { + required double remainingGrams, + required List plannedTrips, + List combinationRecommendations = const [], +}) async { await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -27,6 +33,7 @@ Future _pumpIngredient(WidgetTester tester, {required double remainingGram quantitiesDesired: const [Quantity(amount: 900, unit: Unit.grams)], calculatedRemainingQuantities: [Quantity(amount: remainingGrams, unit: Unit.grams)], productRecommendations: const [], + combinationRecommendations: combinationRecommendations, ownedAmount: 0, ownedUnit: const OwnedUnit(), onOwnedChanged: (double amount, OwnedUnit unit) {}, @@ -105,4 +112,33 @@ void main() { expect(find.textContaining("now"), findsNothing); }); }); + + group("ShoppingIngredient best-value banner", () { + // A real 2-product mix, so the "Best value" banner is shown. + CombinationRecommendation buildMix() { + const Product small = Product(link: "a", quantityPerItem: 250, itemsPerPack: 1, unit: Unit.grams); + const Product large = Product(link: "b", quantityPerItem: 600, itemsPerPack: 1, unit: Unit.grams); + return const CombinationRecommendation( + selections: [ + PackSelection(product: small, packs: 1), + PackSelection(product: large, packs: 1), + ], + overBuyWaste: 0, + expiryWaste: 0, + ); + } + + testWidgets("warns that the copied list splits per trip so it can differ from the banner", (WidgetTester tester) async { + await _pumpIngredient(tester, remainingGrams: 850, plannedTrips: const [], combinationRecommendations: [buildMix()]); + + expect(find.text("Best value: "), findsNothing); // it is part of a RichText, not a standalone Text + expect(find.textContaining("per shop trip"), findsOneWidget); + }); + + testWidgets("shows no per-trip note when there is no mix banner", (WidgetTester tester) async { + await _pumpIngredient(tester, remainingGrams: 600, plannedTrips: const []); + + expect(find.textContaining("per shop trip"), findsNothing); + }); + }); } From 15c198226c42465097f2711e853fd432f87940b1 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Wed, 22 Jul 2026 23:12:31 +0200 Subject: [PATCH 4/5] test(shopping): cover bounded-search fallback and expiry infeasibility 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 --- .../test/waste_optimizer_test.dart | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/menu_management/test/waste_optimizer_test.dart b/menu_management/test/waste_optimizer_test.dart index 3298a05..54ebd43 100644 --- a/menu_management/test/waste_optimizer_test.dart +++ b/menu_management/test/waste_optimizer_test.dart @@ -534,6 +534,50 @@ void main() { expect(rec!.selections, isEmpty); expect(rec.totalWaste, closeTo(0, 0.01)); }); + + test("falls back to a single product when the search space exceeds the bound", () { + // Tiny 1 g packs with a huge need blow past _maxCombinationVectors (20000): the 1 g product + // alone needs 30000 packs, so the Cartesian product is far too large and the search is skipped. + // The bounded fallback (_bestSingleAsCombination) returns the best single product instead. + Product tiny = _product(quantityPerItem: 1); + Product bigger = _product(quantityPerItem: 7); + + CombinationRecommendation? rec = recommendCombination( + totalNeeded: 30000, + events: const [], + ingredient: _ingredient(), + products: [tiny, bigger], + ); + + expect(rec, isNotNull); + expect(rec!.selections, isNotEmpty); + expect(rec.isSingleProduct, isTrue); + // 30000 exact-fit 1 g packs (zero waste) beat 4286 x 7 g packs (2 g waste). + expect(packsBySize(rec), {1.0: 30000}); + expect(rec.totalWaste, closeTo(0, 0.01)); + }); + + test("a larger purchase wins when a quantity-sufficient one expires before a later event", () { + // One 600 g pack, opened shelf life 3 days. Two events 300 g each, 10 days apart. + // By raw quantity one 600 g pack covers the 600 g total, but it opens on day 0 and its 300 g + // leftover expires before day 10, so a single pack cannot feed the second event: infeasible. + // The solver must buy 2 packs. Bought 1200 g, consumed 600 g, 300 g expires, 300 g over-buy. + Product pack = _product(quantityPerItem: 600, shelfLifeDays: 3); + + CombinationRecommendation? rec = recommendCombination( + totalNeeded: 600, + events: [_event(day: 0, amount: 300), _event(day: 10, amount: 300)], + ingredient: _ingredient(), + products: [pack], + ); + + expect(rec, isNotNull); + expect(rec!.isSingleProduct, isTrue); + expect(packsBySize(rec), {600.0: 2}); + expect(rec.expiryWaste, closeTo(300, 0.01)); + expect(rec.overBuyWaste, closeTo(300, 0.01)); + expect(rec.totalWaste, closeTo(600, 0.01)); + }); }); group("combinationPackLines", () { From b5942d0ff75908e0f6818142b65a4a3b30b586af Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Wed, 22 Jul 2026 23:12:42 +0200 Subject: [PATCH 5/5] docs(adr): soften optimality claim for mixed-pack solver 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 --- adr/0018-mixed-pack-combination-solver.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adr/0018-mixed-pack-combination-solver.md b/adr/0018-mixed-pack-combination-solver.md index a38c56f..5646665 100644 --- a/adr/0018-mixed-pack-combination-solver.md +++ b/adr/0018-mixed-pack-combination-solver.md @@ -39,7 +39,7 @@ Best is chosen by: (1) lowest total waste, (2) fewest distinct products (so a si ## 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, so within the bound the chosen mix is optimal for the modeled waste. Above the bound it degrades gracefully to the previous single-product recommendation. +- 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.