Recipe/Solver/Claude
This solver is the result of an extended discussion with Claude. The purpose was to find an alternate solver, so results could be checked. Secondly the idea was to explore the possibilities to the extend the NNLS solver for other unknowns in the data.
Decreasing order NNLS
Here's a summary of the theoretical discussion on decreasing-order NNLS:
The basic reformulation
To enforce a non-increasing solution x₁ ≥ x₂ ≥ ... ≥ xₙ, define a difference vector d where d₁ = x₁ and dⱼ = xⱼ₋₁ − xⱼ for j ≥ 2. The constraint d ≥ 0 then encodes the decreasing order exactly. The transformation matrix L maps x = Ld, giving a standard NNLS problem on à = AL with solution recovered as x = Ld.
Non-negativity interaction
Non-negativity on x is not automatically guaranteed by decreasing order — the sequence can cross zero anywhere. When both constraints are required together, they reduce to a single binding condition: xₙ ≥ 0, equivalent to d₁ ≥ d₂ + ... + dₙ. This is a linear inequality on d that cannot be folded into simple non-negativity, making the joint problem a proper quadratic program rather than plain NNLS.
Post-hoc approach
In practice, solving the decreasing-order NNLS without the non-negativity constraint and checking it afterwards was found to work well. This was attributed to the steepness of the decrease — when x declines steeply, xₙ sits well above zero and the non-negativity constraint is comfortably inactive at the solution, making the post-hoc check a genuine reflection of problem structure rather than luck.
Active set behaviour
Adding the decreasing constraint changes the meaning of the active set fundamentally. In plain NNLS, an active variable means xⱼ = 0. In decreasing-order NNLS, an active variable dⱼ = 0 means xⱼ = xⱼ₋₁ — a plateau where consecutive values are tied. Multiple consecutive active variables produce a flat region in the solution, which the solver interprets as "the data does not support any decrease here." This is a softer and often more physically meaningful outcome than hard zeros, and helps explain why non-negativity violations are rare in practice.
Variance and weighting
Weighting interacts with the constrained problem the same way as in plain NNLS for variables in the passive set, with the optimal choice wᵢ = 1/σᵢ² minimising variance. However the active set behaviour — now producing plateaus rather than zeros — introduces bias-variance tradeoffs near the plateau boundaries that make analytic variance formulas unreliable. Bootstrap methods were recommended for honest uncertainty quantification.
Extending NNLS
The base NNLS solver has been extended to support all aspects of a nutritional table.
Problem description
Given a product label (nutrients_product, g per 100 g product) and CIQUAL nutritional data for the candidate ingredients (ingredients, g per 100 g ingredient), find the recipe weights x (g raw ingredient per 100 g final product) such that:
(ingredients / 100) %*% x ≈ nutrients_product
The solver uses non-negative least squares (NNLS) with a reparametrisation that converts ordering constraints into non-negativity constraints on auxiliary variables d, so that standard NNLS can be applied directly.
Inputs
| Argument | Format | Notes |
|---|---|---|
ingredients
|
named list or matrix | CIQUAL values, g per 100 g ingredient. NA = nil (not reported). May contain more nutrients than the label.
|
nutrients_product
|
named numeric vector | Label values, g per 100 g product. NA = not declared on label.
|
chain
|
list of ingredient() / group() nodes
|
Labelling-law ingredient order (descending weight). |
Unit convention: both inputs use g per 100 g (standard packaging convention). The solver divides ingredients by 100 internally; x comes out in grams.
Constraints
Ingredient ordering (mandatory)
Ingredients must appear on the label in descending order of raw weight. Encoded via the chain argument using ingredient() and group() nodes.
chain <- list(
ingredient("flour"),
group(ingredient("water"), ingredient("yeast")), # sub-recipe: water > yeast
ingredient("salt")
)
A group() means the combined weight of its members is greater than the next chain element. Within a group, members are also ordered descending.
Nutrient ordering (default-on)
Physical truths enforced as hard inequality constraints via slack variables:
nutrient_geq = list(c("fat", "sat_fat"), # sat fat is a subset of fat
c("carbs", "sugar")) # sugars are a subset of carbohydrates
Pairs where a nutrient is absent from the label are silently skipped. Additional pairs can be added or the default list replaced.
Nutrient upper bounds
For nutrients declared as < X g on the label:
nutrient_bounds = list(salt = 0.5)
Soft fraction priors
When the label declares a percentage for an ingredient (e.g. "contains 45% tomato"):
fractions = list(tomato = 0.45)
Group fraction priors
When the label declares a percentage for a sub-recipe:
group_fractions = list(
pizza_base = list(members = c("flour", "water", "yeast"), fraction = 0.60)
)
Low-error ratio constraints (near-hard)
For single-nutrient ingredients (salt, sugar, oil, water) where CIQUAL error is small and the label fraction is reliable. The ratio x_i / x_j = f_i / f_j cancels cooking yield uncertainty.
low_error_fractions = list(salt = 0.02, sugar = 0.10)
Nil and missing data
| Situation | How to encode | Effect |
|---|---|---|
| CIQUAL does not report a nutrient for an ingredient | NA in ingredients
|
Cell excluded from fit |
| Nutrient genuinely absent (e.g. oil has 0 carbs) | 0 in ingredients
|
Kept as a real constraint |
| Nutrient not on the product label | NA in nutrients_product
|
Excluded from fit; predicted afterwards |
| Nutrient in CIQUAL but not on the label | extra row in ingredients
|
Used for prediction only, not fitting |
Outputs
Recipe weights
sol$x — named vector, g raw ingredient per 100 g final product.
Cooking yield
sol$yield_pct — 100 / sum(x) * 100 (%). Values well below 100 % indicate concentration or evaporation (e.g. apple syrup ≈ 18 %).
Nutrient fit report
sol$fit_report — observed vs. fitted values and relative errors for all label nutrients. Flags large residuals (> 20 %) which may indicate a poor CIQUAL proxy.
Predicted nutrients
sol$predicted_nutrients — full table of all CIQUAL nutrients, with columns:
observed— label value (if declared)predicted— value computed from solved recipesource— "declared on label" / "on label as NA" / "not on label — predicted from recipe"
Identifiability diagnostics
sol$id_report — column norms and pairwise correlations for each ingredient's CIQUAL profile. Flags ingredients that are nutritionally invisible or collinear with another ingredient (the solver cannot distinguish them).
NNLS plateau report
sol$plateau_report — for zero-valued ingredients, reports whether the zero is a "true zero" (the fit would worsen if the ingredient were added) or "on plateau" (the fit is indifferent — the ingredient is nutritionally interchangeable with another).
Bootstrap uncertainty
sol$bootstrap_report — 95 % confidence intervals on each ingredient weight, obtained by residual resampling over 500 replicates (configurable). Activated with bootstrap = TRUE.
Producer comparison
When the producer's recipe is known (fully or partially), pass it as recipe_product:
recipe_product = c(flour = 78, oil = 20) # salt unknown → shown as "?"
The recipe table in the output gains a "true" column and a signed error column.
Typical call
sol <- solve_recipe(
ingredients = list(
flour = c(fat=1.4, sat_fat=0.2, carbs=73.3, sugar=0.3, fibre=2.7, protein=10.3, salt=0.01),
oil = c(fat=100, sat_fat=11, carbs=0, sugar=0, fibre=0, protein=0, salt=0),
salt = c(fat=NA, sat_fat=NA, carbs=NA, sugar=NA, fibre=NA, protein=NA, salt=97.5)
),
nutrients_product = c(fat=21.2, sat_fat=2.4, carbs=57.4,
sugar=0.2, fibre=2.1, protein=8.1, salt=2.0),
chain = list(ingredient("flour"), ingredient("oil"), ingredient("salt")),
low_error_fractions = list(salt = 0.02),
recipe_product = c(flour = 78, oil = 20, salt = 2), # optional
bootstrap = TRUE
)
Experiment yourself
You can try this all for yourself (very interesting), no coding required. Download Claude, setup a free account and initialise a chat a set of files:
- CLAUDE.md - is the problem description that Claude bases it's reasoning on;
- recipe_solver_v.1.2.py - is the python code that Claude will use;
- Ciqual.csv - the nutritional data for the ingredients;
- IFCT2017.pdf - the Indian nutritional data information as second source;
Ask @aleene on Slack for the files, as the wiki does not support the corresponding file formats.