vital_solver 0.11.3 copy "vital_solver: ^0.11.3" to clipboard
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 :

  1. eC > macroTol after all post-solve corrections (including carb_excess_reduce_main which can push the carb_main below its floor).
  2. At least 4 rows with netCarbsPer100g > 0 and a carb-related class (ui_carb_main / ui_legumineuse / ui_carb_secondary / ui_veg_structural / ui_leafy_finish).

fiber_deficit_no_high_fiber_source :

  1. fiberDeficit > fiberTol after solver.
  2. 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) #

  1. The recipe contains a protein_main with L/P > 0.5 (fatty cut: lamb shoulder 17/17, duck breast 21/15, salmon 20/13, ribeye 22/15, etc.).
  2. The targets are aggressive on L: targetL / targetP < 0.4 (e.g. P=52 / L=16 → 0.31 ✓).
  3. The solver actually failed with a P deficit (eP < -macroTol) AND/OR an L excess (eL > macroTol).
  4. 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 que carb_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.
  • suggestedSection inchangé.
  • severity inchangé.
  • 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 an anchorRole='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', no anchorRole (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-supplied grams becomes the new softCenter if 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 two non sucr… / vinaigre exclusions.

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 as ui_veg_structural by the solver in isolation — the FF shim was the only thing rescuing them.
  • 3 pains had mealUiRole='main_carb' (typo) and fell to ui_veg_structural because the solver only matched 'carb_main'.
  • Spices at totalC=80 (cinnamon, etc.) were mis-classified as simple_sugar because of an over-eager totalC >= 80 heuristic.

Change #

Two changes in lib/src/quality/:

  1. _inferResolvedMealUiRole (non_v22b_bounds.dart) now reads r['solverRole'] first. If present, it is authoritative and the legacy cascade is bypassed entirely. Mapping:

    • fruitveg_structural (solver has no ui_fruit class; isFruitRow continues to fire on name/category for fruit_bounds.dart)
    • simple_sugararomatic_condiment (the simple_sugar branch is activated via isSweetSimpleCarbDoc, which also checks solverRole)
    • any other value → identity
  2. isSweetSimpleCarbDoc (classify_rows.dart) now respects solverRole as 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:

  1. carb_main first (preserved priority, v0.8.4 behaviour).
  2. Fallback to legumineuse if no carb_main is 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_main renamed to Inactive when no carb_main AND no legumineuse with 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 #

  • supportVegCulinaryPortionsMeta per row now carries dynamicMultiplier, softMinG, softCenterG, softMaxG reflecting the scaled values.
  • SupportVegCulinaryDecision gains dynamicMultiplier, softMinGByRowId, softMaxGByRowId, carbMainSoftMaxG fields.
  • The carb_main row gets a supportVegCulinaryPortionsMeta block with role: 'carb_main', floorG, softCenterG, softMaxG, softCenterWeight: 0.50, softMaxWeight: 0.30.

Tests #

  • test/support_veg_culinary_portions_test.dart updated:
    • 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 to false to 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:

  1. 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.
  2. 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
  3. 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.
  4. 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.
  5. 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 (default true) — feature flag for the new pre-solver mode. Set to false to restore legacy 0.8.2 behaviour on carb_main + supports recipes.
  • carbExcessReduceMainEnabled (default true) — feature flag for the new post-solve trim.
  • carbExcessReduceMainSafetyMinG (default 0.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 / carbExcessReduceMainApplied flags.
  • postSolvePipeline list now contains 10 entries (was 9 in 0.8.2, 8 in 0.8.1).
  • Per-row supportVegCulinaryPortionsMeta records 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.dart end-to-end test now explicitly disables supportVegCulinaryPortionsEnabled (the test pins the legacy applyCarbBounds output — the new mode is covered separately).
  • test/optimizer_test.dart pipeline 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 (default true) — feature flag for the new step. Setting it to false restores the legacy 0.8.1 behaviour.

Added — diagnostics #

  • solverInternalPostCorrections.proteinExcessReduceMainPostSolveMeta — records applied, currentPBefore, excessPBefore, adjustments (per-row beforeG/afterG/deltaG/reducedP/reducedL/tier), candidates, reason, totalsAfter, errorsAfter.
  • Top-level diagnostics gain proteinExcessReduceEnabled / proteinExcessReduceApplied flags.
  • postSolvePipeline list 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.dart byte-equivalence relaxed: legacy oracle postSolvePipeline is 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.550.63 (dense now clearly the primary carrier).
    • vegetableOnlyStructuralShare: 0.200.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*Weight defaults (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: 220160. 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 existing vegetable_only_high_volume_warning still 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, and maxG now all match the post-mode finalMaxG when vegetable_only_balanced is active (no more leftover 1e9 values in nested meta fields).
  • vegetableOnlyBalancedMeta.softMaxOverhead, softMinWeight, softMaxWeight, softCenterWeight exposed 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_main AND no legumineuse, only vegetable sources (ui_carb_secondary / ui_veg_structural / ui_leafy_finish). Activates automatically when targetC >= carbAnchorMinC.
  • Replaces the legacy anchorSoloUnbounded mechanism (which set maxG = 1e9 on 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.dart with the public API VegetableOnlyBalancedDecision, computeVegetableOnlyBalancedDecision, applyVegetableOnlyBalancedBounds.
  • New QualityResult.vegetableOnlyBalancedDecision and SolvePipelineResult.vegetableOnlyBalancedDecision fields.
  • Escape overflow : when the sum of categorical caps cannot reach targetC minus macroTol, the best carrier(s) get their maxG raised 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'] with group, targetC, targetG, softCenterG, finalMaxG, extraGApplied, escapeOverflowActive. Mirrored under row['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_active
    • vegetable_only_escape_overflow_active
    • vegetable_only_no_dense_carb_carrier
    • vegetable_only_no_dense_overflow_carrier
    • vegetable_only_medium_fallback_carrier
    • vegetable_only_light_anchor_fallback
    • vegetable_only_structural_escape_overflow
    • vegetable_only_high_volume_warning (parameterized)

Options (defaults, all override-able) #

  • vegetableOnlyBalancedEnabled = true
  • vegetableOnlyDisableAnchorUnbounded = true
  • vegetableOnlyEscapeOverflowEnabled = true
  • vegetableOnlyEscapeOverflowMarginG = 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: false in the recipe context.

Validation #

  • 13 new tests in test/vegetable_only_balanced_test.dart covering Cases A-F from the spec + detection + leafy exclusion.
  • Two legacy test files updated to set vegetableOnlyBalancedEnabled: false so they keep pinning the legacy anchorSoloUnbounded contract: test/anchor_solo_unbounded_test.dart and test/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 no ui_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 from package:vital_solver/vital_solver.dart. Returns a diagnostic map (violated, anchorId, solvedG, theoreticalMaxG, thresholdG, overstretchRatio, hasLegumineuse, hasCarbMain).
  • New field theoreticalAbsoluteMaxG on the anchor row's secondaryCarbBoundsMeta — preserves the categorical cap before the 1e9 sentinel override, so downstream consumers can compare.
  • The new check result is also exposed in the report raw.checks under the key carbAnchorBoundViolationCheck.

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 (with code, 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. okToCreate is 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 (default true) on the recipe context. When the selected C-anchor (isSelectedCarbAnchor=true) is a ui_legumineuse, ui_carb_secondary, or ui_veg_structural row, its hard maxG cap is removed (replaced with sentinel 1e9). This lets the solver fill C/FiMin deficits even when no carb_main is present and the veg/secondary/légumineuse is the sole effective carb source.
  • ui_carb_main and ui_leafy_finish are 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 the secondary_carb_density_caps_v20 cap (e.g. 220g for light density veg_structural) and could not fill the C target or FiMin, producing solver_fiber_deficit or solver_macro_not_met blockings.
  • The flag carbRescueSelectedAnchorUnboundedEnabled already existed but was never consulted in the rescue's _maxG() path — replaced by an explicit pre-solve override in applyCarbBounds().

Where #

  • lib/src/quality/non_v22b_bounds.dart :: applyCarbBounds() — override inserted after the isSelected block, before carbSupportPolicyMeta.
  • lib/src/pipeline/pre_solver_mutations.dart :: applyV18OptionDefaults() — adds the new option default.
  • Metadata anchorSoloUnboundedApplied (bool) propagated on the row and in secondaryCarbBoundsMeta / carbSupportPolicyMeta for traceability.

Validation #

  • 8 new tests in test/anchor_solo_unbounded_test.dart covering: 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.dart updated to explicitly pass anchorSoloUnboundedEnabled: false when 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.dart validate the path dependency exposes the fix end-to-end.

Migration impact #

  • Recipes that previously failed with solver_fiber_deficit or solver_macro_not_met when only veg/secondary/légumineuse carrying C will now create successfully, with the anchor row growing past its former category cap.
  • Recipes with carb_main are 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 #

  • computeMicrosSnapshot no longer double-counts fiber.fiber_g and fiber.sugars_g for rows whose ingredient doc supplies both microsPer100g.fiber.{fiber_g,sugars_g} AND top-level fiberPer100g / sugarsPer100g. The legacy buildMicrosSnapshot in solve_single_meal_with_targets.dart summed both sources, producing microsSnapshot fiber values ≈ 2× the real intake. The 0.7.1 port preserved this bug for byte-equivalence.
  • Now: _addNestedMicros accumulates the nested map once, and the per-row extractor only fills fiber_g / sugars_g when those keys are absent from microsPer100g.fiber. Rows that only store fiberPer100g / sugarsPer100g at 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.fiber is absent.
  • 243 tests pass (was 242 in 0.7.1). 0 lints.

Migration impact #

  • solver.totals.fiber and solvedIngredients[i].fiber_g were never affected by the legacy bug — they remain unchanged.
  • New Repas docs created via saveInlineRecipeMeal (or any consumer of computeMicrosSnapshot) will have correct microsSnapshot.fiber.fiber_g.
  • Existing Repas docs in production (created via the legacy solveSingleMealWithTargets path or earlier 0.7.1 saves) keep the doubled values until they are re-saved or until a one-off batch reconciles microsSnapshot from solvedIngredients.
  • jours.consumedTotals.fiber_g is recomputed by syncDayDoc whenever 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 legacy buildMicrosSnapshot from solve_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's fiber_g / sugars_g via 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 of solvePipeline(...) and buildPreSolveReport(...) runnable via dart 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 to 0.7.0.

Validation #

  • dart pub publish --dry-run produces 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 registers vital_solver: ^0.5.0 as a pub dependency on the FF project. Stage B rewrites the algorithmic custom actions as thin shims that call solvePipeline / 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.dart and sci-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) — registers vital_solver: ^0.5.0
  • --mode=shims (Stage B) — applies Patches 1 + 2
  • --mode=cleanup (Stage C) — removes 5 dead targets via removeCustomFunction(...) / 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 #

  1. Rename existing FF actions → *_legacy.
  2. Stage A push (pub dep).
  3. Stage B push (shim bodies).
  4. Wire feature flag useVitalSolver in page action chains.
  5. Roll 1% → 10% → 50% → 100%.
  6. Validate one full release cycle.
  7. Cleanup: remove *_legacy, remove applyQualityRulesBeforeSolver and macroOptimizerPQDynamicMaxAction (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 between applyQualityRulesBeforeSolver and 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 _preSolveDiag back 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.dartsolvePipeline(rows, targets, options) orchestrator returning SolvePipelineResult (solverOutput + qualityRows + cascadeDecision + final options). Byte-equivalent to the legacy block in solve_single_meal_with_targets.dart.

Added — pre-solve reporting #

  • lib/src/reporting/messages.dart — typed ReportMessage constants 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.dartproteinMainCheck and carbAnchorCheck (direct ports of summaryProteinMainCheck / summaryCarbAnchorCheck).
  • lib/src/reporting/pre_solve_report_builder.dartbuildPreSolveReport(...) 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 from package: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 entry runMacroOptimizer(ingredients, targetsParam). Direct port of legacy macroOptimizerPQDynamicMaxAction (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 budget
    • coordinate_descent.dart — main loop, ordering, soft-penalty, micro-passes
    • predicates.dart — row classifiers + low-level helpers
    • rescue_carb.dart — sweet-batch low-volume rebalance + 3-bucket carb rescue
    • rescue_protein.dart — whey soft-dose rescue (no hard max)
    • rescue_fat.dart — fat trim + fat anchor rescue
    • optimizer.dart itself — 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 #

  • runMacroOptimizer exported from package:vital_solver/vital_solver.dart.
  • Internal types (_Targets, _ParsedInput) stay private; the package speaks legacy-shaped Map<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 ≤ 120g
    • selectCarbAnchorId(rows, targets, ctx) — priority chain: carb_main → légumineuse → carb_secondary → veg_structural → leafy_finish
    • applyCarbBounds(rows, ctx) — per-class caps with secondary density caps v23 (dense / medium / light)
  • Per100g.fromJson now derives netCarbs = totalCarbs - fiber when 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 legacy applyQualityRulesBeforeSolver rows.map().
  • applyQualityRules(rows, targets, ctx) — top-level orchestrator combining classification → cascade → mutations. Returns QualityResult with 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 a SweetBatchV22bDecision with all 4 paths (locked_at_150 / pass1_batter_flex / pass2_simple_carb_to_zero / unsolvable_floor_exceeds_target) and the full batchFitCheckMeta diag 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 from package: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, clampD
  • lib/src/helpers/serialization.dart: decodeIfJsonString, asMap, asList, firestoreSafe, cleanMapForDiag
  • lib/src/helpers/normalization.dart: norm, slug, parseStringList, tagsOf, hasAnyTag, stableId
  • lib/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 100g
  • IngredientInput — solver input ingredient with toLegacyRow() bridge
  • SolverTargets — P/C/L/FiMin + tolerances, with legacy alias chain (Pr/Ca/Li)
  • RecipeContext — typed wrapper over the ~150-key options map, with .standard() defaults
  • EnrichedRow — quality-rules output, mutable for backward compat with optimizer
  • MacroTotals, SolverErrors, SolverOutput — solver result decomposed
  • ReportItem, 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.json validates 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
0
likes
130
points
54
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

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 FlutterFlow, no Flutter SDK dependencies — fully testable via `dart test`.

Repository (GitHub)
View/report issues

Topics

#nutrition #solver #optimization #dart

License

unknown (license)

More

Packages that depend on vital_solver