Skip to content

Prohibit low-calorie products (<=20 kcal) from the knapsack solution #7

Description

@fjzs

Context

User feedback: the optimizer should never recommend a product with 20 calories or less, regardless of whether it would otherwise fit the budget/weight limits. Today calories is only used as the thing being maximized (ObjectiveCalories) — there is no rule anywhere that disqualifies a product outright based on its calorie value.

Implement this using TDD (/tdd: red-green vertical slices, one test at a time) and follow this repo's commenting conventions (/comment-code).

Where this rule belongs

PreProcess (src/engine/preprocessing/preprocessing.py) already removes products that can never appear in any solution: today that means "a single unit already costs more than the budget or weighs more than the capacity." Both the exact MIP strategy and the greedy heuristic consume PreProcessedData.feasible_products unchanged, so anything filtered here disappears from both solvers automatically, with zero changes to Optimization, VariableSelectProduct, or GreedyCalories.

The low-calorie rule is the same shape — "never eligible for selection," not "trim a total" — so it should be added as a third exclusion condition in PreProcess.run(), next to the existing price/weight checks.

Alternatives considered and rejected:

  • A new MIP constraint component (ConstraintMinCalories, forcing x_i = 0 for disqualified products): would require a parallel check in GreedyCalories too, since the heuristic doesn't build a model or read MIP constraints. Duplicated logic for no benefit.
  • Rejecting low-calorie products in Product.__post_init__: too strict — it would make it impossible to even load such a product into a catalogue (e.g. for reporting), when the actual requirement is about selection, not data validity.

Threshold: fixed constant, not per-request configurable

max_weight_kg / max_budget_usd are per-request fields because different requests genuinely need different limits. "20 calories or less" reads as a fixed business rule from user feedback, not something callers should vary — so it becomes a module-level constant in preprocessing.py, e.g.:

# Minimum calories (kcal) a product must exceed to ever be recommended.
# Source: user feedback — products at or below this threshold provide
# negligible nutritional value and must never be selected, independent
# of whether they'd otherwise fit the budget/weight limits.
MIN_CALORIES_KCAL = 20

Filter condition to add to PreProcess.run()'s list comprehension: p.calories > MIN_CALORIES_KCAL (so exactly 20 is excluded — "20 or less").

Files to touch

  • src/engine/preprocessing/preprocessing.py — add MIN_CALORIES_KCAL constant; extend the feasible_products filter with the calorie condition; update the module and class docstrings, which currently describe only the price/weight infeasibility rule, to also state the calorie rule and why it exists (comment-code Rule 1 — why, not what).

  • tests/engine/optimization_strategy/mip/preprocess/test_preprocess.py — following the existing fixture/ARRANGE/ACT/ASSERT style (see test__preprocess__run__excludes_product_whose_price_exceeds_budget), add via TDD red-green vertical slices (one test → minimal code → next test, never all tests up front):

    • a product with exactly 20 calories is excluded (boundary: "or less")
    • a product with 21 calories is kept (boundary: just above threshold)
    • a mixed catalogue where a low-calorie product is filtered alongside an already-covered price/weight-infeasible product, and a normal product survives
  • tests/resources/large_instance_triggers_heuristic/data.jsonrequired fix, not optional: this fixture has 55 products with calories 10–64 so that feasible_products count (55) exceeds Orchestrator.MAX_PRODUCTS_FOR_MIP (50) and the situation exercises the greedy-heuristic path. With the new filter, products with calories 10–20 (11 of them) would be excluded, dropping the feasible count to 44 — at or under the MIP threshold — which would silently reroute this situation to the exact MIP solver and defeat the test's purpose. Shift every product's calorie value in this fixture above 20 (e.g. add a fixed offset) so the situation still tests the heuristic path after the new rule ships. This situation has no golden model.lp to regenerate (it only asserts on solution.csv totals).

  • New integration situation (e.g. tests/resources/low_calorie_product_filtered/) — a data.json with one product at/under 20 calories and one normal product, plus a new test in tests/integration/test_situations.py mirroring test__cli_main__given_individually_infeasible_products__filters_them_and_solves_with_the_rest, asserting the low-calorie product never contributes to the solution and the produced model.lp matches a golden file (generate the golden per the process already documented in that test module's _assert_model_lp_matches_golden docstring).

No changes needed to domain/product.py, domain/request.py, or anything under engine/optimization/mip/optimization/ — the whole rule is contained in the preprocessing stage.

Verification

  • pytest tests/engine/optimization_strategy/mip/preprocess/test_preprocess.py -v
  • pytest -m integration (covers the new situation and confirms large_instance_triggers_heuristic still routes to the heuristic)
  • black src tests && mypy (project requirement in CLAUDE.md)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions