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.json — required 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)
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
caloriesis 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 consumePreProcessedData.feasible_productsunchanged, so anything filtered here disappears from both solvers automatically, with zero changes toOptimization,VariableSelectProduct, orGreedyCalories.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:
ConstraintMinCalories, forcingx_i = 0for disqualified products): would require a parallel check inGreedyCaloriestoo, since the heuristic doesn't build a model or read MIP constraints. Duplicated logic for no benefit.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_usdare 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 inpreprocessing.py, e.g.: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— addMIN_CALORIES_KCALconstant; extend thefeasible_productsfilter 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/ASSERTstyle (seetest__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):tests/resources/large_instance_triggers_heuristic/data.json— required fix, not optional: this fixture has 55 products with calories 10–64 so thatfeasible_productscount (55) exceedsOrchestrator.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 goldenmodel.lpto regenerate (it only asserts onsolution.csvtotals).New integration situation (e.g.
tests/resources/low_calorie_product_filtered/) — adata.jsonwith one product at/under 20 calories and one normal product, plus a new test intests/integration/test_situations.pymirroringtest__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 producedmodel.lpmatches a golden file (generate the golden per the process already documented in that test module's_assert_model_lp_matches_goldendocstring).No changes needed to
domain/product.py,domain/request.py, or anything underengine/optimization/mip/optimization/— the whole rule is contained in the preprocessing stage.Verification
pytest tests/engine/optimization_strategy/mip/preprocess/test_preprocess.py -vpytest -m integration(covers the new situation and confirmslarge_instance_triggers_heuristicstill routes to the heuristic)black src tests && mypy(project requirement inCLAUDE.md)