vital_solver 0.11.3
vital_solver: ^0.11.3 copied to clipboard
Pure-Dart nutritional solver. Coordinate-descent optimizer with quality rules (V22b sweet-batch cascade, fruit/fat/carb bounds, whey rescue) and pre-solve report aggregation. No Firebase, no FlutterFl [...]
Changelog #
0.11.3 — Two new recurring-pattern detections (over-crowded + no-fiber-source) #
Why #
Two more recurring infeasibility patterns were producing the generic
solver_macro_not_met message, which is not actionable. v0.11.3 adds
dedicated detection + targeted messages for each.
Added — new blocking messages #
| Code | When fires | Message |
|---|---|---|
recipe_over_crowded_for_carb_target |
4+ C-contributing rows + eC > tol after solver | "Tu as trop d'ingrédients glucidiques pour cette cible. Retire un légume ou une légumineuse, ou augmente la cible glucidique du repas." |
fiber_deficit_no_high_fiber_source |
fiber deficit + no ingredient with fiberPer100g >= 3.0 |
"Aucun aliment riche en fibres dans ta recette. Ajoute une légumineuse (lentilles, pois chiches) ou un légume fibreux (chou kale, brocoli, chou-fleur)." |
The existing solver_fiber_deficit keeps firing when fiber sources ARE
present but quantities aren't sufficient — the message tells the user to
add MORE quantity, not to add NEW ingredients.
Cascade order (specific patterns first → most actionable) #
1. fatty_protein_p_l_mismatch (0.11.2)
2. recipe_over_crowded_for_carb_target (NEW)
3. fiber_deficit_no_high_fiber_source (NEW)
4. solver_fiber_deficit (existing, refined)
5. solver_macro_not_met (fallback)
The cascade is specific-first: more precise patterns win over the generic fallback, even when multiple conditions hold. E.g. an over-crowded recipe that also has insufficient fiber will see the over-crowded message (the actionable advice is "retire un légume", not "ajoute des fibres").
Detection criteria #
recipe_over_crowded_for_carb_target :
eC > macroTolafter all post-solve corrections (includingcarb_excess_reduce_mainwhich can push the carb_main below its floor).- At least 4 rows with
netCarbsPer100g > 0and a carb-related class (ui_carb_main/ui_legumineuse/ui_carb_secondary/ui_veg_structural/ui_leafy_finish).
fiber_deficit_no_high_fiber_source :
fiberDeficit > fiberTolafter solver.- No row has
fiberPer100g >= 3.0.
Tests #
test/recurring_blocking_patterns_test.dart— 4 new tests:- Over-crowded fires at C=5 with 4 vegs + lentilles + bavette
- Over-crowded does NOT fire at C=50 (comfortable target)
- Fiber-no-source fires on bavette + tomate + riz (all fiber < 3 g/100g)
- Fiber-no-source does NOT fire when kale is present
- All 321 tests pass with 0 regression.
No API changes #
Same as v0.11.1 and v0.11.2 — message codes are stable, only the cascade that picks which message to emit was extended.
0.11.2 — Detection of "fatty protein / low L target" mismatch #
Why #
Recipes combining a fatty protein_main (épaule d'agneau, magret de canard,
saumon, bœuf gras…) with an aggressive lipid target are mathematically
infeasible — the protein source brings too much fat to ever satisfy both
P and L. Before 0.11.2 these recipes were blocked with the generic message
solver_macro_not_met ("Les cibles macros ne sont pas atteignables…"),
which didn't tell the user how to fix it.
Added — new blocking message #
code: 'fatty_protein_p_l_mismatch'
message: "Ta protéine principale est trop grasse pour ta cible lipides.
Choisis un cut plus maigre (poulet, dinde, filet) ou ajoute une
protéine maigre en supplément (whey, blanc d'œuf)."
suggestedSection: 'protein'
Detection criteria (all must hold) #
- The recipe contains a
protein_mainwithL/P > 0.5(fatty cut: lamb shoulder 17/17, duck breast 21/15, salmon 20/13, ribeye 22/15, etc.). - The targets are aggressive on L:
targetL / targetP < 0.4(e.g. P=52 / L=16 → 0.31 ✓). - The solver actually failed with a P deficit (
eP < -macroTol) AND/OR an L excess (eL > macroTol). - No protein supplement (whey) is present — if there's a supplement, the whey-rescue path can close the P deficit, so this message would be misleading.
When all 4 conditions hold, the dedicated message replaces the generic
solver_macro_not_met in blockingReasons.
Validated outcome (canonical case) #
Recipe: épaule d'agneau (P=17, L=17) + tomate + kale + lentilles Targets: P=52, C=35, L=16, FiMin=8.
| Before (0.11.1) | After (0.11.2) |
|---|---|
solver_macro_not_met ("Les cibles ne sont pas atteignables…") |
fatty_protein_p_l_mismatch ("Ta protéine principale est trop grasse… choisis un cut plus maigre ou ajoute du whey/blanc d'œuf") |
Tests #
test/fatty_protein_pl_mismatch_test.dart— 3 tests:- Fires on agneau + L=16 (canonical case)
- Does NOT fire when protein_main is lean (poulet P=23/L=3)
- Does NOT fire when L target is generous (L=30, ratio L/P ≥ 0.4)
- All 317 tests pass with 0 regression.
No API changes #
The detection runs in pre_solve_report_builder.dart as an additional
branch in the solver-failure cascade. No public API touched, no options
added. Existing integrations keep working unchanged.
0.11.1 — User-facing message wording cleanup (text-only) #
Why #
Three messages displayed in the pre-solve report cards were either vague, circular, or too technical for end users. 0.11.1 is a text-only patch — no solver logic changes, no API changes.
Changed messages #
| Code | Before | After |
|---|---|---|
missing_carb_anchor (blocking) |
"Ajoute un glucide capable de porter les glucides." | "Aucun ingrédient ne peut porter les glucides. Ajoute un féculent, une légumineuse ou des légumes." |
solver_macro_not_met (blocking) |
"Le solveur ne tombe pas exactement sur les cibles. Ajuste les ingrédients ou ajoute une ancre." | "Les cibles macros ne sont pas atteignables avec ces ingrédients. Ajuste la composition, retire un ingrédient saturé ou ajoute une source manquante." |
carb_anchor_is_not_true_main_carb (warning) |
"Aucun glucide principal/féculent n'est présent. Le flow utilise donc la meilleure autre source glucidique comme ancre C selon l'ordre strict : légumineuse, carb secondary, végétal structure, feuilles." | "Aucun féculent n'est présent. Le solveur utilise la meilleure alternative disponible (légumineuse ou légumes) comme source glucidique principale." |
Rationale #
missing_carb_anchor: la formulation précédente était circulaire ("ajoute un glucide pour porter les glucides") et ne suggérait quecarb_main. Le nouveau message liste explicitement les 3 options valides (féculent / légumineuse / légumes).solver_macro_not_met: "ajoute une ancre" était flou. Le nouveau message liste 3 stratégies actionnables (ajuster la composition, retirer un ingrédient saturé, ajouter une source manquante).carb_anchor_is_not_true_main_carb: exposait des codes internes ("carb secondary", "végétal structure"). Reformulé en français nutritionnel commun.
Backward compatibility #
- Codes des messages inchangés — toute UI/intégration qui switche sur le code continue de marcher.
suggestedSectioninchangé.severityinchangé.- Seul le champ
message(texte affiché) est modifié.
Tests #
- 314 tests passent, 0 régression (aucun test n'épinglait les anciennes strings).
0.11.0 — New support_laitiers role for dairy supports #
Why #
Skim milk and low-fat yoghurts (lait écrémé, yaourt nature 0% / 2%, etc.) have balanced-but-low macros (P=3-4, C=5, L=0.1-3.8) that do not match any of the existing flows:
- Classifying them as
fat(current state) makes them ananchorRole='L'ingredient, but their lipid content is so low that the solver would have to push the dose to absurd values (e.g. 15 000 g of skim milk to hit a 15 g L target). - They are culinarily supports — used in fixed-ish doses (a yoghurt is ~125 g, a glass of milk is ~150 g), not adjusted to fill a macro target.
Change #
New solverRole='support_laitiers' value. When a row carries it:
genericCarbClass='non_carb', noanchorRole(not pulled by any flow).role='variable'so the row still contributes its macros to totals.- Default bounds:
minG=50,maxG=250,softCenter=125(a typical yoghurt). User-suppliedgramsbecomes the newsoftCenterif present. - Overridable via ctx:
supportLaitiersMinG,supportLaitiersMaxG,supportLaitiersDefaultG, plus weight knobs.
The row's macros are counted in totals as for any variable row; the solver simply has no "pull" to grow or shrink it.
Migration #
Backwards-compatible. Docs without solverRole='support_laitiers' are
unaffected. To use the new role on a doc, set solverRole='support_laitiers'
in Firestore. The FF shim (action patched in 0.9.0) already passes
solverRole through, so no shim change is required.
Tests #
test/support_laitiers_test.dart adds 5 tests: role mapping, default
bounds, user-grams override, anchor-exclusion, ctx overrides.
All 314 tests pass (309 → 314). Zero regressions.
0.10.0 — Remove legacy classification cascade; trust solverRole #
Why #
v0.9.0 introduced solverRole as the primary classification source but kept
the entire pre-v0.9.0 fallback for un-migrated docs. Now that all 314
production docs carry solverRole (migration completed 2026-05-15) and
the FlutterFlow action propagates it, the fallback is dead weight that
makes the code harder to reason about and was the source of two whole
classes of bugs (see below).
Change #
_inferResolvedMealUiRole (non_v22b_bounds.dart):
Cascade reduced from 9 read sources to 3. After the solverRole priority
check, the legacy fallback reads only mealUiRole (with normalization of
main_carb / fat_topping / fat_nut / fibres aliases), then
resolvedMealUiRole, then selectedMealUiRole. The FF-shim-injected
mealRole / section / creationSection are no longer read on raw input
rows — those fields can still be set by the package on enriched output
rows, but they're no longer required as input.
isSweetSimpleCarbDoc (classify_rows.dart):
Two heuristic branches REMOVED for docs without solverRole:
macroPure(c≥60 ∧ p≤3 ∧ l≤3 ∧ fiber≤2.5) — was catching dried fruits and high-density starches incorrectly.totalC >= 80— was catching cinnamon (totalC=80 with fiber=53) and dry vermicelli (totalC=80) incorrectly. The legacy fallback now only does name-based lexical matching on a small list of explicit sugar words (sucre,miel,sirop,confiture,caramel,maple,agave) with twonon sucr…/vinaigreexclusions.
Migration #
Breaking only for rows that previously relied on the removed heuristics.
If you were classifying a doc as simple_sugar via totalC >= 80 alone
(no sugar word in the name), you now need to set solverRole=simple_sugar
explicitly. The production migration script handled this on 2026-05-15
for all 314 docs.
The full classification cascade (mealRole / section / creationSection
inspection) is gone. If you were passing those keys on input rows
expecting them to be read, switch to setting solverRole directly.
Tests #
Two pre-existing tests that asserted the removed heuristics
(macroPure detection and totalC >= 80 → simple carb) were rewritten to
assert the new behavior (they now verify that those rows are NOT classified
as simple_sugar). Two new tests cover the v0.10.0 solverRole
authoritative path. Total: 309/309 tests pass (zero functional regressions
on real-world cases).
0.9.0 — solverRole as single source of truth for ingredient classification #
Why #
The classification of an ingredient (carb_main / legumineuse / fat / etc.)
was previously derived from a cascade of ~9 fields (mealUiRole,
selectedMealUiRole, categorie, smartCarbGroup, qualityTags,
carbSolverType, etc.) with non-obvious priority rules, half of which had
to be injected by the FlutterFlow shim before the solver could even see
the ingredient. An audit of the production Firestore Ingredients
collection (314 docs) revealed several silent bugs caused by this design:
- 27 légumineuses (Lentilles / Haricots / Pois / Fèves) had
mealUiRole='fibres'and were classified asui_veg_structuralby the solver in isolation — the FF shim was the only thing rescuing them. - 3 pains had
mealUiRole='main_carb'(typo) and fell toui_veg_structuralbecause the solver only matched'carb_main'. - Spices at totalC=80 (cinnamon, etc.) were mis-classified as
simple_sugarbecause of an over-eagertotalC >= 80heuristic.
Change #
Two changes in lib/src/quality/:
-
_inferResolvedMealUiRole(non_v22b_bounds.dart) now readsr['solverRole']first. If present, it is authoritative and the legacy cascade is bypassed entirely. Mapping:fruit→veg_structural(solver has noui_fruitclass;isFruitRowcontinues to fire on name/category forfruit_bounds.dart)simple_sugar→aromatic_condiment(the simple_sugar branch is activated viaisSweetSimpleCarbDoc, which also checkssolverRole)- any other value → identity
-
isSweetSimpleCarbDoc(classify_rows.dart) now respectssolverRoleas authoritative:solverRole=simple_sugar→ returns true unconditionally- any other
solverRole→ returns false (lexical / macroPure / totalC≥80 fallbacks are skipped) - no
solverRole→ legacy behavior preserved
Migration #
Backwards-compatible: docs without solverRole continue to be classified
via the existing cascade. The legacy fallback will be removed in v0.10.0
once the FlutterFlow shim is also simplified to read solverRole directly.
Tests #
test/solver_role_priority_test.dart adds 9 new tests covering each
canonical solverRole value plus the legacy fallback. All 307 tests pass
(298 pre-existing + 9 new). Zero regressions.
0.8.5 — Légumineuse as fallback anchor when no carb_main #
Why #
The v0.8.4 mode support_veg_culinary_portions activated only when a true
carb_main (riz, pdt, pâtes) was present. Recipes anchored on a
légumineuse alone (lentilles, pois chiches…) fell into an architectural
gap: vegs got legacy bounds (no dynamic softCenters), and the légumineuse
itself had no opposing pull. Result: vegs could saturate at their absolute
hard caps (e.g. butternut at 154 g via anchor_solo_unbounded) while kale
stayed sub-portioned (61 g) — culinarily unbalanced.
Change #
computeSupportVegCulinaryDecision now activates with a 2-step anchor
lookup:
carb_mainfirst (preserved priority, v0.8.4 behaviour).- Fallback to
legumineuseif nocarb_mainis present.
When the légumineuse is the anchor, it receives the same FLOOR_5050
treatment as a carb_main: minG = softCenterG = floor, softMaxG = floor × 1.10,
softCenterWeight = 0.50. The vegs supports get the v0.8.4 dynamic softCenters.
Behaviour preserved #
- carb_main + légumineuse together: carb_main still wins as anchor. The légumineuse remains a "free variable" for the solver. This is the same behaviour as v0.8.4 — verified to give a culinarily good plate (the légumineuse acts as a natural C-amortisseur: 0 g at C=20, ~40 g at C=35, 136 g at C=70 with riz).
Diagnostics #
- New field
SupportVegCulinaryDecision.anchorClass:'carb_main','legumineuse', or''(inactive). - Per-row meta for the anchor now shows
'role': 'anchor'and'anchorClass': 'carb_main'or'legumineuse'. - Inactive reason renamed:
'inactive_no_carb_main'→'inactive_no_carb_main_or_legumineuse'.
Validated outcomes (canonical case: bavette + kale + butternut + tomate + lentilles + huile, P=52) #
| Cible C | v0.8.4 (mode inactif) | v0.8.5 (légumineuse comme ancre) |
|---|---|---|
| 20 g | kale 121, butternut 30, tomate 66, lentilles 65 | kale 121, butternut 30, tomate 66, lentilles 65 (similaire — déjà OK) |
| 35 g | kale 62, butternut 154 (au cap!), tomate 89, lentilles 140 | kale 107, butternut 95 (portion culinaire), tomate 99, lentilles 168 (= floor) |
| 50 g | kale 59, butternut 80, tomate 93, lentilles 242 | kale 143 (cap), butternut 104 (cap), tomate 155, lentilles 218 |
| 70 g | kale 172 (>cap!), butternut 142, tomate 156, lentilles 230 | kale 143, butternut 104, tomate 156, lentilles 430 |
Le déséquilibre du butternut au cap à C=35 (cas typique) est corrigé : il revient à 95 g (portion culinaire normale).
Tests #
- New test
Active when legumineuse present (no carb_main)— covers the v0.8.5 activation. - New test
carb_main wins over legumineuse when both present— covers the priority preservation. - Existing test
Inactive when no carb_mainrenamed toInactive when no carb_main AND no legumineusewith updated expected reason. - All 298 tests pass with 0 regression.
Migration #
Fully transparent — no caller-side changes needed. The option flag
supportVegCulinaryPortionsEnabled (default true) controls both
v0.8.4 and v0.8.5 behaviour.
0.8.4 — FLOOR_5050 + COMBO: dynamic veg softCenters + strong carb_main pull #
Why #
On low-density carb_mains (potato 15.29 g C/100g, sweet potato 17.4 g C/100g), the v0.8.3 solver still preferred inflating the carb_main rather than pushing veg supports toward their culinary softCenter. The culinary softCenters were fixed (kale 110, butternut 80, tomate 120), but the carb_main had no opposing pull, making it free to deviate from its floor.
On the canonical bavette + kale + tomate + butternut + pomme de terre + huile
recipe at P=52/C=35/L=17, v0.8.3 produced:
- PDT 168 g (73% of C target — way above the 50% floor)
- Kale 62 g, Tomate 89 g, Butternut 42 g (all sub-culinary)
Two new mechanisms combined #
1. Dynamic veg softCenters
The veg softCenters are no longer fixed: they are now dynamically scaled so
that the present veg supports collectively absorb vegBudgetC = 0.5 × targetC
at their (scaled) softCenter. The scaling is density-proportional:
naturalCgC[veg] = culinarySoftCenter[density] × netC[veg] / 100
multiplier = vegBudgetC / Σ naturalCgC
dynamicSoftCenter[veg] = culinarySoftCenter × multiplier
clamped to [hardMin, culinarySoftCenter × 1.3]
softMin and softMax are framed around the dynamic softCenter:
- softMinG = softCenter × 0.70
- softMaxG = softCenter × 1.15 (clamped to hard cap)
- softCenterWeight = 0.20 (was 0.12 in 0.8.3)
- softMaxWeight = 0.30
2. Strong softCenter on the carb_main at its floor
The carb_main now gets:
- softCenterG = floor (= 50% × targetC × 100 / netC)
- softCenterWeight = 0.50 (strong pull — resists deviation)
- softMaxG = floor × 1.10 (plage of +10% without softMax penalty)
- softMaxWeight = 0.30
The carb_main's hard max is unchanged → at high C targets, the vegs hit their culinary cap and the carb_main is forced to grow past softMax (paying the soft penalty), preserving the v0.8.3 property "carb_main absorbs the excess C at high targets".
Validated outcomes (canonical regression case) #
Recipe: bavette + kale + tomate + butternut + carotte + carb_main + huile. Targets P=52 / L=17 / FiMin=8, C varies.
Riz basmati (76.8 g C/100g — dense)
| C cible | 0.8.3 actuel | 0.8.4 (COMBO) |
|---|---|---|
| C=35 | kale 104 / tom 130 / but 94 / riz 23 | identique (déjà optimal) |
| C=50 | kale 113 / tom 129 / but 84 / riz 43 | kale 143 / tom 151 / but 104 / riz 38 |
Pomme de terre (15.29 g C/100g — light density)
| C cible | 0.8.3 actuel | 0.8.4 (COMBO) | Effet |
|---|---|---|---|
| C=35 | kale 62 / tom 89 / but 42 / pdt 168 | kale 78 / tom 120 / but 82 / pdt 132 | vegs +89, pdt −36 |
| C=50 | kale 59 / tom 93 / but 80 / pdt 242 | kale 119 / tom 155 / but 104 / pdt 197 | vegs +146, pdt −45 |
The veg C share goes from 27% → 42% with PDT at C=35 (close to the 50% target). At higher C targets, vegs saturate at their culinary cap and the carb_main grows to absorb the rest — same behaviour as v0.8.3.
Diagnostics #
supportVegCulinaryPortionsMetaper row now carriesdynamicMultiplier,softMinG,softCenterG,softMaxGreflecting the scaled values.SupportVegCulinaryDecisiongainsdynamicMultiplier,softMinGByRowId,softMaxGByRowId,carbMainSoftMaxGfields.- The carb_main row gets a
supportVegCulinaryPortionsMetablock withrole: 'carb_main',floorG,softCenterG,softMaxG,softCenterWeight: 0.50,softMaxWeight: 0.30.
Tests #
test/support_veg_culinary_portions_test.dartupdated:- Old test "Vegs get culinary soft centers" renamed and updated to expect dynamic values at C=35.
- 3 new tests covering the v0.8.4 mechanisms: carb_main softCenter, multiplier scaling with C target, soft bounds framing.
Backward compatibility #
- The option
supportVegCulinaryPortionsEnabled(default true) still toggles the entire mode. Set tofalseto disable both v0.8.3 and v0.8.4 logic. - Vegs that were near a fixed softCenter in 0.8.3 may now be at a slightly different position because of dynamic scaling — but all macros stay within tolerance and culinary portions are preserved (or improved).
0.8.3 — support_veg_culinary_portions (FLOOR_5050) + carb_excess_reduce_main #
Added — pre-solver mode support_veg_culinary_portions #
A new quality-rules step that activates whenever the recipe contains a
carb_main row AND at least one selected support veg
(carb_secondary / veg_structural / leafy_finish). It complements the
existing vegetable_only_balanced mode (which handles the no carb_main
case) — the two are mutually exclusive.
Problem solved: when a starchy carb_main (rice, potato, pasta) is
present, the v0.8.2 solver landed support vegs at trace amounts (2 g /
7 g / 10 g) because the carb_main alone covered the C target — the vegs
became mathematically optional. Culinarily, the plate looked empty.
FLOOR_5050 strategy:
carb_main.minG = 50% × targetC × 100 / netC— the starchy anchor cannot drop below half of the C allocation. Below C=35 it stays around 20 g cru (a culinary minimum); above C=35 it adapts upward freely.- Adaptive hard mins per veg density:
- veg_structural medium (kale, brocoli) →
minG = 45 g - carb_secondary dense (butternut, carotte) →
minG = 30 g - carb_secondary light (tomate, courgette) →
minG = 40 g - leafy_finish →
minG = 20 g
- veg_structural medium (kale, brocoli) →
- Auto scale-down when the recipe is over-crowded
(
Σ veg_minC + carb_main_floor_C > targetC) — the proposed mins shrink proportionally so the recipe stays solvable. - Culinary softCenters (kale 110 g, butternut 80 g, tomate 120 g, carotte 80 g, leafy 30 g) with weight 0.12 — pull vegs toward real portions without rigid lock-in.
- Hard cap at
softCenter × 1.3— protects vegs against inflation when C target is high. Forces the carb_main to be the variable that adapts upward instead of the vegs gonfler past their culinary range.
Added — post-solve step carb_excess_reduce_main #
A new optimizer step inserted right before final_refresh. Mirrors the
existing protein_excess_reduce_main step from v0.8.2.
Trigger: eC > macroTolC after all other corrections (carb_rescue,
protein_rescue, fat_rescue, fat_trim) have run.
Action: reduces the carb_main row(s) — sorted by netC descending,
densest first → minimal mass change — until eC ≤ macroTolC or the
safety floor (carbExcessReduceMainSafetyMinG, default 0) is reached.
The trim is allowed to go below the FLOOR set by
support_veg_culinary_portions because the design priority is
protecting culinary veg portions in over-crowded recipes.
Added — options #
supportVegCulinaryPortionsEnabled(defaulttrue) — feature flag for the new pre-solver mode. Set tofalseto restore legacy 0.8.2 behaviour oncarb_main + supportsrecipes.carbExcessReduceMainEnabled(defaulttrue) — feature flag for the new post-solve trim.carbExcessReduceMainSafetyMinG(default0.0) — lower bound below which the trim refuses to reduce the carb_main further. Set above 0 to enforce a minimum culinary portion even when over-crowded.
Added — diagnostics #
solverInternalPostCorrections.carbExcessReduceMainPostSolveMeta— per-row trim adjustments (id / beforeG / afterG / deltaG / reducedC), candidates list, totalsAfter, errorsAfter, reason.- Top-level diagnostics gain
carbExcessReduceMainEnabled/carbExcessReduceMainAppliedflags. postSolvePipelinelist now contains 10 entries (was 9 in 0.8.2, 8 in 0.8.1).- Per-row
supportVegCulinaryPortionsMetarecords the floor, hard mins (proposed vs applied), softCenter, hard cap, and applied scale.
Validated outcomes (canonical regression case) #
Recipe: bavette + kale + butternut + tomate + carotte + riz + huile. Targets: P=52, L=17, FiMin=8. C varies.
| C target | bavette | kale | butternut | tomate | carotte | riz | huile | eC |
|---|---|---|---|---|---|---|---|---|
| 20 | 220 | 123 | 30 | 40 | 30 | 11 (trimmed below floor) | 3 | 0.00 |
| 35 | 222 | 64 | 45 | 116 | 103 | 23 | 3 | 0.00 |
| 50 | 208 | 94 | 102 | 132 | 104 | 33 | 4 | -0.07 |
| 70 | 191 | 134 | 104 | 137 | 101 | 56 | 4 | -0.05 |
All four C targets land within macroTolC = 1 and all 4 vegs are present
in culinary portions. Before v0.8.3, the same recipe at C=35 produced
butternut at 2 g, tomate at 2 g, carotte at 7 g (3 vegs at trace level).
Tests #
- New test file
test/support_veg_culinary_portions_test.dart— 12 tests covering activation conditions, bounds applied to rows, solver outcomes at C=35, riz scaling at C=70, the post-solve trim at C=20, feature flags, and the pipeline ordering. test/non_v22b_bounds_test.dartend-to-end test now explicitly disablessupportVegCulinaryPortionsEnabled(the test pins the legacyapplyCarbBoundsoutput — the new mode is covered separately).test/optimizer_test.dartpipeline ordering test updated to include the new step.
0.8.2 — protein_excess_reduce_main post-solve trim #
Added — new post-solve step #
A new step protein_excess_reduce_main_after_carb_rescue is inserted in
the post-solve pipeline between carb_rescue_overflow_v17b_sweet_batch_priority
and protein_supplement_rescue_whey_soft_dose.
Problem solved: when carb_rescue_overflow had to push a low-density
carb anchor (typically a vegetable like tomato at 2.69 g C/100g) far above
its softCenter to hit the C target, the added mass carried collateral
protein. On the canonical regression case (bavette + kale + tomate + huile,
P=52/C=35/L=17), the +180 g of tomato added by carb_rescue carried
+1.59 g of P (tomate.proteinPer100g = 0.88), pushing total P to 53.61
(eP = +1.61 > macroTol = 1). The recipe was reported as solver_macro_not_met
and blocked from creation.
Fix: the new step reduces protein anchors (protein_supplement →
lean protein_main → fatty protein_main, densest first within each
tier) until eP ≤ macroTolP. The collateral fat loss from trimming a
fatty protein is picked up by the existing fat_rescue_overflow step
downstream.
| 0.8.1 | 0.8.2 | |
|---|---|---|
| Bavette | 185 g (blocked) | 177.11 g |
| P total | 53.61 (eP=+1.61) | 52.00 (eP=0.00) |
| C total | 35.00 | 35.00 |
| L total | 16.97 | 16.52 |
constraintsOk |
false |
true |
okToCreate |
false |
true |
Added — options #
proteinExcessReduceEnabled(defaulttrue) — feature flag for the new step. Setting it tofalserestores the legacy 0.8.1 behaviour.
Added — diagnostics #
solverInternalPostCorrections.proteinExcessReduceMainPostSolveMeta— recordsapplied,currentPBefore,excessPBefore,adjustments(per-rowbeforeG/afterG/deltaG/reducedP/reducedL/tier),candidates,reason,totalsAfter,errorsAfter.- Top-level diagnostics gain
proteinExcessReduceEnabled/proteinExcessReduceAppliedflags. postSolvePipelinelist now contains 9 entries (was 8).
Tests #
- New test file
test/protein_excess_reduce_test.dart— 5 tests covering the canonical bavette+tomate case, the new pipeline ordering, the feature flag, the no-op path, and the tier priority. - Manual sanity script
tool/verify_protein_excess_fix.dart— runs the canonical case with and without the fix and prints a side-by-side comparison. test/optimizer_test.dartbyte-equivalence relaxed: legacy oraclepostSolvePipelineis now a subsequence of the package's list (every legacy step is present, in order; the package may add new steps).
0.8.1 — Tighten vegetable_only_balanced distribution #
Changed (default behavior — no API breakage) #
- Group shares (case 1 / dense present):
vegetableOnlyDenseShare:0.55→0.63(dense now clearly the primary carrier).vegetableOnlyStructuralShare:0.20→0.12(structural held back to its true role: volume/fibre, not C porter).vegetableOnlyLightShare:0.15(unchanged).
- Explicit overflow redistribution: the previously-unused 10% overflow
share is now redistributed across the present groups by the
vegetableOnlyOverflow*Weightdefaults (dense 0.70, light 0.20, structural 0.10). The solver no longer has to discover where the slack C should land. - Structural cap lowered:
vegetableOnlyStructuralMaxG:220→160. veg_structural rows can no longer be pushed past their culinary plage in the normal phase (escape overflow can still raise it dynamically with warning).
Added — per-group soft-bound shaping #
The single vegetableOnlySoftMaxOverhead = 1.30 option from 0.8.0 was too
permissive for structural / leafy. New per-group options (defaults shown):
| Group | SoftMaxOverhead | SoftMaxWeight | SoftCenterWeight | SoftMinFraction | SoftMinWeight |
|---|---|---|---|---|---|
| dense | 1.25 | 0.18 | 0.05 | 0.40 | 0.04 |
| medium | 1.20 | 0.22 | 0.12 | 0.60 | 0.10 |
| light | 1.15 | 0.25 | 0.12 | 0.55 | 0.10 |
| structural | 1.12 | 0.35 | 0.30 | 0.90 | 0.30 |
| leafy | 1.05 | 0.40 | 0.25 | 0.50 | 0.15 |
The legacy global options (vegetableOnlySoftMaxOverhead,
vegetableOnlySoftCenterWeight, vegetableOnlySoftMinFraction,
vegetableOnlySoftMinWeight) are still consulted as fallbacks for callers
that explicitly overrode them in 0.8.0.
Added — new warning code #
vegetable_only_very_high_volume_warning— emitted when total resolved vegetable weight ≥vegetableOnlyTotalVegWeightStrongWarningG(default 1000 g). Non-blocking. The existingvegetable_only_high_volume_warningstill fires at the lower threshold (default 800 g).
Behaviour change vs 0.8.0 #
On the canonical test recipe (bavette + kale + tomate + butternut + huile, targets P=52 / C=35 / L=17 / FiMin=8):
| 0.8.0 | 0.8.1 | |
|---|---|---|
| Bavette | 198 g | 219 g |
| Chou kale (structural) | 176 g | 85 g |
| Tomate (light) | 191 g | 205 g |
| Courge butternut (dense) | 223 g | 263 g |
| Huile | 4 g | 3 g |
| Total | 792 g | 772 g |
| Macros met | yes | yes |
Kale stops being an over-grown C carrier and goes back to its structural role; butternut takes a larger share as the dense primary carrier.
Diagnostic consistency #
secondaryCarbBoundsMeta.absoluteMaxG,rescueMaxG, andmaxGnow all match the post-modefinalMaxGwhen vegetable_only_balanced is active (no more leftover1e9values in nested meta fields).vegetableOnlyBalancedMeta.softMaxOverhead,softMinWeight,softMaxWeight,softCenterWeightexposed for introspection.
Validation #
- All 272 existing tests still pass. Case A expectations updated for the new share defaults.
0.8.0 — vegetable_only_balanced carb distribution mode #
Added #
- New auto-activated carb distribution mode for recipes that contain
no
carb_mainAND nolegumineuse, only vegetable sources (ui_carb_secondary/ui_veg_structural/ui_leafy_finish). Activates automatically whentargetC >= carbAnchorMinC. - Replaces the legacy
anchorSoloUnboundedmechanism (which setmaxG = 1e9on the selected C-anchor and let one row absorb all carbs) with a group-share + soft-center distribution :- dense 55%, structural 20%, light 15%, medium 15%, leafy 5% (defaults, override-able via options).
- Fallback shares when no dense is present : medium primary 50%, light primary 45%.
- New file
lib/src/quality/vegetable_only_balanced.dartwith the public APIVegetableOnlyBalancedDecision,computeVegetableOnlyBalancedDecision,applyVegetableOnlyBalancedBounds. - New
QualityResult.vegetableOnlyBalancedDecisionandSolvePipelineResult.vegetableOnlyBalancedDecisionfields. - Escape overflow : when the sum of categorical caps cannot reach
targetCminusmacroTol, the best carrier(s) get theirmaxGraised dynamically (finalMaxG = normalMaxG + extraGNeeded + marginG). Never reverts to a 1e9 sentinel. Priority for the boost: dense > medium > light > structural. Leafy excluded. - Per-row diagnostic
row['vegetableOnlyBalancedMeta']withgroup,targetC,targetG,softCenterG,finalMaxG,extraGApplied,escapeOverflowActive. Mirrored underrow['carbSupportPolicyMeta']for backwards compatibility. - Top-level diagnostic at
report.raw['checks']['vegetableOnlyBalanced']exposing the full decision map. - 8 new non-blocking warning codes:
vegetable_only_balanced_mode_activevegetable_only_escape_overflow_activevegetable_only_no_dense_carb_carriervegetable_only_no_dense_overflow_carriervegetable_only_medium_fallback_carriervegetable_only_light_anchor_fallbackvegetable_only_structural_escape_overflowvegetable_only_high_volume_warning(parameterized)
Options (defaults, all override-able) #
vegetableOnlyBalancedEnabled = truevegetableOnlyDisableAnchorUnbounded = truevegetableOnlyEscapeOverflowEnabled = truevegetableOnlyEscapeOverflowMarginG = 20- Group shares:
DenseShare 0.55,MediumShare 0.15,StructuralShare 0.20,LightShare 0.15,LeafyShare 0.05,OverflowShare 0.10 - Fallback shares:
MediumFallbackShare 0.50,LightFallbackShare 0.45 - Per-tier escape weights:
OverflowDenseWeight 0.70,MediumWeight 0.45,LightWeight 0.20,StructuralWeight 0.10,LightFallbackWeight 0.45 - Categorical caps:
DenseMaxG 380,MediumMaxG 280,StructuralMaxG 220,LightMaxG 250,LeafyMaxG 120 - Soft-bound shaping:
SoftCenterWeight 0.05,SoftMinWeight 0.04,SoftMinFraction 0.40,SoftMaxOverhead 1.30 - Warning thresholds:
TotalVegWeightWarningG 800,TotalVegWeightStrongWarningG 1000
Behaviour change vs 0.7.4 #
On a recipe like bavette + kale + tomate + courge butternut + huile (targets P=52, C=35, L=17, FiMin=8) the legacy run produced butternut=310g + kale=19g (one-row-carries-everything). The new mode produces butternut=209g + kale=181g + tomate=230g — balanced distribution, no 1e9 sentinel, macros still met, fiber 8.7g→14.4g.
Compatibility #
- Public API additive only; existing fields untouched.
- Legacy callers can preserve the 0.7.x behavior by setting
vegetableOnlyBalancedEnabled: falsein the recipe context.
Validation #
- 13 new tests in
test/vegetable_only_balanced_test.dartcovering Cases A-F from the spec + detection + leafy exclusion. - Two legacy test files updated to set
vegetableOnlyBalancedEnabled: falseso they keep pinning the legacyanchorSoloUnboundedcontract:test/anchor_solo_unbounded_test.dartandtest/anchor_solo_overstretch_warning_test.dart. - Full suite: 272 passing, 0 failing.
0.7.4 — Contextual warning when anchor solo unbounded is over-stretched #
Added #
- New warning
carb_anchor_exceeds_theoretical_no_legume— fires when the solo-unbounded C-anchor (0.7.3+) ended up at more than 1.5× its theoretical categorical cap AND the recipe contains noui_legumineuse. Message suggests adding "un féculent ou une légumineuse". - New warning
carb_anchor_exceeds_theoretical_with_legume— same trigger but when the recipe already contains a légumineuse (which is then the over-stretched anchor by priority order). Message suggests adding "un féculent" only. - New check function
carbAnchorBoundViolationCheck(rows, gramsById, ctx)exported frompackage:vital_solver/vital_solver.dart. Returns a diagnostic map (violated,anchorId,solvedG,theoreticalMaxG,thresholdG,overstretchRatio,hasLegumineuse,hasCarbMain). - New field
theoreticalAbsoluteMaxGon the anchor row'ssecondaryCarbBoundsMeta— preserves the categorical cap before the1e9sentinel override, so downstream consumers can compare. - The new check result is also exposed in the report
raw.checksunder the keycarbAnchorBoundViolationCheck.
Threshold #
- Trigger:
solvedG > 1.5 × theoreticalAbsoluteMaxG. A 1.5× margin keeps the warning quiet on small overshoots and only fires on truly absurd portions (e.g. 1167g of chou-fleur for a recipe targeting C=35).
UI consumption #
- No shape change. The new warning appears in
report.warnings[]like any other (withcode,message,severity: 'warning',suggestedSection: 'carb_main'). Status auto-aggregates:'ok' → 'warning'when the new warning fires with no blocking reasons.
Validation #
- 6 new tests in
test/anchor_solo_overstretch_warning_test.dart: recettes 1 & 2 (chou-fleur & kale solo, variant A), recette légumineuse (variant B), recette 3 (sous le seuil 1.5×, no warning), recette avec carb_main (excluded from solo-unbounded, no warning), flag disabled (no warning). - 257 tests pass (was 251 in 0.7.3). 0 lints in vital_solver itself.
Migration impact #
- Recipes that were previously creatable but produced extreme portions
now get a contextual UI hint suggesting the user add a féculent and/or
légumineuse.
okToCreateis unaffected. - Older callers that don't read the new fields are unaffected — additive change only.
0.7.3 — Anchor solo unbounded for veg/secondary/légumineuse C-anchors #
Added #
- New option
anchorSoloUnboundedEnabled(defaulttrue) on the recipe context. When the selected C-anchor (isSelectedCarbAnchor=true) is aui_legumineuse,ui_carb_secondary, orui_veg_structuralrow, its hardmaxGcap is removed (replaced with sentinel1e9). This lets the solver fill C/FiMin deficits even when nocarb_mainis present and the veg/secondary/légumineuse is the sole effective carb source. ui_carb_mainandui_leafy_finishare excluded by design — carb_main has its own cap logic (carbMainAbsoluteMaxG=280), and leafy_finish is intentionally kept capped to force the user to add a more substantial carb source.- Only the row that wins the anchor selection is debanded; other rows of the same family (non-anchor supports) keep their normal category caps.
Why #
- Previously, recipes like
bavette + chou-fleur + huile(no carb_main, no légumineuse) hit thesecondary_carb_density_caps_v20cap (e.g. 220g for light density veg_structural) and could not fill the C target or FiMin, producingsolver_fiber_deficitorsolver_macro_not_metblockings. - The flag
carbRescueSelectedAnchorUnboundedEnabledalready existed but was never consulted in the rescue's_maxG()path — replaced by an explicit pre-solve override inapplyCarbBounds().
Where #
lib/src/quality/non_v22b_bounds.dart::applyCarbBounds()— override inserted after theisSelectedblock, beforecarbSupportPolicyMeta.lib/src/pipeline/pre_solver_mutations.dart::applyV18OptionDefaults()— adds the new option default.- Metadata
anchorSoloUnboundedApplied(bool) propagated on the row and insecondaryCarbBoundsMeta/carbSupportPolicyMetafor traceability.
Validation #
- 8 new tests in
test/anchor_solo_unbounded_test.dartcovering: 3 user recipes (chou-fleur, chou kale, carotte+kale), légumineuse solo, carb_main exclusion, leafy_finish exclusion, flag disabled. - 2 existing tests in
non_v22b_bounds_test.dartupdated to explicitly passanchorSoloUnboundedEnabled: falsewhen testing base cap math. - 251 tests pass (was 249 in 0.7.2). 0 lints.
- 3 cross-package integration tests in
sci-filter/test/solver_integration_test.dartvalidate the path dependency exposes the fix end-to-end.
Migration impact #
- Recipes that previously failed with
solver_fiber_deficitorsolver_macro_not_metwhen only veg/secondary/légumineuse carrying C will now create successfully, with the anchor row growing past its former category cap. - Recipes with
carb_mainare unaffected (carb_main excluded by design). - The new behavior can be disabled per-call via
options['anchorSoloUnboundedEnabled'] = false.
0.7.2 — Fix double-counting bug in computeMicrosSnapshot #
Fixed #
computeMicrosSnapshotno longer double-countsfiber.fiber_gandfiber.sugars_gfor rows whose ingredient doc supplies bothmicrosPer100g.fiber.{fiber_g,sugars_g}AND top-levelfiberPer100g/sugarsPer100g. The legacybuildMicrosSnapshotinsolve_single_meal_with_targets.dartsummed both sources, producing microsSnapshot fiber values ≈ 2× the real intake. The 0.7.1 port preserved this bug for byte-equivalence.- Now:
_addNestedMicrosaccumulates the nested map once, and the per-row extractor only fillsfiber_g/sugars_gwhen those keys are absent frommicrosPer100g.fiber. Rows that only storefiberPer100g/sugarsPer100gat the top level continue to be aggregated correctly.
Validation #
- Updated test "alias chain fiber" → now asserts single-count (4.0 / 2.0) instead of legacy double-count (8.0 / 4.0).
- Added test asserting per-row extractor still works when
microsPer100g.fiberis absent. - 243 tests pass (was 242 in 0.7.1). 0 lints.
Migration impact #
solver.totals.fiberandsolvedIngredients[i].fiber_gwere never affected by the legacy bug — they remain unchanged.- New Repas docs created via
saveInlineRecipeMeal(or any consumer ofcomputeMicrosSnapshot) will have correctmicrosSnapshot.fiber.fiber_g. - Existing Repas docs in production (created via the legacy
solveSingleMealWithTargetspath or earlier 0.7.1 saves) keep the doubled values until they are re-saved or until a one-off batch reconcilesmicrosSnapshotfromsolvedIngredients. jours.consumedTotals.fiber_gis recomputed bysyncDayDocwhenever a Repas changes — so daily aggregates self-heal as users mark meals consumed.
0.7.1 — Public computeMicrosSnapshot helper #
Added #
computeMicrosSnapshot({rows, gramsById})— pure-Dart port of legacybuildMicrosSnapshotfromsolve_single_meal_with_targets.dart. Aggregates the four micronutrient groups (vitamins,minerals,fiber,lipidProfil) from solved rows × grams, recursing into nested numeric maps and filling fiber group'sfiber_g/sugars_gvia the alias-aware [fiberPer100] / [sugarsPer100] extractors.- Exported from
package:vital_solver/vital_solver.dart.
Why #
The FlutterFlow shim that creates a Repas directly from the recipe
creation flow (skipping the Recettes write) needs to compute the
microsSnapshot Firestore field after the solve. Exposing it from the lib
keeps the shim under FlutterFlow's 64 KB per-action limit and avoids
re-implementing ~30 lines of recursive aggregation.
Validation #
- 10 new tests cover scaling, nested recursion, accumulation across rows, zero-grams skip, alias chain fallback for fiber/sugars, NaN/Infinity drop, and ingredientId fallback when rowId absent.
- 242 tests still pass (was 232 in 0.7.0). 0 lints.
0.7.0 — Phase 7 publishable package #
The package is feature-complete and ready to publish. No library code changes; this release adds the metadata, license, example, and publishing guide that turn the package into a deployable artifact.
Added #
LICENSE— proprietary/internal-use text (no redistribution, no competing-product use). Replaceable with MIT/Apache-2.0 if the decision changes when publishing publicly.example/vital_solver_example.dart— minimal end-to-end demo ofsolvePipeline(...)andbuildPreSolveReport(...)runnable viadart run example/vital_solver_example.dart.PUBLISHING.md— covers all 4 hosting options (pub.dev public, Cloudsmith / Artifact Registry, self-hosted pub server, FlutterFlow library project), with the recommended path (pub.dev public), privacy-by-obscurity notes, and the post-publish Phase 6 sequence.
Changed #
pubspec.yaml— proper description, topics (nutrition, solver, optimization, dart), placeholder homepage / repository / issue_tracker fields ready to fill in after the hosting choice. Version bumped to0.7.0.
Validation #
dart pub publish --dry-runproduces only the expected "set homepage / repository" warning (resolvable post-publish by editing the metadata fields). No structural issues.- Total compressed package size: 106 KB (well below pub.dev's 100 MB limit; well within reason for FlutterFlow codegen too).
- 232 tests still pass; 0 lints.
Hosting decision (final after pivot) #
The owner initially chose Google Cloud Artifact Registry as the
private host (reusing the existing Firebase project), but
gcloud artifacts repositories create --repository-format=dart
errors with "No such value for DART in Enum FormatValueValuesEnum"
(gcloud SDK 567.0.0 confirmed). Artifact Registry does not support
the Dart repository format despite supporting npm/maven/python/go.
After weighing the alternatives — Cloudsmith (~25-50€/mo for true private), self-hosted pub server (1-2 days infra), or pub.dev public — the owner chose pub.dev public. Rationale:
- The package contains no proprietary IP, no VITAL credentials, no client data — it's a portable nutritional-macro solver.
- 5-min setup vs hours/days for any private alternative.
- FlutterFlow integrates natively with pub.dev (no custom server config needed).
- Cloudsmith remains a documented escape hatch if the IP situation ever changes.
pubspec.yaml now omits publish_to:, so dart pub publish targets
pub.dev by default. Once published, the package is resolvable as
vital_solver: ^0.7.0 from any Dart/FlutterFlow project worldwide.
0.6.0 — Phase 6 deployment artifacts #
This is a package-side milestone, not a code release: no library
changes. It marks the point at which vital_solver is feature-complete
for FlutterFlow shim consumption and the deployment artifacts that live
outside the package (in the parent sci-filter/ FF workspace) are
authored and reviewed.
Deliverables (in parent workspace) #
sci-filter/dsl/edit_phase6.dart— two-stage DSL script. Stage A registersvital_solver: ^0.5.0as a pub dependency on the FF project. Stage B rewrites the algorithmic custom actions as thin shims that callsolvePipeline/buildPreSolveReport.sci-filter/MIGRATION_PHASE6.md— full surgical patch guide. Covers the pre-deploy rename to_legacy, feature-flag rollout, rollback path, and Patches 1-6 (the original 2 main shims plus 3 extra custom actions plus 1 custom function — see "scope correction" below).sci-filter/shims/solve_single_meal_orchestration_after.dartandsci-filter/shims/build_pre_solve_report_orchestration_after.dart— reference snippets for the 2 main shims' algorithmic blocks.
Scope correction — full caller inventory #
Initial Phase 6 estimate (2 actions to shim) looked incomplete after a
grep surfaced 5 callers + 1 custom function. Owner review then
confirmed 3 of those callers are dead code in the live project, so
the actual migration is back to ~2 patches.
| Caller | Live? | Migration |
|---|---|---|
solve_single_meal_with_targets |
yes (also reached via upsertAndSolveMealsForDatesJours) |
Patch 1 — full solvePipeline |
build_recipe_creation_pre_solve_report |
yes | Patch 2 — buildPreSolveReport |
create_and_solve_meals_for_dates |
dead (owner-confirmed) | none — delete the action |
build_recipe_solved_bundle_action |
dead (owner-confirmed) | none — delete the action |
replan_day_meals_fixed_targets_action |
dead (owner-confirmed) | none — delete the action |
flutter_flow/custom_functions.dart buildPreSolveDiagnosticsReport |
dead (owner-confirmed) | delete via Stage C |
flutter_flow/custom_functions.dart line 1161 (duplicate applyQualityRulesBeforeSolver) |
only used by the function above | delete with the consumer via Stage C |
Net actual scope: Patches 1 + 2 + a Stage C cleanup that removes
3 dead custom actions and 2 dead custom functions. Patches 3-5 from
earlier drafts are preserved in MIGRATION_PHASE6.md (collapsed) for
reference only.
The DSL script edit_phase6.dart is now three-staged:
--mode=pub-dep(Stage A) — registersvital_solver: ^0.5.0--mode=shims(Stage B) — applies Patches 1 + 2--mode=cleanup(Stage C) — removes 5 dead targets viaremoveCustomFunction(...)/removeCustomAction(...), each wrapped in try/catch so an absent entry doesn't fail the run.
Stage C run after at least one full release cycle of validated Stage B traffic.
Pre-requisite: Phase 7 #
FFDependency (the FF project schema) only stores name + version. Git
URLs are not supported. So Phase 7 must complete first so
vital_solver is resolvable on pub.dev or a private pub server.
This is reordered from the original plan (which had 6 → 7) but matches
the actual dependency direction: the shim Dart code in Phase 6 uses
import 'package:vital_solver/...';, so the package must be reachable
before the shim can be deployed.
Bascule (rollout) plan #
- Rename existing FF actions →
*_legacy. - Stage A push (pub dep).
- Stage B push (shim bodies).
- Wire feature flag
useVitalSolverin page action chains. - Roll 1% → 10% → 50% → 100%.
- Validate one full release cycle.
- Cleanup: remove
*_legacy, removeapplyQualityRulesBeforeSolverandmacroOptimizerPQDynamicMaxAction(now redundant).
Package version bumped to 0.6.0 #
Signals Phase 6 readiness even though no library code changed. The next substantive release will be 0.7.0 when the package is actually published.
0.5.0 — Phase 5 pipeline + reporting #
Added — pipeline orchestrator #
lib/src/pipeline/pre_solver_mutations.dart— direct ports of legacy helpers that run betweenapplyQualityRulesBeforeSolverand the optimizer:applyV18OptionDefaults— pipeline-level option defaults (superset of optimizer V17b defaults; adds carb-secondary density caps, legume-transfer toggles, V18 sweet-batch flags).syncContextFromPreSolveDiag— pulls V18 sweet-batch decisions out of_preSolveDiagback into options.normalizeProteinSupplementBoundsAfterQuality— re-applies whey soft-dose policy after applyQualityRules.applyCarbMainMinimumWhenLegumePresent— raises carb_main floor (default 20 g) when both carb_main and legume are variable.
lib/src/pipeline/solve_pipeline.dart—solvePipeline(rows, targets, options)orchestrator returningSolvePipelineResult(solverOutput + qualityRows + cascadeDecision + final options). Byte-equivalent to the legacy block insolve_single_meal_with_targets.dart.
Added — pre-solve reporting #
lib/src/reporting/messages.dart— typedReportMessageconstants for blocking reasons (missing_protein_main_or_supplement,missing_carb_anchor,no_ingredients), warnings (protein_supplement_used_as_fallback,carb_anchor_is_not_true_main_carb,vegetables_only_for_medium_carb_target), parameterized sweet-system warnings, and suggestions (solver_fiber_deficit,solver_macro_not_met). Codes match legacy so existing FF UI consumers don't break.lib/src/reporting/checks.dart—proteinMainCheckandcarbAnchorCheck(direct ports ofsummaryProteinMainCheck/summaryCarbAnchorCheck).lib/src/reporting/pre_solve_report_builder.dart—buildPreSolveReport(...)that runs solvePipeline, performs the structural checks, aggregates blocking/warnings/suggestions, and emits a legacy-shape report payload ready for the FF UI.
Tests #
- 232 tests passing (was 212 in 0.4.0).
- 11 new pipeline tests cover applyV18OptionDefaults, the two pre-solver mutations, and end-to-end solvePipeline (golden path, legume floor, whey rescue).
- 9 new reporting tests cover proteinMainCheck (true main / fallback / none), carbAnchorCheck (anchor selected / missing / targetC≈0), and buildPreSolveReport (empty input, golden path, whey-only fallback).
- 0 lints.
Public API extended #
runMacroOptimizer(Phase 4) and Phase 5 entry points exported frompackage:vital_solver/vital_solver.dart:solvePipeline,SolvePipelineResult,applyV18OptionDefaults,applyCarbMainMinimumWhenLegumePresent,normalizeProteinSupplementBoundsAfterQuality,syncContextFromPreSolveDiag,proteinMainCheck,carbAnchorCheck,buildPreSolveReport,PreSolveReportResult,ReportMessage.
File sizes (each well under FlutterFlow's 64 KB limit) #
- pipeline/pre_solver_mutations.dart: 18.6 KB
- pipeline/solve_pipeline.dart: 5.2 KB
- reporting/checks.dart: 9.2 KB
- reporting/pre_solve_report_builder.dart: 8.7 KB
- reporting/messages.dart: 6.1 KB
Note — proteinSecondPass retry deferred #
The plan listed retry-on-proteinSecondPass-failure as part of Phase 5; it
was deferred because the legacy proteinSecondPassReviewEmbedded logic
spans 200+ lines of solver-result re-analysis that is rarely triggered
in practice. The current buildPreSolveReport runs the pipeline once;
the retry can land as a 0.5.x patch when traffic data shows it's
actually needed.
0.4.0 — Phase 4 optimizer port #
Added — runMacroOptimizer (byte-equivalent to legacy) #
lib/src/optimizer/optimizer.dart— library root + public entryrunMacroOptimizer(ingredients, targetsParam). Direct port of legacymacroOptimizerPQDynamicMaxAction(V17e/idfix, sweet-batch three-bucket- whey rescue + fat trim + grams fix).
- 7 part files split for the 64 KB FlutterFlow custom-code limit:
compute_errors.dart— totals/errors/constraints + simple-sugar budgetcoordinate_descent.dart— main loop, ordering, soft-penalty, micro-passespredicates.dart— row classifiers + low-level helpersrescue_carb.dart— sweet-batch low-volume rebalance + 3-bucket carb rescuerescue_protein.dart— whey soft-dose rescue (no hard max)rescue_fat.dart— fat trim + fat anchor rescueoptimizer.dartitself — entry, parsing, defaults, sweet detect, normalize
Tests #
- 212 tests passing (was 203 in 0.3.2).
- 9 new optimizer tests including 6 byte-equivalence diffs against the
frozen legacy oracle (
test/legacy/macro_optimizer_legacy.dart): golden path, protein-only, whey rescue, carb deficit, fat trim, sweet batch 3-bucket. Output keys (totals,errors,gramsById,constraintsOk,version,solverModel,postSolvePipeline) match legacy exactly. - 0 lints. Analyzer still strict-casts/inference/raw-types clean.
Public API extended #
runMacroOptimizerexported frompackage:vital_solver/vital_solver.dart.- Internal types (
_Targets,_ParsedInput) stay private; the package speaks legacy-shapedMap<String, dynamic>rows so the optimizer plugs into both the existing FF pipeline and the typed-EnrichedRow bridge Phase 5 will provide.
File sizes (each well under FlutterFlow's 64 KB limit) #
- optimizer.dart: 31.8 KB · rescue_carb.dart: 14.9 KB · predicates.dart: 11.9 KB
- rescue_fat.dart: 8.4 KB · coordinate_descent.dart: 8.1 KB
- rescue_protein.dart: 4.6 KB · compute_errors.dart: 3.4 KB
- Total ≈ 83 KB across 7 files (legacy was a single 91 KB / 2484-line file).
0.3.2 — Phase 3 complete #
Added — non-V22b bounds #
lib/src/quality/non_v22b_bounds.dart— direct port of legacy steps 1-5:normalizeMacrosAndRoles(rows, ctx)— macro normalization + role inference (recipe_block / simple_sugar / protein_main / fat / carb_main / etc.)applyProteinSupplementBounds(rows, ctx)— whey soft-dose bounds (minG=15, softCenter=20, no hardMax by default)applyFatCaps(rows, ctx)— oil ≤ 20g, butter ≤ 18g, other fat ≤ 120gselectCarbAnchorId(rows, targets, ctx)— priority chain: carb_main → légumineuse → carb_secondary → veg_structural → leafy_finishapplyCarbBounds(rows, ctx)— per-class caps with secondary density caps v23 (dense / medium / light)
Per100g.fromJsonnow derivesnetCarbs = totalCarbs - fiberwhen not explicit (matches legacy semantics).
Tests #
- 203 tests passing (was 187 in 0.3.1).
- 16 new non-V22b tests cover all 5 sub-modules + 1 end-to-end (chicken + rice + tomate + oil).
- 0 lints.
applyQualityRules is now byte-equivalent to legacy #
The orchestrator runs all 7 steps in order (5 non-V22b + 2 V22b) producing
the same enriched-rows shape as legacy applyQualityRulesBeforeSolver. Ready
for Phase 4 (optimizer).
0.3.1 — Phase 3 V22b pipeline (intermediate) #
Added — V22b row mutations + orchestrator #
applyV22bMutations(decision, rows, targets, ctx)— applies the cascade decision to rows. Mutates batter (bounds + diag), fruits (per-fruit dynamic bounds + Option B promotion), simple carbs (cascade-aware minG = 0 in PASSE 2). Picks first fruit as carb anchor (C). Direct port of legacyapplyQualityRulesBeforeSolverrows.map().applyQualityRules(rows, targets, ctx)— top-level orchestrator combining classification → cascade → mutations. ReturnsQualityResultwith rows + cascade decision. Phase 3 finishing will add bounds for non-V22b categories (carb_main / legumineuse / fat / protein_main).
Tests #
- 187 tests passing (was 174 in 0.3.0).
- 4 new applyV22bMutations tests (batter bounds, miel cascade PASSE 2, fruit promotion, inactive pass-through).
- 9 end-to-end applyQualityRules tests (8 V22b fixtures + non-V22b fallback).
- 0 lints.
Functional V22b pipeline #
The V22b sweet batch system is now fully functional in pure Dart:
- Multi-fruit fitCheck (the original bug fix)
- Cascade PASSE 1 / PASSE 2 / unsolvable detection
- canonicalSimpleMinC fix (sugar minG read fixed)
- Option B per-fruit promotion to own center
- All bounds applied to rows including diagnostic fields
0.3.0 — Phase 3 quality rules sub-modules #
Added — V22b cascade core #
lib/src/quality/fruit_bounds.dart:computeDynamicFruitBounds(fruit, fruitPolicy, ctx, hasSimpleCarb)— direct port of legacy formula (densityFactor × fiberFactor → softMin/center/goodMax/warning/strong/overflow).lib/src/quality/classify_rows.dart: row classifiers ported from legacy:rowIsVariable,isCondimentRole,isRecipeBlockRow,isPreparedSweetBatchRow,isProteinSupplement,isSweetSimpleCarbDoc,isFruitRow,isCarbCandidate,isFatOil,isFatButter,isCarbUiClass.lib/src/quality/sweet_batch_v22b.dart: the V22b cascade in pure-Dart form.computeSweetBatchV22bCascade(rows, targets, ctx)returns aSweetBatchV22bDecisionwith all 4 paths (locked_at_150 / pass1_batter_flex / pass2_simple_carb_to_zero / unsolvable_floor_exceeds_target) and the fullbatchFitCheckMetadiag identical to legacy.
Tests #
- 174 tests passing (was 115 in 0.2.0).
- 9 fruit_bounds tests pin density/fiber factor formulas vs reference values from real Firestore solverMeta.
- 39 classify_rows tests cover all classifier predicates with French/English/macro-density inputs.
- 11 sweet_batch_v22b tests replay the 8 synthetic V22b fixtures + 2 non-V22b paths. Every cascade path is regression-tested.
- 0 lints.
Public API extended #
computeDynamicFruitBounds,computeSweetBatchV22bCascade,SweetBatchV22bDecision, all classifiers exported frompackage:vital_solver/vital_solver.dart.
0.2.0 — Phase 2 helpers + portable types #
Added — helpers (byte-equivalent ports of legacy) #
lib/src/helpers/conversions.dart: toD, toI, toB, toS, clampDlib/src/helpers/serialization.dart: decodeIfJsonString, asMap, asList, firestoreSafe, cleanMapForDiaglib/src/helpers/normalization.dart: norm, slug, parseStringList, tagsOf, hasAnyTag, stableIdlib/src/helpers/macros.dart: per100g extractors (protein/fat/fiber/sugars/totalCarbs/netCarbs/kcal) + cFromG / pFromG / lFromG / fiberFromG / totalCFromG
Added — portable types (no Firestore deps) #
Per100g— macro values per 100gIngredientInput— solver input ingredient withtoLegacyRow()bridgeSolverTargets— P/C/L/FiMin + tolerances, with legacy alias chain (Pr/Ca/Li)RecipeContext— typed wrapper over the ~150-key options map, with.standard()defaultsEnrichedRow— quality-rules output, mutable for backward compat with optimizerMacroTotals,SolverErrors,SolverOutput— solver result decomposedReportItem,PreSolveReport— UI gate output
Tests #
- 115 tests passing across
bootstrap_test.dart,helpers_test.dart(84 tests),types_test.dart(24),snapshot_regression_test.dart(7). - 0 lints, 0 warnings (strict-casts/inference/raw-types).
- End-to-end decoding test on
fixture_v22b_02_pass1_5fruits_no_sugar.jsonvalidates that fixtures parse cleanly through the type layer.
Public API activated #
- Exports for all 4 helpers + 6 types in
lib/vital_solver.dart.
0.1.0 — Phase 1 bootstrap #
- Package skeleton initialized via
dart create -t package. - Strict
analysis_options.yaml(strict-casts, strict-inference, strict-raw-types). - Directory structure scaffolded for Phases 2–5:
lib/src/{types,helpers,quality,optimizer,pipeline,reporting},test/{snapshots,legacy}. - README + plan reference.
- Public API stubbed in
lib/vital_solver.dart(exports commented, activated as phases land). - 9 regression fixtures captured (1 real Firestore + 8 synthetic V22b cascade scenarios covering all 4 resolution modes).
Future #
- 0.3.x — Phase 3 quality rules (apply_quality_rules, classify_rows, compute_bounds, sweet_batch_v22b)
- 0.4.x — Phase 4 optimizer (coordinate_descent, rescue_protein/carb/fat)
- 0.5.x — Phase 5 pipeline + reporting orchestrators
- 0.6.x — Phase 6 shims FlutterFlow + first prod usage behind feature flag
- 0.7.x — Phase 7 Git dependency switch
- 1.0.0 — Phase 8 legacy removed, prod fully on package