From 76ee8fd6e3449361ff064c3609d43354b832c3ba Mon Sep 17 00:00:00 2001 From: langbart Date: Tue, 22 Sep 2026 16:04:11 +0200 Subject: [PATCH] Release version 0.4.0 with min/max inclusion layer enhancements - Introduced a new min/max inclusion layer to improve feed formulation accuracy, allowing for dietary nutrient and ingredient limits. - Added optional `nutrient_limits` and `ingredient_limits` inputs to the `/formulate` and `/validate-recipe` endpoints for per-request overrides. - Updated the `/ready` endpoint to validate inclusion limit tables at deploy time. - Enhanced the CHANGELOG, README, and documentation to reflect these new features and their usage. - Fixed validation issues to ensure consistency between `/formulate` and `/validate-recipe` responses. --- CHANGELOG.md | 21 +- README.md | 38 ++- docs/architecture.md | 9 +- docs/integration.md | 20 ++ docs/testing-guide.md | 196 +++++++++++++-- examples/tilapia_starter_demo.py | 12 +- fasa_api/main.py | 36 ++- fasa_core/__init__.py | 2 +- fasa_core/config/defaults.py | 25 ++ fasa_core/config/ingredient_limits.csv | 43 ++++ fasa_core/config/nutrient_limits.csv | 40 +++ fasa_core/constraint_builder.py | 132 +++++++++- fasa_core/inclusion_limits.py | 308 +++++++++++++++++++++++ fasa_core/models.py | 224 ++++++++++++++++- fasa_core/optimizer.py | 157 +++++++----- fasa_core/validator.py | 162 +++++++++++- pyproject.toml | 2 +- tests/test_api.py | 87 +++++++ tests/test_smoke.py | 336 +++++++++++++++++++++++++ 19 files changed, 1729 insertions(+), 121 deletions(-) create mode 100644 fasa_core/config/ingredient_limits.csv create mode 100644 fasa_core/config/nutrient_limits.csv create mode 100644 fasa_core/inclusion_limits.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0372f85..f105306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.4.0] - 2026-09-22 + +### Added +- Minimum/maximum inclusion layer. Nutrient composition alone is not enough to formulate on: the optimizer could return diets that met every nutrient target yet could not be extruded or produce growth, such as bran-heavy rations. Two new tables carry the limits a formulator would otherwise apply by hand — `fasa_core/config/nutrient_limits.csv` (bounds on dietary nutrient levels, by ASNS spec code) and `fasa_core/config/ingredient_limits.csv` (bounds on how much of a single ingredient may go in, by FICD code). +- Optional `nutrient_limits` and `ingredient_limits` inputs on `/formulate`, for callers that already know the bounds they want. An entry overrides the table for the code it names. +- `recipe[]` lines now carry the limit applied to each ingredient (`min_inclusion_percent`, `max_inclusion_percent`, `limit_source`), and `composition[]` lines carry `source`, saying whether a target came from the species requirement or from the limit layer. +- `infeasibility.bound_conflicts` explains, in plain language, ingredient limits that cannot add up to a full batch — the one failure the existing conflict report cannot describe. +- `/validate-recipe` now checks a recipe's inclusion rates, not only its composition. Optional `species`, `stage`, `production_system`, `processing_method`, `premix_enabled` and the two limit inputs scope the check; `in_limits`, `inclusion_checks`, `nutrient_checks`, `total_inclusion_percent` and `warnings` report the result. Existing payloads are unaffected. +- `/ready` also loads the two limit tables, so a malformed table is caught at deploy time rather than on the first request. + +### Changed +- An ingredient given a `0` maximum is left out of the recipe even when it is priced, and the response names it in a warning — so "we should not recommend this at all" is distinguishable from "too expensive today". +- Where a limit and the species requirement bound the same nutrient, the tighter of the two applies. A limit can never relax an ASNS requirement. + +### Fixed +- A recipe returned by `/formulate` no longer reads as out of spec when sent straight back to `/validate-recipe`. Both endpoints now score achieved-versus-target through the same code, with a tolerance that allows for the rounding in the published inclusion percentages. + +### Notes +- Both tables ship with documented headers and **no data rows** — the values are still being compiled, so formulations are unchanged until the columns are filled. Activating them needs no code change. +- Ingredient limits are written and sent as a mass fraction of total feed (`0.15` = 15%) but reported back as a percentage (`max_inclusion_percent: 15.0`). Nutrient limits use the ASNS unit of their spec code and are not rescaled. ## [0.3.0] - 2026-07-21 diff --git a/README.md b/README.md index 20ea991..64b7b79 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,11 @@ Project changes are tracked in [`CHANGELOG.md`](CHANGELOG.md). - **Linear programming engine** (PuLP + HiGHS) that solves a digestibility-aware least-cost feed formulation against the active ASNS constraints for a chosen species/stage/production-system tuple. - **Premix-aware constraint masking**: vitamins and trace minerals are assumed satisfied by a fixed-rate vitamin/mineral premix (default 0.5%), keeping the LP focused on macros, amino acids, digestible energy, Ca, P, fatty acids, and toxin ceilings. - **Hard ceilings on toxins / anti-nutrients**: aflatoxin B, gossypol, phytic acid, glucosinolates, tannins, etc. are enforced as Maximum constraints regardless of premix or override settings. +- **Min/max inclusion layer**: a second layer of bounds on top of composition data, because nutrient tables alone let the LP build diets that pass every target yet cannot be extruded or grown on. `fasa_core/config/nutrient_limits.csv` adds Minimum/Maximum bounds on dietary nutrient levels (the tighter of ASNS and the limit binds); `fasa_core/config/ingredient_limits.csv` bounds how much of any single ingredient may go in. Both ship empty pending the data — filling the columns activates them with no code change — and either can be supplied per request via `nutrient_limits` / `ingredient_limits`. - **Opt-in advisory caps**: collective binder mass cap and fish-meal cost-share cap are available as optional inputs but are *not* applied by default. Toxicity and ASNS nutrient limits remain the only fixed constraints; callers that want to force a sustainability or pellet-quality ceiling can pass `max_fishmeal_cost_share` and/or `max_binder_inclusion` explicitly. - **Hard-fail with IIS reporting**: when the constraint set is infeasible at the supplied prices/pool, a deletion-filter algorithm extracts a minimal Irreducible Inconsistent Subset of constraints and returns it to the caller so the miller knows exactly why the recipe could not be built. - **PAFF benchmark gate**: independent recomputation of PAFF reference recipes' nutrient composition acts as the correctness test for the data-loading and crosswalk pipeline. -- **FastAPI surface** with `/formulate`, `/supported`, `/validate-recipe`, and `/health` endpoints. +- **FastAPI surface** with `/formulate`, `/supported`, `/validate-recipe`, and `/health` endpoints. `/validate-recipe` scores a caller-supplied recipe against the same inclusion limits and constraint set the optimizer uses, so a hand-edited formulation can be checked without re-running the LP. - **Architectural hooks** (no-ops in MVP) for the post-MVP non-additive interaction layer (Hua & Bureau 2012) and chance-constrained variability handling. ## Layout @@ -62,13 +63,16 @@ fasa_engine/ │ │ ├── crosswalk.json ASNS spec code -> FICD parameter (+ unit factor) │ │ ├── premix_mask.json which spec codes the premix covers │ │ ├── ingredient_pool_africa.csv country-tagged ingredient pool (KE/NG/ZM) +│ │ ├── nutrient_limits.csv min/max bounds on dietary nutrient levels +│ │ ├── ingredient_limits.csv min/max bounds on single-ingredient inclusion │ │ └── defaults.py numeric defaults (premix rate, caps, etc.) │ ├── data_loader.py ASNS / FICD / PAFF loaders (cached) │ ├── crosswalk.py spec → FICD parameter resolver │ ├── ingredient_pool.py pool filter +│ ├── inclusion_limits.py min/max inclusion layer (nutrient + ingredient) │ ├── constraint_builder.py solver-agnostic LP constraint emission │ ├── optimizer.py PuLP+HiGHS solve, IIS via deletion filter -│ ├── validator.py independent composition recompute + PAFF gate +│ ├── validator.py recipe scoring (composition + inclusion limits) + PAFF gate │ └── models.py pydantic request/response schemas ├── fasa_api/ │ └── main.py FastAPI app @@ -125,10 +129,10 @@ uvicorn fasa_api.main:app --reload --port 8000 The API is self-documented at `GET /docs`. Endpoints: - `GET /health` liveness probe -- `GET /ready` readiness probe (validates data files + FICD preload) +- `GET /ready` readiness probe (validates data files, FICD preload, and the inclusion-limit tables) - `GET /supported` discover valid `species`, `production_system`, and `stage` strings - `POST /formulate` run the optimization -- `POST /validate-recipe` recompute composition for an explicit recipe +- `POST /validate-recipe` score an explicit recipe: recompute composition and check its inclusion rates against the limit layer Notes: - Browsers issue **GET** requests; `GET /formulate` will return **405 Method Not Allowed** because `/formulate` is **POST-only**. @@ -157,10 +161,19 @@ For external teams integrating this API into other systems, see [`docs/integrati "10018": 1.50, "62134": 0.80, "62138": 0.10 - } + }, + "nutrient_limits": { "PA05": { "max": 8.0 } }, + "ingredient_limits": { "31605": { "max": 0.15 } } } ``` +`nutrient_limits` (ASNS spec code → bound in that code's ASNS unit, e.g. `%` for fibre, +`kcal/kg` for energy) and `ingredient_limits` (FICD code → mass **fraction** of total feed +in `[0, 1]`, so `0.15` = 15%) are optional per-request overrides of the inclusion-limit +layer. They override the configured CSVs for the codes they name. Where ASNS already +states the same bound, the tighter value binds. Note that the response echoes an +ingredient bound as a **percentage** (`max_inclusion_percent: 15.0`), not a fraction. + `country` is optional (ISO-2: `KE`, `NG`, `ZM`; discover valid values via `/supported`). When supplied, each recipe line is flagged `locally_available`. It is an advisory highlight only — the optimizer always draws on the full pool (millers routinely buy imported soy, premix, etc.), so a country is **never** a hard filter. Example call with auth: @@ -174,12 +187,12 @@ curl -X POST "http://127.0.0.1:8000/formulate" \ ### Reading the response (high level) -- **`recipe`**: ingredient inclusions (percent of final feed) + cost breakdown. If `batch_size_kg` was supplied on the request, each line also carries `quantity_kg`. If `country` was supplied, each line carries `locally_available` (`true`/`false`); it is `null` when no `country` was requested. +- **`recipe`**: ingredient inclusions (percent of final feed) + cost breakdown. If `batch_size_kg` was supplied on the request, each line also carries `quantity_kg`. If `country` was supplied, each line carries `locally_available` (`true`/`false`); it is `null` when no `country` was requested. Each line also echoes the inclusion box that was applied (`min_inclusion_percent`, `max_inclusion_percent`, `limit_source`). - **`batch_size_kg` / `premix_quantity_kg` / `total_cost`**: echoed only when `batch_size_kg` was supplied; otherwise `null`. -- **`composition`**: per-spec achieved vs target, including toxin ceilings. +- **`composition`**: per-spec achieved vs target, including toxin ceilings. `source` says whether the target came from ASNS alone or from the inclusion-limit layer. - **`status`**: - `optimal`: solution exists and is least-cost under constraints - - `infeasible`: no solution; see `infeasibility.iis_codes` / `iis_explanations` for why + - `infeasible`: no solution; see `infeasibility.iis_codes` / `iis_explanations` for why, or `infeasibility.bound_conflicts` when the ingredient inclusion limits alone cannot add up to the feed mass ### Error envelope @@ -202,7 +215,7 @@ Business/auth errors are returned with: - Container command binds to `0.0.0.0:$PORT`. - Required data files are bundled under `data/`. - `/health` is liveness and public. -- `/ready` verifies data artifacts and FICD preload. +- `/ready` verifies data artifacts, FICD preload, and that the inclusion-limit tables parse. - Auth is controlled by: - `FASA_REQUIRE_AUTH` (default: `true`) - `FASA_API_TOKEN` (required when auth enabled) @@ -257,7 +270,7 @@ Configure: ## Modeling notes -1. **Decision variables** `x_i ∈ [0, max_inclusion_i]` = mass fraction of each priced+available ingredient. +1. **Decision variables** `x_i ∈ [min_inclusion_i, max_inclusion_i]` = mass fraction of each priced+available ingredient; the box comes from the inclusion-limit layer and defaults to `[0, 1]`. 2. **Objective** `min Σ price_i × x_i` (USD/kg or local currency, supplied at runtime). 3. **Mass balance** `Σ x_i = 1 − premix_rate` so the premix takes a fixed slice. 4. **Nutritional constraints** generated 1:1 from active ASNS rows for the given (species, system, stage): @@ -266,8 +279,9 @@ Configure: - The `dig_*_fish_percent` and `dig_p_*_percent` FICD columns are bound directly to the corresponding ASNS digestible-nutrient codes — *digestibility is baked into the constraint LHS, not bolted on after*. 5. **Energy column selection** is data-driven: ASNS itself carries the species-appropriate energy code (Tilapia ⇒ ED02 DE-Omni, African Catfish ⇒ ED01 DE-Carni). The crosswalk then maps that code to the pelleted vs. extruded FICD variant per the request's `processing_method`. 6. **Hard toxin ceilings** are NEVER masked, even via override (TX01–TX16 always emit Maximum constraints). -7. **Soft warnings** are appended (not enforced) when any single ingredient exceeds 40 % inclusion. -8. **On infeasibility**, a deletion-filter IIS is returned so the user can see which constraints conflict. +7. **Nutrient inclusion limits** join the ASNS row set before emission, so they inherit unit conversion, the composition report, and IIS reporting. Where ASNS states the same direction the tighter value binds; a code the layer bounds is never masked by the premix. +8. **Soft warnings** are appended (not enforced) when any single ingredient exceeds 40 % inclusion. +9. **On infeasibility**, a deletion-filter IIS is returned so the user can see which constraints conflict. Ingredient bounds that cannot sum to the feed mass are caught before the solve and reported separately as `bound_conflicts`. ## Post-MVP roadmap (hooks already in place) diff --git a/docs/architecture.md b/docs/architecture.md index 503ae2b..62e0c30 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ The project provides an API that helps formulate fish feed recipes based on: ## Main building blocks - `fasa_api/`: API layer (receives requests and returns responses) -- `fasa_core/`: core logic (data loading, constraints, optimization, validation) +- `fasa_core/`: core logic (data loading, constraints, inclusion limits, optimization, validation) - `data/`: reference CSV files used by the core logic - `.github/workflows/`: CI, deployment, and release automation @@ -43,6 +43,13 @@ The project provides an API that helps formulate fish feed recipes based on: `/formulate` flags which ingredients are *locally available* in the response; it never restricts the optimization. The pool is regenerated from source lists by `scripts/build_ingredient_pool.py`. +- Two hand-maintained tables hold the min/max inclusion layer: + `fasa_core/config/nutrient_limits.csv` (bounds on dietary nutrient levels) and + `fasa_core/config/ingredient_limits.csv` (bounds on single-ingredient inclusion). + They express what a mill can actually formulate and process, which composition data + alone does not. Both ship without data rows pending the values; filling the columns + activates the layer with no code change, and clients can supply bounds per request + in the meantime. ## Versioning and changes diff --git a/docs/integration.md b/docs/integration.md index 87d5795..becd65a 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -70,7 +70,27 @@ Recommended client behavior: - Optionally send `country` (ISO-2: `KE`/`NG`/`ZM`) to have each recipe line flagged `locally_available` (`true`/`false`; `null` when no `country` is sent). This is an advisory highlight only — the optimizer is never restricted to local ingredients. +- Optionally send `nutrient_limits` and/or `ingredient_limits` to bound dietary nutrient + levels (ASNS spec code -> `{min, max}` in that code's ASNS unit) or single-ingredient + inclusion (FICD code -> `{min, max}` as a mass fraction of feed). These override the + engine's configured limit tables for the codes they name; where ASNS already states the + same bound, the tighter value binds. Each `recipe` line echoes the box applied + (`min_inclusion_percent`, `max_inclusion_percent`, `limit_source`) and each + `composition` line carries a `source` naming where its target came from. +- **Watch the units on ingredient limits:** you send a **fraction** (`{"max": 0.15}`) and + get back a **percentage** (`"max_inclusion_percent": 15.0`). Out-of-range request values + are rejected with 422, so `15` sent for 15% fails loudly, but rendering `0.15` as "0.15%" + fails silently — convert in both directions. Nutrient limits are not rescaled: they go in + and come back in that spec code's ASNS unit. - Handle `status` in response (`optimal`, `infeasible`, `error`). +- On `infeasible`, check `infeasibility.bound_conflicts` before `iis_codes`: when it is + non-empty the ingredient inclusion limits alone cannot add up to the feed mass and no + IIS was computed. +- To check a recipe the user edited (or one from elsewhere) without re-running the LP, + post it to `/validate-recipe` with the same `species`/`stage`/`production_system` and + any limit overrides. It returns `in_limits`, per-ingredient `inclusion_checks`, and + `nutrient_checks` in the same shape as `/formulate`'s `composition`. All fields other + than `fractions` are optional, so existing callers are unaffected. - Log request IDs (if the calling client or service provides them) for easier debugging. ## 7) Upgrade playbook diff --git a/docs/testing-guide.md b/docs/testing-guide.md index 102086e..7687711 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -15,6 +15,7 @@ This guide covers the model rationale, data structure, API usage, and interpreta 4. [Ingredient Pool](#4-ingredient-pool) 5. [How the Optimizer Works](#5-how-the-optimizer-works) - 5.1 [Premix Masking](#51-premix-masking) + - 5.2 [Min/Max Inclusion Limits](#52-minmax-inclusion-limits) 6. [Endpoint Reference](#6-endpoint-reference) - 6.1 `[GET /supported](#61-get-supported)` - 6.2 `[POST /formulate` — Request Schema](#62-post-formulate--request-schema) @@ -28,7 +29,7 @@ This guide covers the model rationale, data structure, API usage, and interpreta 9. [Known Limitations](#9-known-limitations) - 9.1 [Country Availability Is Advisory Only](#91-country-availability-is-advisory-only) - 9.2 [No Ingredient Price Book](#92-no-ingredient-price-book) - - 9.3 [No Maximum Inclusion Limits for Most Ingredients](#93-no-maximum-inclusion-limits-for-most-ingredients) + - 9.3 [Inclusion Limit Tables Are Not Yet Populated](#93-inclusion-limit-tables-are-not-yet-populated) - 9.4 [Premix Nutrient Contribution Not Modelled](#94-premix-nutrient-contribution-not-modelled) - 9.5 [Anti-Nutritional Interactions Not Modelled](#95-anti-nutritional-interactions-not-modelled) - 9.6 [No Non-Additive Energy Interactions](#96-no-non-additive-energy-interactions) @@ -41,7 +42,7 @@ FASA solves a **least-cost feed formulation problem**: given a set of locally av The approach is equivalent to the classical linear programming (LP) diet formulation method used in animal nutrition. The objective function minimises the weighted sum of ingredient prices. The constraints encode nutritional minima, maxima, and structural rules (see §3). -The tool does **not** optimise for palatability, pellet quality, or any criterion beyond cost and nutritional compliance. +The tool does **not** optimise for palatability, pellet quality, or any criterion beyond cost and nutritional compliance. Those criteria enter as bounds rather than objectives, through the min/max inclusion layer (§5.2). [↑ Contents](#contents) @@ -144,7 +145,7 @@ The pool is generated by `scripts/build_ingredient_pool.py` from a per-country u | `class` | Informational grouping label, auto-derived from the description; **not used by the LP** | | `is_fishmeal` | `1` for fish-meal products; participates in the opt-in `max_fishmeal_cost_share` cap | | `is_binder` | `1` for starch/flour/cassava binders; participates in the opt-in `max_binder_inclusion` cap | -| `max_inclusion` | Optional per-ingredient ceiling; blank for all current entries (see §9.3) | +| `max_inclusion` | Optional per-ingredient ceiling; blank for all current entries. Superseded by `ingredient_limits.csv` where that file names the ingredient (§5.2) | | `countries` | Comma-separated ISO-2 codes (`KE`, `NG`, `ZM`) where the ingredient is locally available; blank = not locally available in any tracked country (imports) | @@ -165,7 +166,7 @@ Representative rows (the full list lives in the CSV): Currently 12 ingredients are flagged `is_fishmeal` and 7 `is_binder`. These flags are auto-derived from the description (fish-meal = descriptions beginning "Fish meal …"; binder = starch/flour/cassava products) and feed only the opt-in advisory caps in §5. -No ingredient in the pool currently carries a hard per-ingredient inclusion limit. FASA has not run feeding trials to justify blanket caps, and the prior 5% blood meal ceiling was an industry rule-of-thumb that has been removed. All ingredients are bounded only by nutritional constraints, toxin ceilings, and (optionally) the advisory binder/fish-meal caps. +No ingredient in the pool CSV currently carries a hard per-ingredient inclusion limit. Per-ingredient bounds are now the job of the curated inclusion-limit layer (§5.2), which is hand-maintained rather than generated and so survives a pool rebuild; the pool's own `max_inclusion` column remains as a fallback for ingredients that layer does not name. Until the limit tables are populated, all ingredients are bounded only by nutritional constraints, toxin ceilings, and (optionally) the advisory binder/fish-meal caps. > **Coverage note:** 141 codes from the country list (all Zambia-only, a contiguous block) have no composition rows in FICD and are therefore excluded from the pool; they cannot be formulated until FICD gains their data. @@ -177,7 +178,7 @@ No ingredient in the pool currently carries a hard per-ingredient inclusion limi The LP is formulated as follows. -**Decision variables:** `x_i` = mass fraction of ingredient `i` in the final feed (dimensionless, bounded [0, 1] or [0, max_inclusion_i] where defined). +**Decision variables:** `x_i` = mass fraction of ingredient `i` in the final feed (dimensionless, bounded `[min_inclusion_i, max_inclusion_i]` where the inclusion-limit layer defines them, otherwise `[0, 1]` — see §5.2). **Objective:** minimise `Σ price_i × x_i` @@ -191,6 +192,7 @@ The LP is formulated as follows. 3. **DP/DE ratio:** Linearised to a single minimum inequality: `Σ (dCP_i − rhs × dDE_i) × x_i ≥ 0`. 4. **Binder cap (opt-in):** `Σ_{i ∈ binders} x_i ≤ max_binder_inclusion`. Not applied by default; only emitted when the caller supplies a value in `[0, 1)`. 5. **Fish-meal cost-share cap (opt-in):** `Σ_{FM} price_i × x_i ≤ max_fishmeal_cost_share × Σ price_i × x_i` (linearised). Not applied by default; only emitted when the caller supplies a value in `[0, 1)`. +6. **Nutrient inclusion limits:** additional `Minimum`/`Maximum` rows contributed by the inclusion-limit layer (see §5.2). They are indistinguishable from ASNS rows in the LP. The solver used is HiGHS (via PuLP), with a fallback to CBC. The time limit is 30 seconds per request. The solution is exact (continuous LP) and globally optimal within the model's assumptions. @@ -202,6 +204,53 @@ For brood stock stages, Vitamin C (V09) is re-activated even when premix masking Custom masking can be specified per request via `custom_premix_mask_codes` (see §7.2). +### 5.2 Min/Max Inclusion Limits + +Nutrient composition alone is not enough to formulate on. ASNS states what the fish *requires*; it does not state what a mill can actually mix, pellet or extrude. An LP given only composition data will load up on whatever is cheapest per unit of nutrient — commonly bran by-products — and return a diet that satisfies every nutrient target while being unextrudable and unable to grow fish. Commercial formulation closes that gap with a second layer of bounds, applied by hand: a ceiling on dietary fibre, and a ceiling (or floor) on how much of any single ingredient may go in. + +That layer is now part of the engine. It has two halves: + + +| Layer | Keyed by | Effect on the LP | Config file | +| -------------------- | -------------------- | ------------------------------------------------------------------- | ---------------------------------------- | +| Nutrient limits | ASNS spec code | Extra `Minimum`/`Maximum` constraint rows on dietary nutrient levels | `fasa_core/config/nutrient_limits.csv` | +| Ingredient limits | FICD ingredient code | The decision variable's box: `min_inclusion ≤ x_i ≤ max_inclusion` | `fasa_core/config/ingredient_limits.csv` | + + +Both files ship with a documented header and **no data rows yet** — the values are still being compiled. While they are empty the engine behaves exactly as it did before: ingredients are bounded only by the ASNS constraints, the toxin ceilings and the opt-in advisory caps. Filling the columns activates the layer with no code change. In the meantime, either layer can be supplied per request via `nutrient_limits` / `ingredient_limits` (see §6.2). + +**Units — read this before sending limits.** The two halves use different units, and the ingredient half flips between request and response: + +| Where | Quantity | Unit | +| --- | --- | --- | +| `nutrient_limits` request, and `nutrient_limits.csv` `min`/`max` | dietary nutrient level | the **ASNS unit for that spec code** — `%` for proximates and amino acids, `kcal/kg` for energy, `mg/kg` for trace minerals, `ppb` for mycotoxins (see §2.3) | +| `ingredient_limits` request, and `ingredient_limits.csv` `min_inclusion`/`max_inclusion` | single-ingredient inclusion | **mass fraction of total feed, in [0, 1]** — `0.15` means 15% | +| `min_inclusion_percent` / `max_inclusion_percent` in the response | the same bound, echoed back | **percent of feed, 0-100** — the 0.15 above comes back as `15.0` | + +So an ingredient bound is **sent as a fraction and reported as a percentage**. Values outside [0, 1] on the request are rejected with a 422, which catches `15` sent for 15%, but `0.15` displayed as "0.15%" is a silent client-side error — convert on the way in and out. Nutrient bounds are not rescaled in either direction: they go in and come back in the ASNS unit. + +**Scoping.** Both files share four filter columns — `species`, `production_system`, `stage_weight`, `processing_method` — where a blank cell means "applies to all". `stage_weight` matches as a substring of the ASNS stage label, so `Starter` covers `< 5g (Starter)` (the same convention as `premix_mask.json`). `processing_method` exists because the same ingredient often tolerates a different rate in a pellet than in an extrudate. When several rows match one request, the most specific row wins (most non-blank filters); ties resolve toward the tighter bound, and each direction resolves independently. + +**Nutrient limits and ASNS.** Resolution is per (spec code, direction): + +- The direction is already in ASNS for the requested stage → the **tighter** value binds (max of minimums, min of maximums), and the composition line reports `source: "asns+limits"`. A commercial 8% fibre ceiling therefore never relaxes a 7% ASNS requirement. +- The direction is absent from ASNS → a new constraint is emitted (`source: "limits"`). This is how ceilings ASNS does not carry at all — ash, NDF/ADF — get added. +- A bound this layer contributes is **never masked by the premix**: an explicit operator bound always binds. A plain ASNS row for the same code stays masked, so capping zinc does not also resurrect the zinc minimum the premix supplies. +- Ratio specs (DP/DE) are not supported by this layer; a limit on one is skipped with a warning. + +Because nutrient limits become ordinary constraint rows, they inherit the whole existing pipeline: unit conversion, the `composition` report, and the deletion-filter IIS (an infeasibility a nutrient limit caused is named in `iis_codes`, tagged `[limits]` or `[request]` in the explanation). + +**Ingredient limits.** These become the decision variable's bounds, so they cost the solver nothing: + +- A `max_inclusion` of 0 removes the ingredient from consideration entirely, even when it is priced. The response then carries a `[limit]` warning naming it, so a "we should not recommend this at all" exclusion is distinguishable from a "too expensive today" one. +- A `min_inclusion` only applies to ingredients the caller actually priced — the engine cannot force in an ingredient it has no price for. +- A row here supersedes the pool CSV's generated `max_inclusion` column (§4) for that ingredient. +- Each `recipe` line echoes the box that was applied (`min_inclusion_percent`, `max_inclusion_percent`, `limit_source`), so a client can explain why an ingredient stopped where it did. + +Because these are variable bounds rather than constraint rows, the IIS cannot see them. Limits that cannot add up to the required feed mass — ceilings summing below `1 − premix_rate`, or floors summing above it — are therefore detected before the solve and reported in `infeasibility.bound_conflicts` instead (see §7.2). + +Both halves are also checked by `POST /validate-recipe` (§6.4), which scores a recipe the caller supplies rather than one the LP produced. + [↑ Contents](#contents) --- @@ -255,6 +304,8 @@ All field definitions and defaults: | `max_fishmeal_cost_share` | float or null | No | `null` | Optional cap on fish-meal cost share (is_fishmeal = true). Range [0, 1]. Omit (or send `null`) to apply no cap — cost alone then drives fish-meal inclusion | | `max_binder_inclusion` | float or null | No | `null` | Optional cap on collective binder mass fraction (is_binder = true). Range [0, 1]. Omit (or send `null`) to apply no cap | | `custom_premix_mask_codes` | list of strings | No | `null` | If provided, **replaces** the default premix mask entirely. List of ASNS specification codes (e.g. `["V01","V02","M08"]`) to exclude from the LP. Toxin codes (TX*) in this list are still enforced. Maximum 200 codes | +| `nutrient_limits` | object or null | No | `null` | Per-request override of the nutrient inclusion-limit layer (§5.2): ASNS spec code → `{"min": …, "max": …}` in that code's ASNS unit, e.g. `{"PA05": {"max": 8}}`. An entry **replaces** the configured limit for that code. Either key may be omitted. Maximum 300 codes | +| `ingredient_limits` | object or null | No | `null` | Per-request override of the ingredient inclusion-limit layer (§5.2): FICD ingredient code → `{"min": …, "max": …}` as a mass fraction of total feed in [0, 1], e.g. `{"31605": {"max": 0.15}}`. An entry **replaces** the configured limit for that ingredient. Maximum 300 codes | **Example minimum request body:** @@ -281,6 +332,27 @@ Prices are in the currency of your choice; the returned `cost_per_kg` will be ex **Practical note on ingredient selection:** The LP can only use ingredients for which both a price and a pool entry exist. Providing more ingredients increases the feasible space and generally reduces cost. If the ingredient set is too restricted, the solver may return `infeasible`. +**Example request with inclusion limits:** hold dietary crude fibre below 8% of the diet, cap wheat bran and rice bran at 15% each, and exclude cottonseed meal outright. + +```json +{ + "species": "Nile Tilapia", + "stage": "< 5g (Starter)", + "production_system": "General-LowCost", + "prices": { "...": 0.0 }, + "nutrient_limits": { + "PA05": { "max": 8.0 } + }, + "ingredient_limits": { + "31605": { "max": 0.15 }, + "30937": { "max": 0.15 }, + "30404": { "max": 0.0 } + } +} +``` + +Both objects are optional and independent. Values sent here override the configured CSV layer for the codes they name and leave every other code resolving from the files as usual. + --- ### 6.3 `POST /formulate` — Response Schema and Interpretation @@ -320,6 +392,9 @@ Prices are in the currency of your choice; the returned `cost_per_kg` will be ex | `cost_contribution` | This ingredient's contribution to the total feed cost (fraction × price). Summing all `cost_contribution` values gives `cost_per_kg` | | `quantity_kg` | Absolute mass of this ingredient in kg, rounded to 3 decimals. Populated only when `batch_size_kg` is supplied on the request; otherwise `null` | | `locally_available` | Boolean or null. When a `country` was supplied on the request, `true`/`false` indicates whether this ingredient is tagged for that country; `null` when no `country` was requested | +| `min_inclusion_percent` | The inclusion floor applied to this ingredient, as a **percentage** of feed (0-100) — note that `ingredient_limits` is *sent* as a fraction in [0, 1]; `null` when unbounded below (§5.2) | +| `max_inclusion_percent` | The inclusion ceiling applied to this ingredient, as a **percentage** of feed (0-100); `null` when unbounded above (§5.2) | +| `limit_source` | Where that box came from: `"limits"` (config CSV), `"request"` (per-call override), or `"pool"` (the pool CSV's `max_inclusion`). `null` when no limit applied to this ingredient | **Composition lines** (`composition` list): @@ -330,42 +405,96 @@ Prices are in the currency of your choice; the returned `cost_per_kg` will be ex | `code` | ASNS specification code | | `spec_label` | Human-readable name of the constraint | | `restriction_type` | `"Minimum"`, `"Maximum"`, or `"Minimum"` (ratios are linearised to a minimum constraint) | -| `target` | Constraint threshold value from the ASNS database | +| `target` | Constraint threshold actually enforced — the ASNS value, or the tighter value if the inclusion-limit layer overrode it | | `achieved` | Value realised by the optimised recipe | | `unit` | Measurement unit | | `in_spec` | Boolean: `true` if the achieved value satisfies the constraint. Should be `true` for all rows in an `"optimal"` solution | +| `source` | Where `target` came from: `"asns"`, `"asns+limits"` / `"asns+request"` (an ASNS target tightened by the inclusion-limit layer), or `"limits"` / `"request"` (a bound ASNS does not state for this stage). See §5.2 | -The `composition` list includes only constraints that were active in the LP (i.e. those with a non-blank ASNS value, a mapped FICD parameter, and not masked by the premix). Masked constraints (vitamins, trace minerals) do not appear. +The `composition` list includes only constraints that were active in the LP (i.e. those with a non-blank ASNS value or an inclusion limit, a mapped FICD parameter, and not masked by the premix). Masked constraints (vitamins, trace minerals) do not appear unless the inclusion-limit layer gives them an explicit bound. --- ### 6.4 `POST /validate-recipe` — Request and Response -This endpoint recomputes the nutritional composition of a **user-defined recipe** without running the optimiser. It is useful for checking an existing formulation against the FICD composition data. +This endpoint scores a **user-defined recipe** without running the optimiser: it recomputes the recipe's nutritional composition from FICD and checks its inclusion rates against the min/max inclusion layer (§5.2). Use it to check a hand-edited recipe, an externally sourced formulation, or a recipe `/formulate` returned that the miller has since adjusted. **Request:** -| Field | Type | Required | Meaning | -| ------------ | --------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `fractions` | object | **Yes** | Dictionary mapping ingredient code (string) to mass fraction (float in [0, 1]). Fractions do not need to sum to 1; each is treated independently | -| `parameters` | list of strings | No | List of FICD parameter column names to include in the output (e.g. `["crude_protein_percent", "lysine_percent"]`). If omitted, all parameters are returned | +| Field | Type | Required | Default | Meaning | +| ------------------- | --------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `fractions` | object | **Yes** | — | Dictionary mapping ingredient code (string) to mass fraction (float in [0, 1]) | +| `parameters` | list of strings | No | `null` | FICD parameter column names to include in `composition` (e.g. `["crude_protein_percent"]`). If omitted, all parameters are returned | +| `species` | string or null | No | `null` | Optional. Scopes the inclusion limits, and (with `stage` and `production_system`) enables the nutrient checks | +| `stage` | string or null | No | `null` | Optional ASNS `stage_weight` label | +| `production_system` | string or null | No | `null` | Optional. Required together with `species` and `stage` for the nutrient checks | +| `processing_method` | string | No | `"pelleted"` | Scopes the inclusion limits and selects the digestible-energy column for the nutrient checks | +| `premix_enabled` | boolean | No | `true` | Mirrors `/formulate`: when `true`, the vitamin and trace-mineral specs the premix covers are excluded from the nutrient checks (the premix's own contribution is not modelled) | +| `nutrient_limits` | object or null | No | `null` | Same shape, units and meaning as on `/formulate` (§6.2; units in §5.2 — ASNS unit per spec code); overrides the configured limits for the codes it names | +| `ingredient_limits` | object or null | No | `null` | Same shape, units and meaning as on `/formulate` (§6.2; units in §5.2 — mass fraction in [0, 1], reported back as a percentage); overrides the configured limits | +All fields other than `fractions` are optional, so the original payload shape (`fractions` + `parameters`) keeps working unchanged. + **Response:** + +| Field | Type | Meaning | +| ------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `composition` | object | `Σ composition_ij × fraction_i` per requested FICD parameter, rounded to 6 decimals | +| `total_inclusion_percent` | float | Sum of the supplied fractions, as a percentage of feed mass | +| `in_limits` | boolean | `true` when every check that ran passed — both the inclusion checks and (when they ran) the nutrient checks | +| `inclusion_checks` | list | One line per supplied ingredient: `code`, `description`, `inclusion_percent`, the applied box (`min_inclusion_percent`, `max_inclusion_percent`, `limit_source`) and `in_limits` | +| `nutrient_checks` | list | Composition lines in the same shape as `/formulate`'s `composition` (§6.3), including `target`, `achieved`, `in_spec` and `source`. Empty unless `species`, `stage` and `production_system` were all supplied | +| `warnings` | list of strings | Non-fatal notes — see below | + + ```json { - "composition": { - "crude_protein_percent": 32.41, - "lysine_percent": 1.87, - ... - } + "composition": { "crude_fibre_percent": 4.817768 }, + "total_inclusion_percent": 99.5, + "in_limits": false, + "inclusion_checks": [ + { + "code": "31605", + "description": "Wheat bran", + "inclusion_percent": 40.0, + "min_inclusion_percent": null, + "max_inclusion_percent": 15.0, + "limit_source": "request", + "in_limits": false + } + ], + "nutrient_checks": [ + { + "code": "PA05", + "spec_label": "Crude Fibre", + "restriction_type": "Maximum", + "target": 7.0, + "achieved": 4.817768, + "unit": "%", + "in_spec": true, + "source": "asns" + } + ], + "warnings": [] } ``` -Each value is the weighted sum `Σ composition_ij × fraction_i` across the supplied ingredients, rounded to 6 decimal places. No nutritional constraints are checked; this is a pure composition calculation. +**What runs when.** The inclusion checks always run — limit rows with blank scope columns apply to every request, so a blanket ceiling binds even with no `species`/`stage` given. The nutrient checks need the full `(species, stage, production_system)` triple to resolve an ASNS row set; supply part of it and they are skipped with a `[skip]` warning. + +**Warnings specific to this endpoint:** + + +| Warning | Meaning | +| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `[mass]` | The fractions sum to something other than 99–100% of feed mass, so they do not describe a complete diet | +| `[skip]` | Either the scope was incomplete for the nutrient checks, or some codes are absent from the ingredient pool. Pool-absent codes still contribute to `composition` (that path reads FICD directly) but can hold no inclusion limit and contribute nothing to the nutrient checks — the same restriction `/formulate` has | + + +**Consistency with `/formulate`.** The nutrient checks reuse the optimiser's own constraint set and achieved-vs-target arithmetic, so a recipe `/formulate` returned scores as fully in spec when fed straight back. The `in_spec` tolerance is scaled to each constraint's coefficients and budgets for the 4-decimal precision of the `inclusion_percent` values this API publishes; without that, any exactly-binding constraint (and the DP/DE ratio, whose linearised target is 0) would read as violated on a round trip. [↑ Contents](#contents) @@ -381,7 +510,7 @@ A soft warning is generated (but the solution remains optimal) if any single ing ### 7.2 Infeasible -No combination of the supplied ingredients can simultaneously satisfy all active nutritional constraints within the structural limits (mass balance, toxin ceilings, and — when the caller opted in — the binder or fish-meal cost-share caps). +No combination of the supplied ingredients can simultaneously satisfy all active nutritional constraints within the structural limits (mass balance, toxin ceilings, ingredient inclusion limits, and — when the caller opted in — the binder or fish-meal cost-share caps). The `infeasibility` object contains: @@ -389,7 +518,8 @@ The `infeasibility` object contains: | Field | Meaning | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `iis_codes` | List of ASNS specification codes in the approximate irreducible infeasible subset (IIS) — the minimal set of conflicting constraints identified by the engine | -| `iis_explanations` | Human-readable description of each conflicting constraint | +| `iis_explanations` | Human-readable description of each conflicting constraint, tagged with its source (`[asns]`, `[limits]`, `[request]`) | +| `bound_conflicts` | Ingredient inclusion limits that cannot add up to the required feed mass. Populated instead of `iis_codes` — see below | | `suggestion` | A general diagnostic suggestion | @@ -398,8 +528,22 @@ The `infeasibility` object contains: - The ingredient set is too small or nutritionally inadequate to meet one or more active constraints simultaneously (e.g. insufficient amino acid sources to satisfy a protein minimum, especially when an opt-in fish-meal cost-share cap is also being applied). - A constraint requires a nutrient for which no supplied ingredient contains that nutrient in the FICD database (composition value is zero or absent for all included ingredients). - Conflicting constraints: e.g. a high protein minimum combined with a low crude fibre maximum may require ingredients that simultaneously violate another constraint. +- An inclusion limit (§5.2) is too tight for the priced set: a nutrient ceiling below what any affordable combination can reach, or per-ingredient caps that leave no room for a nutrient minimum. -**Diagnostic approach:** Examine the `iis_codes` returned. If, for example, the IIS names amino-acid codes (e.g. `AA05` Lysine) and you opted into `max_fishmeal_cost_share`, consider adding a synthetic lysine source (code `61109`) or removing/relaxing the fish-meal cost cap. If no opt-in cap is in play, the conflict lies entirely between the ingredient set and the ASNS targets. +**Bound conflicts.** Ingredient inclusion limits are decision-variable bounds rather than constraint rows, so the IIS cannot attribute a failure to them. When the ceilings on the priced ingredients sum to less than `1 − premix_rate`, or the floors sum to more, no constraint subset can ever be feasible — so the engine detects this before solving, returns `iis_codes: []`, and states the arithmetic in `bound_conflicts`: + +```json +{ + "iis_codes": [], + "iis_explanations": [], + "bound_conflicts": [ + "Maximum inclusion limits on the priced ingredients sum to 24.00%, below the 99.50% of feed mass that must be filled. Price more ingredients or raise a maximum." + ], + "suggestion": "Adjust the ingredient inclusion limits so their range spans the required feed mass, or price additional ingredients." +} +``` + +**Diagnostic approach:** Check `bound_conflicts` first — when it is non-empty the inclusion limits alone are the cause and no IIS was computed. Examine the `iis_codes` returned. If, for example, the IIS names amino-acid codes (e.g. `AA05` Lysine) and you opted into `max_fishmeal_cost_share`, consider adding a synthetic lysine source (code `61109`) or removing/relaxing the fish-meal cost cap. If no opt-in cap is in play, the conflict lies entirely between the ingredient set and the ASNS targets. ### 7.3 Error @@ -418,6 +562,8 @@ Warnings are non-fatal messages appended to any response (including optimal solu | ----------------------- | -------------------------------------------------------------------------------------------------------------------- | | Unmapped specification | An ASNS constraint has no corresponding FICD parameter column; the constraint was dropped and not enforced by the LP | | Single ingredient > 40% | One ingredient exceeds 40% of total mass in the optimal solution; may warrant inspection | +| `[skip]` nutrient limit | A nutrient inclusion limit (§5.2) names a code with no FICD mapping, or a ratio spec; the limit was ignored | +| `[limit]` excluded | A priced ingredient was removed from consideration by a 0% maximum inclusion limit (§5.2) | [↑ Contents](#contents) @@ -438,9 +584,13 @@ Two related gaps remain: only three countries are currently tracked, and 141 Zam The engine has no internal price reference. Prices must be supplied by the user in every request. There is no validation of price plausibility or currency consistency. Results are directly sensitive to the price values provided. -### 9.3 No Per-Ingredient Maximum Inclusion Limits +### 9.3 Inclusion Limit Tables Are Not Yet Populated + +The min/max inclusion layer described in §5.2 is implemented and wired into the LP, but both of its tables — `fasa_core/config/nutrient_limits.csv` and `fasa_core/config/ingredient_limits.csv` — ship without data rows. The values are still being compiled. + +Until they are filled, ingredient inclusions are bounded only by the ASNS nutritional constraints, the TX01–TX16 toxin ceilings, and — when the caller opts in — the collective binder or fish-meal cost-share caps. This is the practical limitation behind diets that look cheap and pass every nutrient check yet would not be recommended in practice: a 40% bran inclusion, for instance, is within every ASNS target the engine can currently see, but such a mash will not extrude and will not support growth. -No ingredient in the pool currently carries a hard per-ingredient maximum inclusion limit. FASA has not run feeding trials to justify blanket commercial-industry rules-of-thumb (e.g. a 5 % palatability ceiling on blood meal), and applying such caps without evidence can make diets unnecessarily expensive for small-scale millers. The CSV's `max_inclusion` column is kept for future use should trial evidence become available. In the meantime, ingredient inclusions are bounded only by the ASNS nutritional constraints, the TX01–TX16 toxin ceilings, and — when the caller opts in — the collective binder or fish-meal cost-share caps. +Callers that already know the bounds they want can supply them per request through `nutrient_limits` and `ingredient_limits` (§6.2) without waiting for the tables. Once the tables are populated the same bounds apply automatically to every request in scope. ### 9.4 Premix Nutrient Contribution Not Modelled diff --git a/examples/tilapia_starter_demo.py b/examples/tilapia_starter_demo.py index cd33012..7319792 100644 --- a/examples/tilapia_starter_demo.py +++ b/examples/tilapia_starter_demo.py @@ -88,8 +88,18 @@ def main() -> int: c = _run("Opt-in: 40 % FM cost-share + 25 % binder", max_fishmeal_cost_share=0.40, max_binder_inclusion=0.25) + # Scenario D — the min/max inclusion layer, passed per request. Composition data + # alone lets the LP load up on bran: it satisfies every nutrient target yet the + # mash will not extrude and the fish will not grow on it. Here a dietary fibre + # ceiling joins the ASNS set (the tighter of the two binds) and the two brans get + # per-ingredient ceilings. The same bounds normally come from + # `fasa_core/config/{nutrient,ingredient}_limits.csv`. + d = _run("Inclusion limits: fibre ≤ 8 % of diet, wheat/rice bran ≤ 15 % each", + nutrient_limits={"PA05": {"max": 8.0}}, + ingredient_limits={"31605": {"max": 0.15}, "30937": {"max": 0.15}}) + # Success if any scenario produced an optimal solution. - return 0 if "optimal" in {a.status, b.status, c.status} else 1 + return 0 if "optimal" in {a.status, b.status, c.status, d.status} else 1 if __name__ == "__main__": diff --git a/fasa_api/main.py b/fasa_api/main.py index 778d6f6..2d94d45 100644 --- a/fasa_api/main.py +++ b/fasa_api/main.py @@ -7,7 +7,7 @@ GET /health liveness probe GET /supported enumerate species/stages/systems POST /formulate run the LP - POST /validate-recipe recompute composition for an explicit recipe + POST /validate-recipe score an explicit recipe (composition + inclusion limits) """ from __future__ import annotations @@ -32,6 +32,7 @@ SUPPORTED_SPECIES, ) from fasa_core.data_loader import list_supported_stages, load_ficd_wide +from fasa_core.inclusion_limits import ingredient_limit_rows, nutrient_limit_rows from fasa_core.models import ( ErrorResponse, FormulateRequest, @@ -42,7 +43,7 @@ ValidateRecipeResponse, ) from fasa_core.optimizer import formulate -from fasa_core.validator import compute_composition +from fasa_core.validator import validate_recipe as validate_recipe_core LOGGER = logging.getLogger("fasa_api") @@ -96,6 +97,13 @@ def _readiness_check() -> tuple[bool, str]: load_ficd_wide() except Exception as exc: return False, f"Failed to preload FICD table: {exc}" + try: + # Malformed inclusion limits raise on load; catch that at deploy time + # rather than on the first /formulate call. + nutrient_limit_rows() + ingredient_limit_rows() + except Exception as exc: + return False, f"Failed to load inclusion limits: {exc}" return True, "ready" @@ -217,6 +225,8 @@ def formulate_endpoint( custom_premix_mask_codes=req.custom_premix_mask_codes, batch_size_kg=req.batch_size_kg, country=req.country, + nutrient_limits=req.nutrient_limits, + ingredient_limits=req.ingredient_limits, ) elapsed_ms = (time.perf_counter() - started_at) * 1000.0 LOGGER.info( @@ -237,7 +247,7 @@ def formulate_endpoint( "/validate-recipe", response_model=ValidateRecipeResponse, tags=["api"], - summary="Recompute composition for an explicit recipe", + summary="Score an explicit recipe against composition and inclusion limits", responses={ 400: {"model": ErrorResponse, "description": "Bad request"}, 401: {"model": ErrorResponse, "description": "Missing or invalid token"}, @@ -253,5 +263,21 @@ def validate_recipe( status_code=400, detail=_error("invalid_fraction", "All fraction values must be within [0,1]."), ) - df = compute_composition(req.fractions, parameters=req.parameters) - return {"composition": df["value"].round(6).to_dict()} + try: + return validate_recipe_core( + req.fractions, + parameters=req.parameters, + species=req.species, + stage=req.stage, + production_system=req.production_system, + processing_method=req.processing_method, + premix_enabled=req.premix_enabled, + nutrient_limits=req.nutrient_limits, + ingredient_limits=req.ingredient_limits, + ) + except ValueError as e: + # e.g. a species/stage/production_system triple with no ASNS rows + raise HTTPException( + status_code=400, + detail=_error("invalid_request", "Recipe validation request is invalid.", str(e)), + ) from e diff --git a/fasa_core/__init__.py b/fasa_core/__init__.py index d2ae1bb..1e886be 100644 --- a/fasa_core/__init__.py +++ b/fasa_core/__init__.py @@ -9,4 +9,4 @@ ... processing_method="pelleted") """ -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/fasa_core/config/defaults.py b/fasa_core/config/defaults.py index 063e645..33810f8 100644 --- a/fasa_core/config/defaults.py +++ b/fasa_core/config/defaults.py @@ -33,6 +33,15 @@ def normalize_country(value: "str | None") -> "str | None": """ return ((value or "").strip().upper() or None) +def as_percent(value: "float | None") -> "float | None": + """Mass fraction → percent of feed, at the precision the API reports inclusions. + + Single source of truth for the fraction→percent rule, shared by the optimizer's + recipe lines and the validator's inclusion checks; None passes through. + """ + return None if value is None else round(value * 100.0, 4) + + # --- premix --- DEFAULT_PREMIX_RATE = 0.005 # 0.5 % of total feed mass (industry-typical for vit/min premix) @@ -49,6 +58,22 @@ def normalize_country(value: "str | None") -> "str | None": # --- numerical tolerances --- SOLUTION_FRACTION_TOL = 1e-6 # ingredients below this fraction are dropped from the reported recipe + +# `in_spec` tolerance for achieved-vs-target: +# tol = ABS + REL*|target| + INPUT*Σ|a_i| +# An absolute epsilon alone is wrong for two reasons. A linearized ratio (DP/DE) has +# target = 0 with coefficients in the hundreds, and energy rows carry coefficients in +# the thousands — so the meaningful error scale is the row's own coefficients, not 1e-6. +# The INPUT term is the dominant one: it budgets for quantization of the caller's +# fractions, which matters because the API publishes inclusion_percent at 4 decimals +# (a 1e-6 quantum in fraction terms) and /validate-recipe accepts those values back. +# Without it, a recipe that /formulate just returned reads as out of spec on any +# exactly-binding constraint. 2e-6 is 4x the 5e-7 worst-case half-quantum per +# ingredient; even on the largest rows the resulting tolerance stays physically +# negligible (~0.07 kcal on a 3243 kcal/kg energy minimum). +IN_SPEC_ABS_TOL = 1e-6 +IN_SPEC_REL_TOL = 1e-9 +IN_SPEC_INPUT_TOL = 2e-6 VALIDATION_REL_TOL = 0.02 # 2 % — relaxed from 0.1 % because PAFF Calculated_Composition is # published with 2-decimal precision; rounding alone consumes # ~0.5 % at our 5-7 % ash / fibre values. 2 % rejects real diff --git a/fasa_core/config/ingredient_limits.csv b/fasa_core/config/ingredient_limits.csv new file mode 100644 index 0000000..5c3e570 --- /dev/null +++ b/fasa_core/config/ingredient_limits.csv @@ -0,0 +1,43 @@ +# FASA ingredient inclusion limits — safe/practical bounds on SINGLE-INGREDIENT inclusion. +# +# Nutrient composition alone is not enough to formulate on: an ingredient can satisfy every +# nutrient constraint and still be unusable at the level the LP picked (pellet durability, +# extrudability, palatability, bulk density, rancidity, anti-nutrient load). This file +# carries the per-ingredient ceiling/floor a formulator would apply by hand. +# +# Effect on the LP: the bounds become the decision variable's box, i.e. +# min_inclusion <= x_code <= max_inclusion +# A max of 0 removes the ingredient from consideration entirely, even when it is priced +# (the response then carries a "[limit]" warning naming it). A min only applies to +# ingredients the caller actually priced — the engine cannot force in an ingredient it has +# no price for. +# +# Columns: +# code FICD ingredient code (must be present in the ingredient pool). +# species blank = all. Exact match ("Nile Tilapia" | "African Catfish"). +# production_system blank = all. Exact match ("General-LowCost" | "General"). +# stage_weight blank = all. SUBSTRING match against the ASNS stage label, so +# "Starter" covers "< 5g (Starter)" (same convention as +# premix_mask.json stage_overrides). +# processing_method blank = all. Exact match ("pelleted" | "extruded") — the same +# ingredient often tolerates a higher rate in a pellet than in an +# extrudate, or vice versa. +# min_inclusion mass FRACTION of total feed in [0, 1] — write 0.15 for 15%, NOT 15. +# Blank = 0 (no floor). +# max_inclusion mass FRACTION of total feed in [0, 1] — write 0.15 for 15%, NOT 15. +# Blank = no ceiling. A value outside [0, 1] fails at load time. +# note free text, not read by the engine. +# +# The API reports these bounds back as PERCENTAGES (max_inclusion_percent: 15.0); only +# this file and the request payload use fractions. +# +# When several rows match a request, the most specific row wins (most non-blank scope +# columns); ties are resolved toward the tighter bound. Each direction resolves +# independently. A row here supersedes the pool CSV's generated `max_inclusion` column +# for that ingredient. +# +# NO ROWS YET — the limits are still being compiled. While this file holds no data rows +# the engine behaves exactly as before (ingredients bounded only by nutrient, toxin and +# opt-in advisory constraints). Fill the columns to activate the layer; callers can also +# pass `ingredient_limits` per request in the meantime. +code,species,production_system,stage_weight,processing_method,min_inclusion,max_inclusion,note diff --git a/fasa_core/config/nutrient_limits.csv b/fasa_core/config/nutrient_limits.csv new file mode 100644 index 0000000..2fb94e6 --- /dev/null +++ b/fasa_core/config/nutrient_limits.csv @@ -0,0 +1,40 @@ +# FASA nutrient inclusion limits — commercial-practice bounds on DIETARY NUTRIENT levels. +# +# This layer sits on top of ASNS. ASNS states what the fish *requires*; this file states +# what a feed mill can actually *formulate and process* (e.g. crude fibre is commonly +# held below ~8 % because higher-fibre mashes do not extrude and depress growth). +# +# Resolution against ASNS, per (spec_code, direction): +# - direction already in ASNS for the requested stage -> the TIGHTER value wins +# (max of minimums, min of maximums); the constraint is reported with +# source = "asns+limits". +# - direction absent from ASNS -> a new constraint is emitted (source = "limits"). +# - a spec code named here is NEVER masked by the premix: an explicit bound always binds. +# +# Columns: +# spec_code ASNS specification code (must exist in crosswalk.json). Ratio +# specs (DP/DE) are not supported by this layer and are skipped +# with a warning. +# species blank = all. Exact match ("Nile Tilapia" | "African Catfish"). +# production_system blank = all. Exact match ("General-LowCost" | "General"). +# stage_weight blank = all. SUBSTRING match against the ASNS stage label, so +# "Starter" covers "< 5g (Starter)" (same convention as +# premix_mask.json stage_overrides). +# processing_method blank = all. Exact match ("pelleted" | "extruded"). +# min / max bound value in the ASNS unit for that spec code (% for proximates +# and amino acids, kcal/kg for energy, mg/kg for trace minerals, +# ppb for mycotoxins — the `unit` column of the ASNS CSV is the +# authority). Blank = no bound from this layer in that direction. +# These are NOT rescaled: they go in and come back in that unit. +# unit optional guard: when filled it must equal the crosswalk's ASNS +# unit for the code, otherwise loading fails. Catches unit slips. +# note free text, not read by the engine. +# +# When several rows match a request, the most specific row wins (most non-blank scope +# columns); ties are resolved toward the tighter bound. Each direction resolves +# independently. +# +# NO ROWS YET — the limits are still being compiled. While this file holds no data rows +# the engine behaves exactly as before (ASNS alone). Fill the columns to activate the +# layer; callers can also pass `nutrient_limits` per request in the meantime. +spec_code,species,production_system,stage_weight,processing_method,min,max,unit,note diff --git a/fasa_core/constraint_builder.py b/fasa_core/constraint_builder.py index d68506c..8da13d3 100644 --- a/fasa_core/constraint_builder.py +++ b/fasa_core/constraint_builder.py @@ -20,6 +20,14 @@ output. Toxin (TX*) maximums are NEVER masked even if the user passes a custom mask that includes them — the premix doesn't add toxins, and the safety ceilings on aflatoxin/gossypol/etc. are non-negotiable per the FASA brief. + +The nutrient inclusion-limit layer (`inclusion_limits`) is folded into the ASNS +row set before emission, so the limits inherit the whole existing pipeline — +crosswalk resolution, unit conversion, coefficients, the composition report and +the deletion-filter IIS. A row the layer created or tightened is exempt from the +premix mask — an explicit operator bound always binds — while a plain ASNS row +for the same code stays masked, so capping zinc does not also resurrect the zinc +minimum the premix supplies. """ from __future__ import annotations @@ -30,8 +38,11 @@ import pandas as pd from . import crosswalk +from .config.defaults import IN_SPEC_ABS_TOL, IN_SPEC_INPUT_TOL, IN_SPEC_REL_TOL from .data_loader import get_active_constraints +from .inclusion_limits import Bound from .ingredient_pool import IngredientRecord, attach_ficd_rows +from .models import NutrientLine @dataclass @@ -43,6 +54,7 @@ class LinearConstraint: coeffs: dict[str, float] constant: float unit: str + source: str = "asns" # "asns" | "asns+" | ""; see inclusion_limits def __repr__(self) -> str: # pragma: no cover op = {"Minimum": ">=", "Maximum": "<=", "Ratio": "="}[self.restriction_type] @@ -58,6 +70,7 @@ def build_constraints( premix_enabled: bool = True, premix_rate: float = 0.005, premix_mask_override: Optional[list[str]] = None, + nutrient_limits: Optional[dict[str, Bound]] = None, ) -> tuple[list[LinearConstraint], list[str]]: """Materialize the active constraint set. @@ -67,6 +80,7 @@ def build_constraints( warnings : list[str] — non-fatal notes (unmappable specs, etc.) """ asns_active = get_active_constraints(species, stage, production_system) + asns_active, warnings = _apply_nutrient_limits(asns_active, nutrient_limits or {}) ficd_pool = attach_ficd_rows(pool).set_index("code") mask = ( @@ -76,18 +90,19 @@ def build_constraints( ) out: list[LinearConstraint] = [] - warnings: list[str] = [] for _, row in asns_active.iterrows(): code = row["code"] rtype = row["restriction_type"] + source = row["source"] # --- masking rules -------------------------------------------------- # - if code in mask and not code.startswith("TX"): + if code in mask and not code.startswith("TX") and source == "asns": # vit/trace-mineral satisfied by premix; skip silently continue - # toxins are NEVER masked, even via override - # (we've already short-circuited above for non-TX masked codes) + # toxins are NEVER masked, even via override. Neither is a row the limit layer + # created or tightened — but a plain ASNS row for the same code stays masked, + # so bounding zinc's maximum never resurrects the minimum the premix supplies. ficd_param, factor = crosswalk.resolve(code, processing_method) unit = row["unit"] or crosswalk.spec_unit(code) @@ -121,6 +136,7 @@ def build_constraints( coeffs=coeffs, constant=constant, unit=unit, + source=source, ) ) continue @@ -156,11 +172,119 @@ def build_constraints( coeffs=coeffs, constant=0.0, unit=unit, + source=source, ) ) return out, warnings +def _apply_nutrient_limits( + asns_active: pd.DataFrame, + bounds: dict[str, Bound], +) -> tuple[pd.DataFrame, list[str]]: + """Fold the nutrient inclusion-limit layer into the active ASNS rows. + + Where ASNS already states the same direction for a spec code, the tighter value + replaces the target; where it does not, a row is appended. Either way the result + is an ordinary ASNS-shaped frame, so the emission loop below stays unchanged. + """ + df = asns_active.copy() + df["source"] = "asns" + if not bounds: + return df, [] + + warnings: list[str] = [] + present = {(r["code"], r["restriction_type"]): i for i, r in df.iterrows()} + appended: list[dict] = [] + + for code, bound in bounds.items(): + for rtype, value in (("Minimum", bound.minimum), ("Maximum", bound.maximum)): + if value is None: + continue + + idx = present.get((code, rtype)) + if idx is not None: + current = float(df.at[idx, "value_numeric"]) + tighter = max(current, value) if rtype == "Minimum" else min(current, value) + if tighter != current: + df.at[idx, "value_numeric"] = tighter + df.at[idx, "source"] = f"asns+{bound.source}" + continue + + ficd_param, _ = crosswalk.resolve(code) + if ficd_param is None: + warnings.append( + f"[skip] nutrient limit for {code} has no FICD mapping; ignored." + ) + continue + if ficd_param == "__ratio__": + warnings.append( + f"[skip] nutrient limit for {code} targets a ratio spec " + f"({crosswalk.spec_label(code)}); not supported by this layer." + ) + continue + + appended.append({ + "code": code, + "specification": crosswalk.spec_label(code), + "unit": crosswalk.spec_unit(code), + "restriction_type": rtype, + "value_numeric": float(value), + "source": bound.source, + }) + + if appended: + df = pd.concat([df, pd.DataFrame(appended)], ignore_index=True) + return df, warnings + + +# =========================================================================== # +# scoring # +# =========================================================================== # + + +def score_constraints( + constraints: list[LinearConstraint], + fractions: dict[str, float], +) -> list[NutrientLine]: + """Recompute Σ a_i x_i + constant per constraint and label it against the target. + + Shared by `/formulate` (scoring the LP solution) and `/validate-recipe` (scoring + a recipe the caller supplies), so both answer the achieved-vs-target question + through exactly the same arithmetic. + """ + out: list[NutrientLine] = [] + for con in constraints: + weights = [con.coeffs.get(code, 0.0) for code in fractions] + achieved = sum(w * f for w, f in zip(weights, fractions.values())) + con.constant + + # Scale the tolerance to the row's own coefficients rather than to an absolute + # epsilon; the dominant term budgets for quantization of the caller's + # fractions. See IN_SPEC_INPUT_TOL for why. + tol = ( + IN_SPEC_ABS_TOL + + IN_SPEC_REL_TOL * abs(con.rhs) + + IN_SPEC_INPUT_TOL * sum(abs(w) for w in weights) + ) + + in_spec = ( + (con.restriction_type == "Minimum" and achieved >= con.rhs - tol) or + (con.restriction_type == "Maximum" and achieved <= con.rhs + tol) or + (con.restriction_type == "Ratio" and abs(achieved - con.rhs) <= tol) + ) + out.append(NutrientLine( + code=con.spec_code, + spec_label=con.spec_label, + restriction_type=con.restriction_type, + target=con.rhs, + achieved=round(achieved, 6), + unit=con.unit, + in_spec=bool(in_spec), + source=con.source, + )) + return out + + def _ingredient_coefficients(ficd_pool: pd.DataFrame, param: str) -> dict[str, float]: """Pull the FICD `param` value for each pooled ingredient. Missing → 0.""" if param not in ficd_pool.columns: diff --git a/fasa_core/inclusion_limits.py b/fasa_core/inclusion_limits.py new file mode 100644 index 0000000..4b1d7c4 --- /dev/null +++ b/fasa_core/inclusion_limits.py @@ -0,0 +1,308 @@ +"""Min/max inclusion layer: commercial-practice bounds on nutrients and ingredients. + +ASNS states what the fish requires. This layer states what a feed mill can actually +formulate and process — the knowledge a formulator applies by hand on top of a +composition table (fibre ceilings, per-ingredient palatability/extrudability caps, +minimum carrier levels). Without it a least-cost LP happily returns diets that meet +every nutrient target yet cannot be extruded or will not grow fish. + +Two config files, both shipped with a documented header and no data rows yet (so the +engine behaves exactly as before until the columns are filled): + + * `config/nutrient_limits.csv` bounds on dietary nutrient levels, keyed by ASNS code + * `config/ingredient_limits.csv` bounds on single-ingredient inclusion, keyed by FICD code + +Both share the same scoping convention: the `species`, `production_system`, +`stage_weight` and `processing_method` columns are filters, blank meaning "applies to +all". `stage_weight` matches as a substring of the ASNS stage label (the convention +`crosswalk.premix_mask_codes` already uses for its stage overrides). When several rows +match, the most specific row wins; ties resolve toward the tighter bound, and each +direction resolves independently. + +Precedence per code: a request-supplied limit replaces the CSV-resolved one wholesale +(the `custom_premix_mask_codes` precedent), and for ingredients the pool CSV's generated +`max_inclusion` is the fallback when neither layer names the ingredient. +""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Callable, Iterable, Optional + +import pandas as pd + +from . import crosswalk +from .config.defaults import DEFAULT_PROCESSING_METHOD +from .ingredient_pool import IngredientRecord + +NUTRIENT_LIMITS_PATH = Path(__file__).parent / "config" / "nutrient_limits.csv" +INGREDIENT_LIMITS_PATH = Path(__file__).parent / "config" / "ingredient_limits.csv" + +# Scope columns shared by both files; blank cell ⇒ no filtering on that dimension. +SCOPE_COLUMNS = ("species", "production_system", "stage_weight", "processing_method") + + +@dataclass(frozen=True) +class Bound: + """A resolved min/max pair for one spec or ingredient code. + + `source` labels where the pair came from — "limits" (CSV), "request" (per-call + override), or "pool" (the generated pool `max_inclusion` fallback) — and is + echoed through to the response so a client can explain a binding bound. + """ + + minimum: Optional[float] = None + maximum: Optional[float] = None + source: str = "limits" + + +@dataclass(frozen=True) +class LimitScope: + """What a limit row is matched against — one request's formulation context. + + Mirrors `SCOPE_COLUMNS`. Any field may be left unset: a row whose matching column + is blank applies regardless, so a caller that knows only the processing method + still picks up the blanket rows. + """ + + species: Optional[str] = None + stage: Optional[str] = None + production_system: Optional[str] = None + processing_method: str = DEFAULT_PROCESSING_METHOD + + +# =========================================================================== # +# public # +# =========================================================================== # + + +def from_request(limits: Optional[dict]) -> dict[str, Bound]: + """Adapt a request's ``{code: {min, max}}`` mapping into request-sourced bounds. + + Accepts the pydantic limit models or plain dicts, so the API layer stays a + pass-through and direct core callers can hand in literals. + """ + out: dict[str, Bound] = {} + for code, limit in (limits or {}).items(): + pair = limit if isinstance(limit, dict) else {"min": limit.min, "max": limit.max} + out[str(code)] = Bound( + minimum=pair.get("min"), maximum=pair.get("max"), source="request" + ) + return out + + +def nutrient_bounds( + scope: LimitScope, + overrides: Optional[dict[str, Bound]] = None, +) -> dict[str, Bound]: + """Resolve the nutrient-level bounds active for one request, keyed by ASNS code.""" + resolved = _resolve(nutrient_limit_rows(), scope) + resolved.update(overrides or {}) + return resolved + + +def ingredient_bounds( + scope: LimitScope, + pool: Iterable[IngredientRecord], + overrides: Optional[dict[str, Bound]] = None, +) -> dict[str, Bound]: + """Resolve the per-ingredient inclusion bounds active for one request. + + Falls back to the pool CSV's generated `max_inclusion` for ingredients that + neither the limits file nor the request names. + """ + resolved = _resolve(ingredient_limit_rows(), scope) + resolved.update(overrides or {}) + for rec in pool: + if rec.code not in resolved and rec.max_inclusion is not None: + resolved[rec.code] = Bound(maximum=rec.max_inclusion, source="pool") + return resolved + + +def box(bound: Optional[Bound]) -> tuple[float, float]: + """The mass-fraction box a bound implies; an unset side opens to the full [0, 1]. + + Shared by the LP's variable bounds and the pre-solve conflict check so the two + cannot disagree about what a missing minimum or maximum means. + """ + if bound is None: + return 0.0, 1.0 + return ( + 0.0 if bound.minimum is None else bound.minimum, + 1.0 if bound.maximum is None else bound.maximum, + ) + + +def zero_cap_warnings( + bounds: dict[str, Bound], + pool_by_code: dict[str, IngredientRecord], +) -> list[str]: + """Flag priced ingredients a 0 % ceiling removed from consideration. + + Without this the ingredient simply vanishes from the response and the miller + cannot tell a "too expensive" exclusion from a "never recommend this" one. + """ + return [ + f"[limit] '{pool_by_code[code].description}' is priced but excluded by a " + f"0% maximum inclusion limit ({bound.source})." + for code, bound in sorted(bounds.items()) + if bound.maximum == 0.0 and code in pool_by_code + ] + + +def bound_conflicts( + bounds: dict[str, Bound], + codes: Iterable[str], + target_mass: float, +) -> list[str]: + """Report inclusion bounds that cannot add up to the required feed mass. + + These are decision-variable bounds, not rows in the constraint list, so the + deletion-filter IIS cannot see them: it would burn a solve per nutrient + constraint and then blame the nutrients. Checking the box directly turns the + single most likely misconfiguration into a message the caller can act on. + """ + boxes = [box(bounds.get(c)) for c in codes] + floor = sum(lo for lo, _ in boxes) + ceiling = sum(hi for _, hi in boxes) + + out: list[str] = [] + if floor > target_mass + 1e-9: + out.append( + f"Minimum inclusion limits on the priced ingredients sum to " + f"{floor * 100:.2f}%, above the {target_mass * 100:.2f}% of feed mass left " + f"for ingredients. Lower a minimum or reduce the premix rate." + ) + if ceiling < target_mass - 1e-9: + out.append( + f"Maximum inclusion limits on the priced ingredients sum to " + f"{ceiling * 100:.2f}%, below the {target_mass * 100:.2f}% of feed mass that " + f"must be filled. Price more ingredients or raise a maximum." + ) + return out + + +# =========================================================================== # +# loading # +# =========================================================================== # + + +@lru_cache(maxsize=1) +def nutrient_limit_rows() -> tuple[dict, ...]: + """Parsed nutrient-limit rows. Raises on an unmappable code or a unit mismatch.""" + rows = _load(NUTRIENT_LIMITS_PATH, "spec_code", "min", "max") + for row in rows: + code = row["key"] + if crosswalk.resolve(code)[0] is None: + raise ValueError( + f"nutrient_limits.csv: spec_code {code!r} has no crosswalk entry." + ) + declared = (row["raw"].get("unit") or "").strip() + expected = crosswalk.spec_unit(code) + if declared and declared != expected: + raise ValueError( + f"nutrient_limits.csv: spec_code {code!r} declares unit {declared!r} " + f"but the crosswalk's ASNS unit is {expected!r}." + ) + return rows + + +@lru_cache(maxsize=1) +def ingredient_limit_rows() -> tuple[dict, ...]: + """Parsed ingredient-limit rows. Raises on a bound outside [0, 1].""" + rows = _load(INGREDIENT_LIMITS_PATH, "code", "min_inclusion", "max_inclusion") + for row in rows: + for field in ("min", "max"): + value = row[field] + if value is not None and not 0.0 <= value <= 1.0: + raise ValueError( + f"ingredient_limits.csv: code {row['key']!r} has " + f"{field}_inclusion {value!r} outside [0, 1] (mass fraction)." + ) + return rows + + +def _load(path: Path, key_col: str, min_col: str, max_col: str) -> tuple[dict, ...]: + """Parse one limits CSV into normalized rows (cached by the callers above). + + Rows carry a `specificity` count — how many scope columns are filled — which + `_resolve` uses to let a stage-specific row beat a blanket one. + """ + # No existence guard: the files are committed config, so a missing one is a + # deployment defect that should surface (like the pool CSV and crosswalk.json), + # not silently disable the layer. `/ready` calls the loaders for exactly this. + raw = pd.read_csv(path, comment="#", dtype=str, keep_default_na=False) + missing = {key_col, min_col, max_col, *SCOPE_COLUMNS} - set(raw.columns) + if missing: + raise ValueError(f"{path.name}: missing column(s) {sorted(missing)}.") + + out: list[dict] = [] + for _, r in raw.iterrows(): + key = str(r[key_col]).strip() + if not key: + continue + lo, hi = _num(r[min_col]), _num(r[max_col]) + if lo is None and hi is None: + continue + if lo is not None and hi is not None and lo > hi: + raise ValueError(f"{path.name}: {key!r} has minimum {lo} above maximum {hi}.") + scope = {c: str(r[c]).strip() for c in SCOPE_COLUMNS} + out.append({ + "key": key, + "min": lo, + "max": hi, + "scope": scope, + "specificity": sum(1 for v in scope.values() if v), + "raw": {c: str(v).strip() for c, v in r.items()}, + }) + return tuple(out) + + +def _num(cell: object) -> Optional[float]: + text = str(cell).strip() + return float(text) if text else None + + +# =========================================================================== # +# resolution # +# =========================================================================== # + + +def _resolve(rows: tuple[dict, ...], scope: LimitScope) -> dict[str, Bound]: + by_key: dict[str, list[dict]] = defaultdict(list) + for row in rows: + if _in_scope(row["scope"], scope): + by_key[row["key"]].append(row) + return { + key: Bound( + minimum=_tightest(matches, "min", max), + maximum=_tightest(matches, "max", min), + ) + for key, matches in by_key.items() + } + + +def _in_scope(row: dict[str, str], scope: LimitScope) -> bool: + # stage matches as a substring so "Starter" covers "< 5g (Starter)" + return ( + row["species"] in ("", scope.species) + and row["production_system"] in ("", scope.production_system) + and (not row["stage_weight"] or row["stage_weight"] in (scope.stage or "")) + and row["processing_method"] in ("", scope.processing_method) + ) + + +def _tightest( + rows: list[dict], + field: str, + pick: Callable[[Iterable[float]], float], +) -> Optional[float]: + """Most specific rows win; ties resolve toward the tighter bound via `pick`.""" + candidates = [r for r in rows if r[field] is not None] + if not candidates: + return None + top = max(r["specificity"] for r in candidates) + return pick(r[field] for r in candidates if r["specificity"] == top) diff --git a/fasa_core/models.py b/fasa_core/models.py index 6cd2398..b94af52 100644 --- a/fasa_core/models.py +++ b/fasa_core/models.py @@ -8,11 +8,58 @@ from typing import Dict, List, Literal, Optional -from pydantic import BaseModel, Field, ConfigDict, field_validator +from pydantic import BaseModel, Field, ConfigDict, field_validator, model_validator from .config.defaults import normalize_country +# ---------- inclusion limits (shared by request and response) --------------- # + + +class _Limit(BaseModel): + """Base for the min/max pairs of the inclusion-limit layer.""" + + model_config = ConfigDict(extra="forbid") + + min: Optional[float] = None + max: Optional[float] = None + + @model_validator(mode="after") + def _min_not_above_max(self): + if self.min is not None and self.max is not None and self.min > self.max: + raise ValueError(f"min ({self.min}) must not exceed max ({self.max})") + return self + + +class NutrientLimit(_Limit): + """Bound on a dietary nutrient level. Values are in the ASNS unit for the spec + code they are keyed by — % for proximates and amino acids, kcal/kg for energy, + mg/kg for trace minerals, ppb for mycotoxins. Omit a side to leave it unbounded.""" + + min: Optional[float] = Field( + default=None, ge=0.0, description="Lower bound in this spec code's ASNS unit." + ) + max: Optional[float] = Field( + default=None, ge=0.0, description="Upper bound in this spec code's ASNS unit." + ) + + +class IngredientInclusionLimit(_Limit): + """Bound on a single ingredient's inclusion, as a mass FRACTION of total feed in + [0, 1] — 0.15 means 15% of the feed. Note the unit flip against the response, + which reports the same bound as a percentage (`max_inclusion_percent: 15.0`). + Omit a side to leave it unbounded.""" + + min: Optional[float] = Field( + default=None, ge=0.0, le=1.0, + description="Lower bound as a mass fraction of total feed (0.05 = 5%).", + ) + max: Optional[float] = Field( + default=None, ge=0.0, le=1.0, + description="Upper bound as a mass fraction of total feed (0.15 = 15%). 0 excludes the ingredient.", + ) + + # ---------- request --------------------------------------------------------- # @@ -102,6 +149,29 @@ def _normalize_country(cls, v): max_length=200, ) + nutrient_limits: Optional[Dict[str, NutrientLimit]] = Field( + default=None, + description=( + "Per-request override of the nutrient inclusion-limit layer: ASNS spec code " + "-> {min, max} in that code's ASNS unit (e.g. {'PA05': {'max': 8}} to hold " + "crude fibre below 8% of the diet). An entry replaces the configured limit " + "for that code. Where ASNS already states the same direction, the tighter " + "value binds. Ratio specs are not supported and are skipped with a warning." + ), + max_length=300, + ) + ingredient_limits: Optional[Dict[str, IngredientInclusionLimit]] = Field( + default=None, + description=( + "Per-request override of the ingredient inclusion-limit layer: FICD " + "ingredient code -> {min, max} as a mass fraction of total feed (e.g. " + "{'31147': {'max': 0.2}} to cap rice bran at 20%). An entry replaces the " + "configured limit for that ingredient. A max of 0 excludes the ingredient " + "even when it is priced; a min only applies to priced ingredients." + ), + max_length=300, + ) + class ValidateRecipeRequest(BaseModel): """JSON payload accepted by POST /validate-recipe.""" @@ -120,6 +190,59 @@ class ValidateRecipeRequest(BaseModel): max_length=300, ) + # Scope for the inclusion-limit layer. All optional so the endpoint keeps + # working as a plain composition recompute when they are omitted; limit rows + # with a blank scope column always apply, scoped rows need the matching value. + species: Optional[Literal["Nile Tilapia", "African Catfish"]] = Field( + default=None, + description=( + "Optional. Scopes the inclusion limits and, together with `stage` and " + "`production_system`, enables the nutrient checks." + ), + ) + stage: Optional[str] = Field( + default=None, + description="Optional ASNS stage_weight label, e.g. '< 5g (Starter)'.", + examples=["< 5g (Starter)"], + ) + production_system: Optional[Literal["General-LowCost", "General"]] = Field( + default=None, + description="Optional. Required (with `species` and `stage`) for the nutrient checks.", + ) + processing_method: Literal["pelleted", "extruded"] = Field( + default="pelleted", + description=( + "Scopes the inclusion limits and selects the digestible-energy column " + "used by the nutrient checks." + ), + ) + premix_enabled: bool = Field( + default=True, + description=( + "Mirrors /formulate: when true the vitamin and trace-mineral specs the " + "premix covers are excluded from the nutrient checks, since the premix's " + "own contribution is not modelled." + ), + ) + + nutrient_limits: Optional[Dict[str, NutrientLimit]] = Field( + default=None, + description=( + "ASNS spec code -> {min, max} in that code's ASNS unit, e.g. " + "{'PA05': {'max': 8}}. Overrides the configured limits for the codes named." + ), + max_length=300, + ) + ingredient_limits: Optional[Dict[str, IngredientInclusionLimit]] = Field( + default=None, + description=( + "FICD ingredient code -> {min, max} as a mass fraction of total feed in " + "[0, 1], e.g. {'31605': {'max': 0.15}}. Overrides the configured limits " + "for the codes named." + ), + max_length=300, + ) + # ---------- response -------------------------------------------------------- # @@ -133,6 +256,25 @@ class IngredientLine(BaseModel): quantity_kg: Optional[float] = None # True/False when a `country` was requested; None when no country context was given. locally_available: Optional[bool] = None + min_inclusion_percent: Optional[float] = Field( + default=None, + description=( + "Inclusion floor applied to this ingredient, as a PERCENTAGE of feed " + "(limits are sent as fractions but reported as percentages). " + "Null when unbounded below." + ), + ) + max_inclusion_percent: Optional[float] = Field( + default=None, + description="Inclusion ceiling, as a percentage of feed. Null when unbounded above.", + ) + limit_source: Optional[str] = Field( + default=None, + description=( + "Where that bound came from: 'limits' (config table), 'request' (this call), " + "or 'pool' (the generated pool fallback). Null when no limit applied." + ), + ) class NutrientLine(BaseModel): @@ -144,6 +286,14 @@ class NutrientLine(BaseModel): unit: str in_spec: bool masked_by_premix: bool = False + source: str = Field( + default="asns", + description=( + "Where `target` came from: 'asns' (species requirement alone), " + "'asns+limits'/'asns+request' (an ASNS target tightened by the inclusion-limit " + "layer), or 'limits'/'request' (a bound ASNS does not state for this stage)." + ), + ) class InfeasibilityReport(BaseModel): @@ -152,6 +302,15 @@ class InfeasibilityReport(BaseModel): iis_codes: List[str] = Field(..., description="Irreducible Inconsistent Subset of ASNS spec codes.") iis_explanations: List[str] suggestion: str + bound_conflicts: List[str] = Field( + default=[], + description=( + "Ingredient inclusion limits that cannot add up to the required feed mass. " + "These are decision-variable bounds rather than constraint rows, so they " + "are reported here instead of through `iis_codes`; when this list is " + "populated the IIS is not computed." + ), + ) class FormulateResponse(BaseModel): @@ -194,8 +353,69 @@ class SupportedResponse(BaseModel): stages_by_species_and_system: Dict[str, Dict[str, List[str]]] +class IngredientLimitLine(BaseModel): + """One supplied ingredient's inclusion checked against the limit layer. + + All three percent fields are PERCENTAGES of feed mass, while `fractions` and + `ingredient_limits` on the request are fractions in [0, 1] — 0.15 in, 15.0 out. + """ + + code: str = Field(..., description="FICD ingredient code.") + description: str = Field(..., description="Ingredient name.") + inclusion_percent: float = Field( + ..., description="The supplied inclusion, as a percentage of feed mass." + ) + min_inclusion_percent: Optional[float] = Field( + default=None, description="Applied floor, percent of feed; null when unbounded below." + ) + max_inclusion_percent: Optional[float] = Field( + default=None, description="Applied ceiling, percent of feed; null when unbounded above." + ) + limit_source: Optional[str] = Field( + default=None, + description=( + "Where the bound came from: 'limits' (config table), 'request' (this call), " + "or 'pool' (the generated pool fallback). Null when no limit applied." + ), + ) + in_limits: bool = Field( + ..., description="False when this inclusion falls outside the applied box." + ) + + class ValidateRecipeResponse(BaseModel): - composition: Dict[str, float] + composition: Dict[str, float] = Field( + ..., description="Weighted nutrient composition per requested FICD parameter." + ) + + total_inclusion_percent: float = Field( + default=0.0, + description=( + "Sum of the supplied fractions, as a percentage of feed mass. A complete " + "diet sums to 100%, or 99-100% when a premix takes the remainder." + ), + ) + in_limits: bool = Field( + default=True, + description=( + "True when every check that ran passed — the per-ingredient inclusion " + "checks and, when they ran, the nutrient checks." + ), + ) + inclusion_checks: List[IngredientLimitLine] = Field( + default=[], description="One line per supplied ingredient. Always populated." + ) + nutrient_checks: List[NutrientLine] = Field( + default=[], + description=( + "Achieved-vs-target per active constraint, same shape as /formulate's " + "`composition`. Empty unless species, stage and production_system were all supplied." + ), + ) + warnings: List[str] = Field( + default=[], + description="Non-fatal notes: '[mass]' (fractions do not form a complete diet), '[skip]' (a check could not run).", + ) class ErrorResponse(BaseModel): diff --git a/fasa_core/optimizer.py b/fasa_core/optimizer.py index e218bac..a369e7d 100644 --- a/fasa_core/optimizer.py +++ b/fasa_core/optimizer.py @@ -27,6 +27,7 @@ import pulp +from . import inclusion_limits from .config.defaults import ( DEFAULT_MAX_BINDER_INCLUSION, DEFAULT_MAX_FISHMEAL_COST_SHARE, @@ -36,15 +37,16 @@ SOLUTION_FRACTION_TOL, SOLVER_TIME_LIMIT_SECONDS, WARN_INGREDIENT_INCLUSION_THRESHOLD, + as_percent, normalize_country, ) -from .constraint_builder import LinearConstraint, build_constraints +from .constraint_builder import LinearConstraint, build_constraints, score_constraints +from .inclusion_limits import Bound, LimitScope from .ingredient_pool import IngredientRecord, load_pool from .models import ( FormulateResponse, InfeasibilityReport, IngredientLine, - NutrientLine, ) LOGGER = logging.getLogger("fasa_core.optimizer") @@ -69,6 +71,8 @@ def formulate( custom_premix_mask_codes: Optional[list[str]] = None, batch_size_kg: Optional[float] = None, country: Optional[str] = None, + nutrient_limits: Optional[dict] = None, + ingredient_limits: Optional[dict] = None, ) -> FormulateResponse: """Run the LP and return a structured response. @@ -95,6 +99,21 @@ def formulate( pool_by_code = {r.code: r for r in pool} ingr_codes = list(pool_by_code.keys()) + # The min/max inclusion layer: nutrient bounds join the ASNS constraint set, + # ingredient bounds become the decision variables' box. + scope = LimitScope( + species=species, + stage=stage, + production_system=production_system, + processing_method=processing_method, + ) + nutrient_box = inclusion_limits.nutrient_bounds( + scope, overrides=inclusion_limits.from_request(nutrient_limits) + ) + ingredient_box = inclusion_limits.ingredient_bounds( + scope, pool, overrides=inclusion_limits.from_request(ingredient_limits) + ) + constraints, build_warnings = build_constraints( species=species, stage=stage, @@ -104,50 +123,30 @@ def formulate( premix_enabled=premix_enabled, premix_rate=premix_rate, premix_mask_override=custom_premix_mask_codes, + nutrient_limits=nutrient_box, ) + build_warnings += inclusion_limits.zero_cap_warnings(ingredient_box, pool_by_code) # MVP placeholder hook (no-op). Wire in non-additive interaction corrections # here when empirical pairwise data becomes available (Hua & Bureau 2012). constraints = _apply_anti_nutrient_digestibility_penalties(constraints) - # ---------- 2. build & solve the LP ------------------------------------- # - - prob, x = _build_pulp_problem( - ingr_codes, pool_by_code, prices, constraints, + target_mass = 1.0 - (premix_rate if premix_enabled else 0.0) + solver_kwargs = dict( premix_rate=premix_rate if premix_enabled else 0.0, max_fishmeal_cost_share=max_fishmeal_cost_share, max_binder_inclusion=max_binder_inclusion, + bounds=ingredient_box, ) - status = _solve(prob) - # ---------- 3. infeasibility path --------------------------------------- # - - if status != pulp.LpStatusOptimal: - iis = _deletion_filter_iis( - ingr_codes, pool_by_code, prices, constraints, - premix_rate=premix_rate if premix_enabled else 0.0, - max_fishmeal_cost_share=max_fishmeal_cost_share, - max_binder_inclusion=max_binder_inclusion, - ) + def _infeasible(report: InfeasibilityReport) -> FormulateResponse: return FormulateResponse( status="infeasible", species=species, stage=stage, production_system=production_system, processing_method=processing_method, country=country, warnings=build_warnings, - infeasibility=InfeasibilityReport( - iis_codes=[c.spec_code for c in iis], - iis_explanations=[ - f"{c.spec_code} ({c.spec_label}): {c.restriction_type} " - f"{c.rhs:g} {c.unit} cannot be met from the priced pool." - for c in iis - ], - suggestion=( - "Consider adding alternative ingredients (e.g., synthetic Lys/Met, " - "fish meal, soybean meal), increasing the premix rate, or relaxing " - "the production-system tier (General-LowCost ↔ General)." - ), - ), + infeasibility=report, premix_enabled=premix_enabled, premix_rate=premix_rate, max_fishmeal_cost_share=max_fishmeal_cost_share, @@ -155,7 +154,53 @@ def formulate( batch_size_kg=batch_size_kg, ) - # ---------- 4. extract & decorate solution ------------------------------ # + # ---------- 2. bound-feasibility pre-check ------------------------------ # + # Inclusion bounds that cannot sum to the feed mass make every subset of the + # constraint list infeasible, so the deletion filter would spend a solve per + # nutrient and then blame the nutrients. Report the real cause instead. + + conflicts = inclusion_limits.bound_conflicts(ingredient_box, ingr_codes, target_mass) + if conflicts: + return _infeasible(InfeasibilityReport( + iis_codes=[], + iis_explanations=[], + bound_conflicts=conflicts, + suggestion=( + "Adjust the ingredient inclusion limits so their range spans the " + "required feed mass, or price additional ingredients." + ), + )) + + # ---------- 3. build & solve the LP ------------------------------------- # + + prob, x = _build_pulp_problem( + ingr_codes, pool_by_code, prices, constraints, **solver_kwargs + ) + status = _solve(prob) + + # ---------- 4. infeasibility path --------------------------------------- # + + if status != pulp.LpStatusOptimal: + iis = _deletion_filter_iis( + ingr_codes, pool_by_code, prices, constraints, **solver_kwargs + ) + return _infeasible(InfeasibilityReport( + iis_codes=[c.spec_code for c in iis], + iis_explanations=[ + f"{c.spec_code} ({c.spec_label}): {c.restriction_type} " + f"{c.rhs:g} {c.unit} cannot be met from the priced pool " + f"[{c.source}]." + for c in iis + ], + suggestion=( + "Consider adding alternative ingredients (e.g., synthetic Lys/Met, " + "fish meal, soybean meal), increasing the premix rate, relaxing an " + "inclusion limit, or relaxing the production-system tier " + "(General-LowCost ↔ General)." + ), + )) + + # ---------- 5. extract & decorate solution ------------------------------ # solution_fractions = {c: float(v.value() or 0.0) for c, v in x.items()} cost = float(pulp.value(prob.objective)) @@ -167,14 +212,18 @@ def formulate( continue rec = pool_by_code[code] qty_kg = round(frac * batch_size_kg, 3) if batch_size_kg is not None else None + bound = ingredient_box.get(code) recipe.append(IngredientLine( code=code, description=rec.description, - inclusion_percent=round(frac * 100.0, 4), + inclusion_percent=as_percent(frac), cost_per_kg=prices[code], cost_contribution=round(frac * prices[code], 6), quantity_kg=qty_kg, locally_available=(country in rec.countries) if country is not None else None, + min_inclusion_percent=as_percent(bound.minimum) if bound else None, + max_inclusion_percent=as_percent(bound.maximum) if bound else None, + limit_source=bound.source if bound else None, )) if frac > WARN_INGREDIENT_INCLUSION_THRESHOLD: warnings.append( @@ -183,9 +232,7 @@ def formulate( f"single-ingredient guard threshold." ) - composition = _build_composition_report( - constraints, solution_fractions, premix_enabled - ) + composition = score_constraints(constraints, solution_fractions) recipe.sort(key=lambda r: -r.inclusion_percent) @@ -228,13 +275,16 @@ def _build_pulp_problem( premix_rate: float, max_fishmeal_cost_share: Optional[float], max_binder_inclusion: Optional[float], + bounds: dict[str, Bound], ): prob = pulp.LpProblem("FASA_FeedFormulation", pulp.LpMinimize) + # `bounds` is the resolved inclusion-limit box (it already folds in the pool + # CSV's generated max_inclusion); unbounded ingredients get the full [0, 1]. x: dict[str, pulp.LpVariable] = {} for code in ingr_codes: - ub = pool_by_code[code].max_inclusion if pool_by_code[code].max_inclusion is not None else 1.0 - x[code] = pulp.LpVariable(f"x_{code}", lowBound=0.0, upBound=ub, cat="Continuous") + lb, ub = inclusion_limits.box(bounds.get(code)) + x[code] = pulp.LpVariable(f"x_{code}", lowBound=lb, upBound=ub, cat="Continuous") # objective prob += pulp.lpSum(prices[c] * x[c] for c in ingr_codes), "TotalCost_per_kg" @@ -298,7 +348,7 @@ def _solve(prob: pulp.LpProblem) -> int: def _deletion_filter_iis( ingr_codes, pool_by_code, prices, constraints, *, - premix_rate, max_fishmeal_cost_share, max_binder_inclusion, + premix_rate, max_fishmeal_cost_share, max_binder_inclusion, bounds, ) -> list[LinearConstraint]: """Greedy deletion-filter: returns a minimal infeasible subset. @@ -314,6 +364,7 @@ def _is_infeasible(subset): premix_rate=premix_rate, max_fishmeal_cost_share=max_fishmeal_cost_share, max_binder_inclusion=max_binder_inclusion, + bounds=bounds, ) st = _solve(prob) return st != pulp.LpStatusOptimal @@ -333,38 +384,6 @@ def _is_infeasible(subset): return keep -# =========================================================================== # -# composition report # -# =========================================================================== # - - -def _build_composition_report( - constraints: list[LinearConstraint], - fractions: dict[str, float], - premix_enabled: bool, -) -> list[NutrientLine]: - """For each emitted constraint, recompute Σ a_i x_i + constant and label it.""" - out: list[NutrientLine] = [] - for con in constraints: - achieved = sum(con.coeffs.get(c, 0.0) * fractions.get(c, 0.0) - for c in fractions) + con.constant - in_spec = ( - (con.restriction_type == "Minimum" and achieved >= con.rhs - 1e-6) or - (con.restriction_type == "Maximum" and achieved <= con.rhs + 1e-6) or - (con.restriction_type == "Ratio" and abs(achieved - con.rhs) < 1e-6) - ) - out.append(NutrientLine( - code=con.spec_code, - spec_label=con.spec_label, - restriction_type=con.restriction_type, - target=con.rhs, - achieved=round(achieved, 6), - unit=con.unit, - in_spec=bool(in_spec), - )) - return out - - # =========================================================================== # # post-MVP hooks (no-ops for now) # # =========================================================================== # diff --git a/fasa_core/validator.py b/fasa_core/validator.py index dfb06ec..0f3c22b 100644 --- a/fasa_core/validator.py +++ b/fasa_core/validator.py @@ -4,6 +4,10 @@ column-mapping errors. The `validate()` function is called automatically by the API and also exposed for ad-hoc testing. +`validate_recipe()` backs POST /validate-recipe: it recomputes composition for a +recipe the caller supplies and scores it against the min/max inclusion layer, so a +hand-edited or externally sourced recipe can be checked without re-running the LP. + PAFF benchmark: feed the engine the 36 reference recipes' inclusion vectors *directly* (i.e., bypass the LP and compute composition only) and compare nutrient-by-nutrient with the published Calculated_Composition table. @@ -11,10 +15,29 @@ from __future__ import annotations +from functools import lru_cache +from typing import Optional + import pandas as pd -from .config.defaults import VALIDATION_REL_TOL +from . import inclusion_limits +from .config.defaults import ( + DEFAULT_PROCESSING_METHOD, + SOLUTION_FRACTION_TOL, + VALIDATION_REL_TOL, + as_percent, +) +from .constraint_builder import build_constraints, score_constraints from .data_loader import load_ficd_wide, load_paff +from .inclusion_limits import LimitScope +from .ingredient_pool import load_pool +from .models import IngredientLimitLine, NutrientLine, ValidateRecipeResponse + +# A complete diet sums to 100% of feed mass, or a little less when a vitamin/mineral +# premix takes the remainder (the LP returns 1 - premix_rate, default 99.5%). Same +# band the smoke tests assert on the LP's own output. +MIN_TOTAL_INCLUSION = 0.99 +MAX_TOTAL_INCLUSION = 1.0 # --------------------------------------------------------------------------- # @@ -44,6 +67,143 @@ def compute_composition( return composition.rename("value").to_frame() +# --------------------------------------------------------------------------- # +# explicit recipe → composition + inclusion-rate checks # +# --------------------------------------------------------------------------- # + + +def validate_recipe( + fractions: dict[str, float], + *, + parameters: Optional[list[str]] = None, + species: Optional[str] = None, + stage: Optional[str] = None, + production_system: Optional[str] = None, + processing_method: str = DEFAULT_PROCESSING_METHOD, + premix_enabled: bool = True, + nutrient_limits: Optional[dict] = None, + ingredient_limits: Optional[dict] = None, +) -> ValidateRecipeResponse: + """Score a caller-supplied recipe: composition, inclusion rates, nutrient levels. + + Composition always comes back. The per-ingredient inclusion checks always run — + limit rows with a blank scope column apply to every request, so they bind even + when no species/stage is given. The nutrient checks need the full + (species, stage, production_system) triple to resolve an ASNS row set, and are + omitted with a warning when it is incomplete. + """ + fractions = {str(code): float(frac) for code, frac in fractions.items()} + composition = compute_composition(fractions, parameters=parameters) + scope = LimitScope( + species=species, + stage=stage, + production_system=production_system, + processing_method=processing_method, + ) + + pool = load_pool(only_codes=set(fractions)) + descriptions = _ficd_descriptions() + warnings: list[str] = [] + + unpooled = sorted(set(fractions) - {rec.code for rec in pool}) + if unpooled: + # These still contribute to `composition` (that path reads FICD directly), + # but they carry no pool entry, so they can hold no inclusion limit and + # contribute nothing to the nutrient checks — same restriction /formulate has. + warnings.append( + f"[skip] {len(unpooled)} code(s) are absent from the ingredient pool, so they " + f"carry no inclusion limits and are excluded from the nutrient checks: " + f"{', '.join(unpooled)}." + ) + + # ---------- inclusion rates --------------------------------------------- # + + total = sum(fractions.values()) + # The band carries a tolerance so summation noise at exactly 1.0 does not warn. + if not (MIN_TOTAL_INCLUSION - SOLUTION_FRACTION_TOL + <= total + <= MAX_TOTAL_INCLUSION + SOLUTION_FRACTION_TOL): + warnings.append( + f"[mass] inclusions sum to {total * 100:.2f}% of feed mass. A complete diet " + f"sums to 100%, or {MIN_TOTAL_INCLUSION * 100:.0f}-100% when a vitamin/mineral " + f"premix takes the remainder." + ) + + box = inclusion_limits.ingredient_bounds( + scope, pool, overrides=inclusion_limits.from_request(ingredient_limits) + ) + + inclusion_checks = [ + _check_inclusion(code, frac, box.get(code), descriptions.get(code, "")) + for code, frac in sorted(fractions.items(), key=lambda kv: -kv[1]) + ] + + # ---------- nutrient levels --------------------------------------------- # + + nutrient_checks: list[NutrientLine] = [] + if species and stage and production_system: + constraints, build_warnings = build_constraints( + species=species, + stage=stage, + production_system=production_system, + pool=pool, + processing_method=processing_method, + premix_enabled=premix_enabled, + nutrient_limits=inclusion_limits.nutrient_bounds( + scope, overrides=inclusion_limits.from_request(nutrient_limits) + ), + ) + warnings += build_warnings + nutrient_checks = score_constraints(constraints, fractions) + elif any((species, stage, production_system)) or nutrient_limits: + warnings.append( + "[skip] nutrient checks need species, stage and production_system together; " + "only the per-ingredient inclusion limits were checked." + ) + + return ValidateRecipeResponse( + composition=composition["value"].round(6).to_dict(), + total_inclusion_percent=as_percent(total), + in_limits=( + all(line.in_limits for line in inclusion_checks) + and all(line.in_spec for line in nutrient_checks) + ), + inclusion_checks=inclusion_checks, + nutrient_checks=nutrient_checks, + warnings=warnings, + ) + + +def _check_inclusion( + code: str, + fraction: float, + bound: Optional[inclusion_limits.Bound], + description: str, +) -> IngredientLimitLine: + """Compare one inclusion against its resolved box, tolerating fraction-scale noise.""" + low = bound.minimum if bound is not None else None + high = bound.maximum if bound is not None else None + return IngredientLimitLine( + code=code, + description=description, + inclusion_percent=as_percent(fraction), + min_inclusion_percent=as_percent(low), + max_inclusion_percent=as_percent(high), + limit_source=bound.source if bound is not None else None, + in_limits=( + (low is None or fraction >= low - SOLUTION_FRACTION_TOL) + and (high is None or fraction <= high + SOLUTION_FRACTION_TOL) + ), + ) + + +@lru_cache(maxsize=1) +def _ficd_descriptions() -> dict[str, str]: + """code -> description for every FICD ingredient. Read-only; derived once.""" + ficd = load_ficd_wide() + return dict(zip(ficd["code"].astype(str), ficd["description"])) + + # --------------------------------------------------------------------------- # # PAFF benchmark gate # # --------------------------------------------------------------------------- # diff --git a/pyproject.toml b/pyproject.toml index e3b4a92..700a5f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fasa-engine" -version = "0.3.0" +version = "0.4.0" description = "Least-cost, digestibility-aware aquaculture feed formulation engine (FASA MVP)." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_api.py b/tests/test_api.py index a250202..b3dfc8c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,6 +3,7 @@ from fastapi.testclient import TestClient from fasa_api.main import app +from tests.test_smoke import DEMO_PRICES def _client(auth_required: bool = True, token: str = "test-token") -> TestClient: @@ -70,3 +71,89 @@ def test_validate_recipe_rejects_invalid_fraction_range(): ) assert r.status_code == 400 assert r.json()["detail"]["code"] == "invalid_fraction" + + +def test_formulate_accepts_inclusion_limits(): + """Both limit layers travel through the HTTP surface and shape the response.""" + client = _client(auth_required=True, token="abc123") + payload = { + "species": "Nile Tilapia", + "stage": "< 5g (Starter)", + "prices": DEMO_PRICES, + "nutrient_limits": {"PA05": {"max": 8.0}}, + "ingredient_limits": {"30355": {"max": 0.35}}, + } + r = client.post("/formulate", json=payload, headers={"Authorization": "Bearer abc123"}) + assert r.status_code == 200 + body = r.json() + assert body["status"] in {"optimal", "infeasible"} + for line in body["recipe"]: + if line["code"] == "30355": + assert line["max_inclusion_percent"] == 35.0 + assert line["inclusion_percent"] <= 35.0 + 1e-3 + # ASNS caps starter fibre at 7%, tighter than the 8% practice limit + fibre = next(c for c in body["composition"] if c["code"] == "PA05") + assert (fibre["target"], fibre["source"]) == (7.0, "asns") + + +def test_formulate_rejects_an_out_of_range_ingredient_limit(): + client = _client(auth_required=True, token="abc123") + payload = { + "species": "Nile Tilapia", + "stage": "< 5g (Starter)", + "prices": {"30355": 0.30}, + "ingredient_limits": {"30355": {"max": 1.5}}, # >100% of feed mass + } + r = client.post("/formulate", json=payload, headers={"Authorization": "Bearer abc123"}) + assert r.status_code == 422 + + +def test_validate_recipe_keeps_the_original_payload_shape_working(): + """Backward compatibility: fractions + parameters alone still returns composition.""" + client = _client(auth_required=True, token="abc123") + payload = {"fractions": {"30355": 0.5, "31237": 0.495}, + "parameters": ["crude_protein_percent"]} + r = client.post("/validate-recipe", json=payload, + headers={"Authorization": "Bearer abc123"}) + assert r.status_code == 200 + body = r.json() + assert set(body["composition"]) == {"crude_protein_percent"} + assert body["nutrient_checks"] == [] + assert body["in_limits"] is True + + +def test_validate_recipe_checks_inclusion_rates(): + """Inclusion limits travel through the HTTP surface and flag a breach.""" + client = _client(auth_required=True, token="abc123") + payload = { + "fractions": {"31605": 0.40, "31621": 0.595}, + "parameters": [], + "species": "Nile Tilapia", + "stage": "< 5g (Starter)", + "production_system": "General-LowCost", + "ingredient_limits": {"31605": {"max": 0.15}}, + } + r = client.post("/validate-recipe", json=payload, + headers={"Authorization": "Bearer abc123"}) + assert r.status_code == 200 + body = r.json() + assert body["in_limits"] is False + assert body["total_inclusion_percent"] == 99.5 + bran = next(c for c in body["inclusion_checks"] if c["code"] == "31605") + assert (bran["max_inclusion_percent"], bran["in_limits"]) == (15.0, False) + # full scope given, so the nutrient side was scored too + assert body["nutrient_checks"] + + +def test_validate_recipe_rejects_an_unknown_stage(): + client = _client(auth_required=True, token="abc123") + payload = { + "fractions": {"30355": 0.995}, + "species": "Nile Tilapia", + "stage": "not-a-stage", + "production_system": "General", + } + r = client.post("/validate-recipe", json=payload, + headers={"Authorization": "Bearer abc123"}) + assert r.status_code == 400 + assert r.json()["detail"]["code"] == "invalid_request" diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 9946743..3425cf5 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -368,3 +368,339 @@ def test_paff_reproduction(paff_label): pytest.skip("no comparable parameters in PAFF") # Tight tolerance is a stretch goal; loose tolerance must hold. assert (rep_clean["rel_diff"] < 0.10).all(), rep_clean.to_string() + + +# --------------------------------------------------------------------------- # +# min/max inclusion layer # +# --------------------------------------------------------------------------- # + + +BASE = dict( + species="Nile Tilapia", + stage="< 5g (Starter)", + production_system="General-LowCost", + prices=DEMO_PRICES, +) + + +def _spec(res, code): + return next(line for line in res.composition if line.code == code) + + +def test_limits_config_files_ship_without_data_rows(): + """The layer is wired but empty, so today's formulations are unchanged.""" + from fasa_core.inclusion_limits import ingredient_limit_rows, nutrient_limit_rows + + assert nutrient_limit_rows() == () + assert ingredient_limit_rows() == () + + +def test_nutrient_limit_tighter_than_asns_replaces_the_target(): + """ASNS caps starter crude fibre at 7%; a 3% practice limit must bind instead.""" + res = formulate(**BASE, nutrient_limits={"PA05": {"max": 3.0}}) + fibre = _spec(res, "PA05") + assert fibre.restriction_type == "Maximum" + assert fibre.target == 3.0 + assert fibre.source == "asns+request" + + +def test_nutrient_limit_looser_than_asns_never_loosens_it(): + """A commercial 8% fibre ceiling must not relax the 7% ASNS requirement.""" + res = formulate(**BASE, nutrient_limits={"PA05": {"max": 8.0}}) + fibre = _spec(res, "PA05") + assert fibre.target == 7.0 + assert fibre.source == "asns" + + +def test_nutrient_limit_adds_a_bound_asns_does_not_state(): + """ASNS has no ash ceiling for this stage; the limit layer supplies one.""" + res = formulate(**BASE, nutrient_limits={"PA06": {"max": 8.0}}) + ash = _spec(res, "PA06") + assert (ash.restriction_type, ash.target, ash.source) == ("Maximum", 8.0, "request") + + +def test_nutrient_limit_on_a_ratio_spec_is_skipped_with_a_warning(): + res = formulate(**BASE, nutrient_limits={"ADPXF09": {"min": 30.0}}) + assert any("ratio spec" in w for w in res.warnings) + + +def test_nutrient_limit_is_not_masked_by_the_premix(): + """Zinc is premix-masked, but an explicit operator bound must still bind.""" + res = formulate(**BASE, premix_enabled=True, nutrient_limits={"M12": {"max": 250.0}}) + zinc = _spec(res, "M12") + assert (zinc.restriction_type, zinc.target, zinc.source) == ("Maximum", 250.0, "request") + + +def test_ingredient_limit_floor_is_honored_and_echoed(): + """A minimum forces the ingredient in and the line reports the applied box.""" + res = formulate(**BASE, ingredient_limits={"10018": {"min": 0.05}}) + if res.status != "optimal": + pytest.skip(f"LP did not solve to optimal (status={res.status})") + line = next(l for l in res.recipe if l.code == "10018") + assert line.inclusion_percent >= 5.0 - 1e-3 + assert line.min_inclusion_percent == 5.0 + assert line.limit_source == "request" + + +def test_ingredient_limit_ceiling_is_honored(): + res = formulate(**BASE, ingredient_limits={"31621": {"max": 0.15}}) + if res.status != "optimal": + pytest.skip(f"LP did not solve to optimal (status={res.status})") + line = next((l for l in res.recipe if l.code == "31621"), None) + if line is not None: + assert line.inclusion_percent <= 15.0 + 1e-3 + assert line.max_inclusion_percent == 15.0 + + +def test_ingredient_zero_ceiling_excludes_a_priced_ingredient_and_warns(): + """'Do not recommend this, full stop' — the exclusion must be visible.""" + res = formulate(**BASE, ingredient_limits={"31605": {"max": 0.0}}) + assert all(line.code != "31605" for line in res.recipe) + assert any("31605" in w or "Wheat bran" in w for w in res.warnings) + + +def test_ceilings_that_cannot_fill_the_feed_report_a_bound_conflict(): + """Bounds are variable boxes, not constraint rows, so the IIS cannot see them.""" + res = formulate(**BASE, ingredient_limits={c: {"max": 0.01} for c in DEMO_PRICES}) + assert res.status == "infeasible" + assert res.infeasibility.iis_codes == [] + assert len(res.infeasibility.bound_conflicts) == 1 + assert "below" in res.infeasibility.bound_conflicts[0] + + +def test_floors_above_the_available_feed_mass_report_a_bound_conflict(): + limits = {c: {"min": 0.10} for c in list(DEMO_PRICES)[:12]} + res = formulate(**BASE, ingredient_limits=limits) + assert res.status == "infeasible" + assert len(res.infeasibility.bound_conflicts) == 1 + assert "above" in res.infeasibility.bound_conflicts[0] + + +def test_a_binding_nutrient_limit_is_named_in_the_iis(): + """An infeasibility the limit layer caused must be attributable to it.""" + res = formulate(**BASE, nutrient_limits={"PA03": {"max": 20.0}}) + assert res.status == "infeasible" + assert "PA03" in res.infeasibility.iis_codes + assert any("[request]" in e for e in res.infeasibility.iis_explanations) + + +def test_limits_absent_leaves_the_recipe_and_the_box_untouched(): + """Wiring the layer in must not move today's baseline result.""" + base = formulate(**BASE) + if base.status != "optimal": + pytest.skip(f"LP did not solve to optimal (status={base.status})") + assert all(line.limit_source is None for line in base.recipe) + assert all(line.max_inclusion_percent is None for line in base.recipe) + assert all(line.source == "asns" for line in base.composition) + + +def test_scope_resolution_prefers_the_most_specific_row(tmp_path): + """A stage-scoped row beats a blanket one; ties fall to the tighter bound.""" + from fasa_core.inclusion_limits import LimitScope, _load, _resolve + + csv = tmp_path / "limits.csv" + csv.write_text( + "code,species,production_system,stage_weight,processing_method," + "min_inclusion,max_inclusion\n" + "31605,,,,,,0.30\n" # blanket ceiling + "31605,,,Starter,,,0.10\n" # stage-specific: substring match + "31605,Nile Tilapia,,,,,0.20\n" # equally specific as the stage row + "30355,,,,,0.05,\n" # floor only + ) + rows = _load(csv, "code", "min_inclusion", "max_inclusion") + resolved = _resolve(rows, LimitScope("Nile Tilapia", "< 5g (Starter)", "General-LowCost")) + + # two rows tie at specificity 1; the tighter ceiling wins + assert resolved["31605"].maximum == 0.10 + assert resolved["31605"].minimum is None + assert (resolved["30355"].minimum, resolved["30355"].maximum) == (0.05, None) + + +def test_scope_filters_exclude_non_matching_rows(tmp_path): + from fasa_core.inclusion_limits import LimitScope, _load, _resolve + + csv = tmp_path / "limits.csv" + csv.write_text( + "code,species,production_system,stage_weight,processing_method," + "min_inclusion,max_inclusion\n" + "31605,African Catfish,,,,,0.10\n" + "30355,,,,extruded,,0.10\n" + ) + rows = _load(csv, "code", "min_inclusion", "max_inclusion") + tilapia = LimitScope("Nile Tilapia", "< 5g (Starter)", "General-LowCost") + catfish = LimitScope("African Catfish", "< 5g (Starter)", "General", "extruded") + assert _resolve(rows, tilapia) == {} + assert set(_resolve(rows, catfish)) == {"31605", "30355"} + + +def test_limits_csv_rejects_an_inverted_bound(tmp_path): + from fasa_core.inclusion_limits import _load + + csv = tmp_path / "limits.csv" + csv.write_text( + "code,species,production_system,stage_weight,processing_method," + "min_inclusion,max_inclusion\n" + "31605,,,,,0.40,0.10\n" + ) + with pytest.raises(ValueError, match="above maximum"): + _load(csv, "code", "min_inclusion", "max_inclusion") + + +def test_request_limit_models_reject_invalid_pairs(): + from pydantic import ValidationError + + from fasa_core.models import IngredientInclusionLimit, NutrientLimit + + with pytest.raises(ValidationError): + IngredientInclusionLimit(max=1.5) # above 100% of feed + with pytest.raises(ValidationError): + IngredientInclusionLimit(min=0.4, max=0.1) # inverted + with pytest.raises(ValidationError): + NutrientLimit(min=-1.0) # negative nutrient level + + +# --------------------------------------------------------------------------- # +# /validate-recipe: composition + inclusion-rate checks # +# --------------------------------------------------------------------------- # + + +def _lp_fractions(**overrides): + """The LP's own recipe as a fractions dict, via the 4-dp percentages it publishes.""" + res = formulate(**{**BASE, **overrides}) + if res.status != "optimal": + pytest.skip(f"LP did not solve to optimal (status={res.status})") + return {line.code: line.inclusion_percent / 100.0 for line in res.recipe} + + +def test_validate_recipe_accepts_the_lp_solution_as_in_spec(): + """A recipe /formulate just returned must not read as non-compliant. + + Guards the `in_spec` tolerance: constraints the LP drives to exactly binding + (and the DP/DE ratio, whose linearized target is 0) lose ~1e-5 when the + published 4-decimal inclusion_percent values are fed back in. + """ + from fasa_core.validator import validate_recipe + + res = validate_recipe( + _lp_fractions(), + parameters=["crude_protein_percent"], + species=BASE["species"], + stage=BASE["stage"], + production_system=BASE["production_system"], + ) + off_spec = [line.code for line in res.nutrient_checks if not line.in_spec] + assert off_spec == [] + assert res.in_limits is True + assert res.nutrient_checks, "nutrient checks should run when the full scope is given" + assert 99.0 <= res.total_inclusion_percent <= 100.0 + + +def test_validate_recipe_still_catches_a_real_nutrient_violation(): + """The tolerance must not be loose enough to pass an implausible diet.""" + from fasa_core.validator import validate_recipe + + res = validate_recipe( + {"31621": 0.995}, # all wheat flour: nowhere near the protein/energy targets + parameters=[], + species=BASE["species"], + stage=BASE["stage"], + production_system=BASE["production_system"], + ) + violations = {line.code for line in res.nutrient_checks if not line.in_spec} + assert {"PA03", "ED02"} <= violations + assert res.in_limits is False + + +def test_validate_recipe_flags_an_inclusion_above_its_ceiling(): + """The colleague's case: 40% bran against a 15% ceiling.""" + from fasa_core.validator import validate_recipe + + fractions = {"31605": 0.40, "31621": 0.595} + res = validate_recipe(fractions, parameters=[], ingredient_limits={"31605": {"max": 0.15}}) + + bran = next(line for line in res.inclusion_checks if line.code == "31605") + assert bran.inclusion_percent == 40.0 + assert bran.max_inclusion_percent == 15.0 + assert bran.limit_source == "request" + assert bran.in_limits is False + assert res.in_limits is False + # the unbounded ingredient is reported too, and passes + other = next(line for line in res.inclusion_checks if line.code == "31621") + assert (other.max_inclusion_percent, other.limit_source, other.in_limits) == (None, None, True) + + +def test_validate_recipe_flags_an_inclusion_below_its_floor(): + from fasa_core.validator import validate_recipe + + res = validate_recipe( + {"10018": 0.01, "31621": 0.985}, + parameters=[], + ingredient_limits={"10018": {"min": 0.05}}, + ) + line = next(c for c in res.inclusion_checks if c.code == "10018") + assert (line.min_inclusion_percent, line.in_limits) == (5.0, False) + + +def test_validate_recipe_applies_a_nutrient_limit_tighter_than_asns(): + from fasa_core.validator import validate_recipe + + res = validate_recipe( + _lp_fractions(), + parameters=[], + species=BASE["species"], + stage=BASE["stage"], + production_system=BASE["production_system"], + nutrient_limits={"PA05": {"max": 0.5}}, # far below the achieved ~1.5% + ) + fibre = next(line for line in res.nutrient_checks if line.code == "PA05") + assert (fibre.target, fibre.source, fibre.in_spec) == (0.5, "asns+request", False) + assert res.in_limits is False + + +def test_validate_recipe_without_scope_checks_inclusions_only(): + """Backward compatible: the original payload still returns composition.""" + from fasa_core.validator import validate_recipe + + res = validate_recipe({"30355": 0.5, "31237": 0.495}, + parameters=["crude_protein_percent"]) + assert set(res.composition) == {"crude_protein_percent"} + assert res.nutrient_checks == [] + assert len(res.inclusion_checks) == 2 + assert res.in_limits is True + assert res.warnings == [] + + +def test_validate_recipe_warns_when_the_scope_is_incomplete(): + from fasa_core.validator import validate_recipe + + res = validate_recipe({"30355": 0.995}, parameters=[], species=BASE["species"]) + assert res.nutrient_checks == [] + assert any("nutrient checks need" in w for w in res.warnings) + + +def test_validate_recipe_warns_when_inclusions_do_not_form_a_diet(): + from fasa_core.validator import validate_recipe + + res = validate_recipe({"30355": 0.5}, parameters=[]) + assert res.total_inclusion_percent == 50.0 + assert any(w.startswith("[mass]") for w in res.warnings) + + +def test_validate_recipe_warns_on_codes_outside_the_ingredient_pool(): + """Such codes still reach `composition` but can hold no limit — say so.""" + from fasa_core.validator import validate_recipe + + res = validate_recipe({"30355": 0.5, "31621": 0.495, "10073": 0.0}, + parameters=[], ingredient_limits={"30355": {"max": 0.4}}) + assert not any(w.startswith("[skip]") for w in res.warnings) + + res = validate_recipe({"99999": 0.995}, parameters=[]) + assert any("99999" in w and w.startswith("[skip]") for w in res.warnings) + + +def test_validate_recipe_rejects_an_unknown_stage(): + from fasa_core.validator import validate_recipe + + with pytest.raises(ValueError, match="No ASNS constraints"): + validate_recipe({"30355": 0.995}, parameters=[], species="Nile Tilapia", + stage="not-a-stage", production_system="General")