diff --git a/README.md b/README.md index 3a533d8..d91abf8 100644 --- a/README.md +++ b/README.md @@ -197,16 +197,20 @@ Per-job payload fields: `json_for_advice_generation.date_of_today` is oggi for this advisory (the run's `as_of`, formatted `DD-MM-YYYY`). `last_advice.date` is the previous advisory's issuance date, in the same format. `last_applied_treatment` is omitted unless -`allowed_products` is non-empty, no spray appears in the meteorological window -(`as_of-5 .. as_of`), and a spray exists in `as_of-25 .. as_of-6`; when present -it is `{ "date": "", "products": [...] }` at the end of the payload. -A product already described under `allowed_products` or +no spray appears in the meteorological window (`as_of-5 .. as_of`) and a spray +exists in `as_of-25 .. as_of-6`, including when `allowed_products` is empty; +when present it is `{ "date": "", "products": [...] }` at the end of +the payload. A product already described under `allowed_products` or `last_advice.suggested_products` is listed by name; any other product is the full label object. When a treatment is recommended and `allowed_products` is non-empty, the advice LLM reuses `last_advice.suggested_products` if those products remain appropriate, even when they are not in `allowed_products`; otherwise it is restricted to `allowed_products`. If `allowed_products` is -empty, nothing is prescribed. +empty, nothing is prescribed. The advisory explains that the model shows no +infection risk, that the last spray still covers the crop (or that its cover +is ending), or that a new treatment cannot be applied yet because the minimum +interval since the last treatment has not elapsed; it does not say that the +product list was empty. If a field's 11-day window has no `FASE2`, `FASE5`, or `INCUBAZPRIMARIA` day and no true `observation` day (`as_of-5 .. as_of`), the product search is skipped @@ -363,7 +367,7 @@ Run once per `(field, disease)` job by `pipeline/job.py::run_job`: 3. **Observation** — `AI_agrosupport_agro_ril_pathogen` (`rilpato_layer`/`rilpato_date`/`rilpato_diffusion`); `observation` is true for `as_of` or any of the past 5 days if a row exists there with `rilpato_diffusion` other than `32` (including `NULL`) 4. **Weather** — `TDatiMeteo_D` for past days, `TDatiMeteo_D_FRC` for today/future 5. **Phenology** — carry-forward of latest observation ≤ day; future days null; if today's phase is missing, use yesterday's -6. **Applied treatments** — one query loads crop-protection sprays over `as_of-25 .. as_of` from `AI_agrosupport_agro_ril_operations*` with `rilop_operation = 10`. Days in `as_of-5 .. as_of` fill meteorological `applied_treatment`. The latest spray in the full 25-day window drives the prefilter rotation gate. `last_applied_treatment` still uses only `as_of-25 .. as_of-6`, and only when the recent window is empty. +6. **Applied treatments** — one query loads crop-protection sprays over `as_of-25 .. as_of` from `AI_agrosupport_agro_ril_operations*` with `rilop_operation = 10`. Days in `as_of-5 .. as_of` fill meteorological `applied_treatment`. If the latest spray in the full 25-day window still has a non-NULL `product_uses.treatment_interval_min_days` strictly greater than days elapsed (any tank-mix partner), query synthesis, prefilter, and vector search are skipped and `allowed_products` stays empty. `last_applied_treatment` still uses only `as_of-25 .. as_of-6`, and only when the recent window is empty. 7. **LLM** — synthesise 1–2 anonymised search queries from the subset 8. **Product prefilter** — organic / crop / disease gates, then one prefilter rule against the batch-wide, in-memory `products` index (`pipeline/stages/disease.py::select_prefilter_rule`, first match wins): @@ -377,10 +381,10 @@ Run once per `(field, disease)` job by `pipeline/job.py::run_job`: | `FASE5` today or in the past only | `systemicity` in `{contact, mixed}` and `preventive_action = 1` and `curative_action = 0` and `eradicant_action = 0` | | none of the above | no FASE-specific cut; organic/crop/disease gates only | - After those gates, if the latest spray in `as_of-25 .. as_of` still has a non-NULL `product_uses.treatment_interval_min_days` strictly greater than days elapsed, candidates whose `active_ingredients` overlap that product's (tank-mix partners evaluated per product) are dropped. NULL or elapsed interval leaves the list unchanged. + This stage is skipped (along with query synthesis and vector search) when the latest spray's min treatment interval is still open; see stage 6. 9. **Vector search** — Weaviate `near_text` on `ProductProfile`, always filtered with `contains_any` on the SQL-allowlisted `product_id`s (`product_id` must use `tokenization: field`). One synthesized query retrieves the top 4; two queries retrieve the top 2 unique each (the second search over-fetches so overlaps can still fill two products) 10. **Product JSON** — label details per product from `products`, `product_uses`, `label_chunks`, cached per `(crop, disease)` -11. **Context enrichment** — `allowed_products`, `applied_treatment` expansion, `last_advice` (with issuance `date`) from the `advice` table, and optional `last_applied_treatment` (spray date + products) when the recent window is empty but a spray exists in `as_of-25 .. as_of-6`. Enrichment reuses the job's 25-day treatment history; it does not query operations again. +11. **Context enrichment** — `allowed_products`, `applied_treatment` expansion, `last_advice` (with issuance `date`) from the `advice` table, and optional `last_applied_treatment` (spray date + products) when the recent window is empty but a spray exists in `as_of-25 .. as_of-6`, including when `allowed_products` is empty. Enrichment reuses the job's 25-day treatment history; it does not query operations again. 12. **Advice generation** — second LLM profile returns the structured advisory 13. **Persistence** — the advisory plus its collapsed input are written to `advice` inside one transaction diff --git a/config.yaml b/config.yaml index 090a805..e343897 100644 --- a/config.yaml +++ b/config.yaml @@ -26,7 +26,7 @@ llm: anthropic_model: claude-opus-4-5 gemini_model: gemini-2.5-pro max_tokens: 5120 - thinking_budget: 9216 + thinking_budget: 11264 temperature: 0.0 # ── Crop-first worklist ──────────────────────────────────────────────────────── diff --git a/pipeline/job.py b/pipeline/job.py index 1259052..84f8944 100644 --- a/pipeline/job.py +++ b/pipeline/job.py @@ -42,10 +42,7 @@ from pipeline.stages.disease import resolve_disease_forecast_by_anmod, select_pr from pipeline.stages.llm import synthesize_queries from pipeline.stages.observation import load_observation from pipeline.stages.phenology import load_phenology -from pipeline.stages.products import ( - excluded_active_ingredients_for_last_treatment, - prefilter_products, -) +from pipeline.stages.products import last_treatment_interval_open, prefilter_products from pipeline.stages.treatments import load_treatment_history from pipeline.stages.vector import RecommendedProduct from pipeline.stages.weather import load_weather @@ -167,7 +164,7 @@ def _run_job_inner(resources: Resources, job: Job, as_of: date, dry_run: bool) - product_index = resources.product_index if product_index is None: raise RuntimeError("load_product_index() must run before run_job().") - excluded_ais = excluded_active_ingredients_for_last_treatment( + interval_open = last_treatment_interval_open( conn, product_index, crop_english=job.crop_english, @@ -251,7 +248,13 @@ def _run_job_inner(resources: Resources, job: Job, as_of: date, dry_run: bool) - return payload recommended: list[RecommendedProduct] = [] - if has_risk: + if has_risk and interval_open: + logger.info( + "Field %s: last treatment interval still open; product search " + "skipped, advice generated without allowed products.", + job.field_id, + ) + elif has_risk: query_system_prompt = resources.prompts.query_prompt(job.crop_english, disease.disease_english) queries = synthesize_queries( settings, @@ -269,7 +272,6 @@ def _run_job_inner(resources: Resources, job: Job, as_of: date, dry_run: bool) - disease_english=disease.disease_english, organic=job.organic, rule=prefilter_rule, - excluded_active_ingredients=excluded_ais, ) payload["candidate_count"] = len(candidates) diff --git a/pipeline/stages/advice_context.py b/pipeline/stages/advice_context.py index 61c39fe..ac574d1 100644 --- a/pipeline/stages/advice_context.py +++ b/pipeline/stages/advice_context.py @@ -111,9 +111,10 @@ def enrich_advice_json( handling other fields for the same crop/disease pair (see `ProductJsonBuilder`), so it never stores a connection itself. - When allowed_products is non-empty, the meteorological window has no sprays, - and a spray exists in as_of-25 .. as_of-6, append last_applied_treatment - as the last key (date + products). Otherwise that key is omitted. + When the meteorological window has no sprays and a spray exists in + as_of-25 .. as_of-6, append last_applied_treatment as the last key + (date + products), including when allowed_products is empty. Otherwise + that key is omitted. `treatment_history` is the job's already-loaded 25-day operations window; this function does not query treatments again. """ @@ -158,8 +159,6 @@ def _maybe_add_last_applied_treatment( builder: ProductJsonBuilder, treatment_history: TreatmentHistory, ) -> None: - if not advice_json.get("allowed_products"): - return if _has_recent_applied_treatment(advice_json): return diff --git a/pipeline/stages/products.py b/pipeline/stages/products.py index 2d74080..ab8366f 100644 --- a/pipeline/stages/products.py +++ b/pipeline/stages/products.py @@ -5,7 +5,6 @@ from __future__ import annotations import logging from dataclasses import dataclass from datetime import date -from typing import Iterable import pyodbc @@ -136,11 +135,7 @@ class ProductIndex: return self._by_name.get(normalize_name(product_name)) -def _normalize_ingredient(value: str) -> str: - return value.strip().casefold() - - -def excluded_active_ingredients_for_last_treatment( +def last_treatment_interval_open( conn: pyodbc.Connection, index: ProductIndex, *, @@ -148,23 +143,23 @@ def excluded_active_ingredients_for_last_treatment( disease_english: str, latest_spray: tuple[date, list[str]] | None, as_of: date, -) -> frozenset[str]: +) -> bool: """ - Active ingredients still locked by the latest spray's treatment interval. + True if the latest spray is still inside its treatment interval. For each product in `latest_spray`, look up `product_uses.treatment_interval_min_days` - for this crop/disease. A product is locked if any matching use row has a + for this crop/disease. The interval is open if any matching use row has a non-NULL min interval strictly greater than days elapsed since the spray. - Only locked products contribute ingredients. Tank-mix partners are - evaluated independently. Names missing from `products` are skipped. + Tank-mix partners are evaluated independently: one locked partner opens + the interval for the whole spray. Names missing from `products` are skipped. """ if latest_spray is None: - return frozenset() + return False spray_date, product_names = latest_spray days_elapsed = (as_of - spray_date).days if days_elapsed < 0 or days_elapsed > LAST_TREATMENT_MAX_AGE_DAYS: - return frozenset() + return False resolved: list[ProductRow] = [] seen_ids: set[str] = set() @@ -173,7 +168,7 @@ def excluded_active_ingredients_for_last_treatment( if row is None: logger.warning( "Last applied product '%s' has no row in products; " - "it cannot contribute to the rotation gate.", + "it cannot contribute to the interval check.", name, ) continue @@ -183,7 +178,7 @@ def excluded_active_ingredients_for_last_treatment( resolved.append(row) if not resolved: - return frozenset() + return False product_ids = [row.product_id for row in resolved] placeholders = ",".join("?" * len(product_ids)) @@ -205,26 +200,17 @@ def excluded_active_ingredients_for_last_treatment( if min_days > days_elapsed: locked_ids.add(str(use_row.product_id)) - excluded: set[str] = set() - locked_names: list[str] = [] - for row in resolved: - if row.product_id not in locked_ids: - continue - locked_names.append(row.product_name) - for ingredient in row.active_ingredients: - key = _normalize_ingredient(str(ingredient)) - if key: - excluded.add(key) + if not locked_ids: + return False - if locked_names: - logger.info( - "Rotation gate: spray_date=%s days_elapsed=%s locked=%s excluded_ingredients=%d", - spray_date.isoformat(), - days_elapsed, - locked_names, - len(excluded), - ) - return frozenset(excluded) + locked_names = [row.product_name for row in resolved if row.product_id in locked_ids] + logger.info( + "Treatment interval still open: spray_date=%s days_elapsed=%s locked=%s", + spray_date.isoformat(), + days_elapsed, + locked_names, + ) + return True def prefilter_products( @@ -234,7 +220,6 @@ def prefilter_products( disease_english: str, organic: bool, rule: str | None, - excluded_active_ingredients: Iterable[str] | None = None, ) -> list[ProductCandidate]: """ Filter the (crop, disease) bucket by organic flag and the selected @@ -251,15 +236,7 @@ def prefilter_products( fase5_past -> systemicity in {contact, mixed} AND preventive_action = 1 AND curative_action = 0 AND eradicant_action = 0 None -> no FASE-specific cut; crop/disease/organic gates only - - When `excluded_active_ingredients` is non-empty, a candidate is also - dropped if any of its `active_ingredients` matches (case-insensitive). """ - blocked = { - _normalize_ingredient(str(item)) - for item in (excluded_active_ingredients or []) - if item is not None and str(item).strip() - } candidates: list[ProductCandidate] = [] for row in index.candidates_for(crop_english, disease_english): @@ -309,21 +286,14 @@ def prefilter_products( continue # rule is None: no FASE-specific cut, only the gates above apply. - if blocked and any( - _normalize_ingredient(str(ingredient)) in blocked - for ingredient in row.active_ingredients - ): - continue - candidates.append(row.as_candidate()) logger.info( - "Product prefilter: crop=%s disease=%s organic=%s rule=%s excluded_ais=%d -> %d candidates", + "Product prefilter: crop=%s disease=%s organic=%s rule=%s -> %d candidates", crop_english, disease_english, organic, rule, - len(blocked), len(candidates), ) return candidates diff --git a/pipeline/stages/treatments.py b/pipeline/stages/treatments.py index 7dac85e..b2cac0d 100644 --- a/pipeline/stages/treatments.py +++ b/pipeline/stages/treatments.py @@ -76,7 +76,7 @@ class TreatmentHistory: return self._window(start, self.as_of) def latest(self) -> tuple[date, list[str]] | None: - """Most recent spray in as_of-25 .. as_of, or None. Used by the rotation gate.""" + """Most recent spray in as_of-25 .. as_of, or None. Used by the interval skip.""" start = self.as_of - timedelta(days=LAST_TREATMENT_MAX_AGE_DAYS) window = self._window(start, self.as_of) if not window: @@ -104,7 +104,7 @@ def load_treatment_history( Load every crop-protection spray on the field over as_of-25 .. as_of. Callers slice the result: recent() for meteorological applied_treatment, - latest() for the rotation gate, latest_before_recent() for + latest() for the interval skip, latest_before_recent() for last_applied_treatment. Days without a treatment are absent from by_day. """ start = as_of - timedelta(days=LAST_TREATMENT_MAX_AGE_DAYS) diff --git a/prompts/_output_format.md b/prompts/_output_format.md index 7e2ea91..5820f53 100644 --- a/prompts/_output_format.md +++ b/prompts/_output_format.md @@ -14,7 +14,47 @@ introduction, no commentary outside the JSON. If `allowed_products` is empty, you must not prescribe any product (`treatments` must be `[]` and `apply_treatment` must be `0`), even if -`last_advice.suggested_products` names one. +`last_advice.suggested_products` names one. Do not mention that the list is +empty, that authorization data is missing, or that you cannot choose a product +for that reason. Do not quote JSON keys such as `allowed_products`. Explain the +agronomic reason in Italian, in both `full_advice` and `advice_summary`. These +sentences describe residual cover, the minimum interval since the last +treatment, or the absence of model risk; they are not a future-treatment or +dosage discussion. + +Read the latest spray from meteorological `applied_treatment` (the past 5 days) +or, when that window has no spray, from `last_applied_treatment`. Persistence is +`treatment_interval_min_days`–`treatment_interval_max_days` on the use whose +minimum is still greater than the days from the spray date to `date_of_today`. +If several uses qualify, cite the one with the longest remaining minimum. If +`treatment_interval_max_days` is null, cite the minimum only. + +- No model risk: the 11-day window has no `FASE2`, `FASE5`, or `INCUBAZPRIMARIA`, + and no `observation` is true. Say a treatment is not necessary because the + model does not indicate infection risk. +- Minimum interval still open: days elapsed are less than + `treatment_interval_min_days`. Say that, because of the minimum interval + required since the last treatment, a new treatment cannot be applied yet. + Cite the spray date and that minimum in days. Say this together with the + cover sentence below; do not prescribe a product. +- Cover still open: days elapsed are less than `treatment_interval_min_days` and + more than two days remain. Say the crop should be considered protected and + cite the persistence range. +- Cover ending: two days or fewer remain before `treatment_interval_min_days`, + or the minimum has already elapsed while the model still shows no risk. Say + protective efficacy is coming to an end (or has ended) and cite the spray as + a calendar date (for example `12 aprile 2026`). Still prescribe nothing. +- Catalog gap: `disease_forecast` or `observation` shows risk, and no spray + interval is still open. Do not claim the crop is protected and do not invent + a product name. State the model risk without blaming an empty list. + `apply_treatment` stays `0`. + +Follow the tone of these examples; do not copy them when the dates or the +persistence differ: + +- *Tuttavia, considerata la recente applicazione di un prodotto con una persistenza di 8–10 giorni, la coltura è da ritenersi protetta.* +- *Considerato che l'ultimo trattamento è stato eseguito il 12 aprile 2026, la sua efficacia protettiva sta volgendo al termine.* +- *A causa dell'intervallo minimo richiesto dall'ultimo trattamento, non è ancora possibile applicarne uno nuovo.* When you do recommend a treatment (`apply_treatment` = 1) and `allowed_products` is non-empty: if `last_advice.suggested_products` names one or more products diff --git a/prompts/advice/_default/system.md b/prompts/advice/_default/system.md index 33b7b4d..ea25274 100644 --- a/prompts/advice/_default/system.md +++ b/prompts/advice/_default/system.md @@ -1,5 +1,5 @@ You are a decision support system for crop protection, advising on the risk of a plant disease for a specific crop. Your goal is to prevent infection while avoiding unnecessary treatments. You will be given the crop's phenology using the BBCH phases, weather data from the past five days, forecasts for the next five days, the deterministic phytopathological model's day-by-day risk phase output (for example FASE2 / FASE5, where a higher phase number generally indicates a more advanced and more urgent stage of infection risk), and a list of authorized treatment products with their active substances and label directives. -STRICT RULES: 1. Base your interpretation strictly on the model's output; do not speculate beyond it. 2. If no treatment is currently necessary, do not discuss a hypothetical future treatment or dosage. +STRICT RULES: 1. Base your interpretation strictly on the model's output; do not speculate beyond it. 2. If no treatment is currently necessary, do not discuss a hypothetical future treatment or dosage. Do discuss residual protection of a product already applied, using its persistence and application date, and say when the minimum interval since that treatment means a new one cannot be applied yet. NOTE FOR OPERATORS: this is the generic fallback advice prompt. It is used because no dedicated prompt exists yet under `prompts/advice/__/` for this crop-disease pair — see the pipeline README for how to add one. Consider writing a pair-specific prompt that explains this disease's own phase semantics, the way `prompts/advice/grapevine__downy_mildew/system.md` explains FASE2/FASE5 for downy mildew, before relying on this fallback in production. diff --git a/prompts/advice/grapevine__downy_mildew/system.md b/prompts/advice/grapevine__downy_mildew/system.md index 6642850..45711cd 100644 --- a/prompts/advice/grapevine__downy_mildew/system.md +++ b/prompts/advice/grapevine__downy_mildew/system.md @@ -6,4 +6,4 @@ In FASE2, the germination of Plasmopara viticola oospores involves the formation INCUBAZPRIMARIA is the progress percentage of the incubation process associated with a specific infection event. Incubation begins only when the temperature conditions required for the infection to establish are met, progressing gradually until reaching 100%, at which point symptom onset is expected. In the absence of suitable conditions, the incubation process may fail to start or may be interrupted. If the value for the disease_forecast key in the JSON is INCUBAZPRIMARIA_40, the 40 after the underscore represents 40%. If observation = TRUE, it represents direct field evidence of infection and the risk of further spread, as observed on-site by the farmer or technician. ADDITIONAL NOTES: Main grapevine phenology phases are: BBCH < 53: leaf development, BBCH < 60: inflorescence emerge; BBCH < 70: flowering BBCH < 80: fruits development, BBCH < 90: ripening. You are smart, wise, nice, honest, and determined to promote Integrated Pest Management practices. ho cambiato idea, scrivilo in Italiano. -STRICT RULES: 1. Stick only to the output of the model for any interpreting or explanation. 2. If there is no necessity for a treatment, don't discuss the probable future situation that may need a treatment or the dosage. +STRICT RULES: 1. Stick only to the output of the model for any interpreting or explanation. 2. If there is no necessity for a treatment, don't discuss the probable future situation that may need a treatment or the dosage. Do discuss residual protection of a product already applied, using its persistence and application date, and say when the minimum interval since that treatment means a new one cannot be applied yet. diff --git a/tests/test_advice_context.py b/tests/test_advice_context.py index d92932a..a5c01c5 100644 --- a/tests/test_advice_context.py +++ b/tests/test_advice_context.py @@ -65,7 +65,7 @@ def _enrich( class LastAppliedTreatmentTests(unittest.TestCase): - def test_omits_when_allowed_products_empty_and_does_not_query(self) -> None: + def test_includes_older_spray_when_allowed_products_empty_and_does_not_query(self) -> None: advice_json = _payload() with patch("pipeline.stages.treatments.fetch_all") as fetch: _enrich( @@ -74,8 +74,11 @@ class LastAppliedTreatmentTests(unittest.TestCase): treatment_history=_history("LIETO"), ) fetch.assert_not_called() - self.assertNotIn("last_applied_treatment", advice_json) self.assertEqual(advice_json["allowed_products"], []) + self.assertEqual( + advice_json["last_applied_treatment"], + {"date": "10-04-2026", "products": [{"LIETO": {"uses": []}}]}, + ) def test_omits_when_recent_applied_treatment_present(self) -> None: advice_json = _payload(applied=["LIETO"]) diff --git a/tests/test_products.py b/tests/test_products.py index 6035ed0..0e90476 100644 --- a/tests/test_products.py +++ b/tests/test_products.py @@ -1,4 +1,4 @@ -"""Unit tests for the treatment-interval active-ingredient rotation gate.""" +"""Unit tests for the treatment-interval skip helper.""" from __future__ import annotations @@ -7,12 +7,7 @@ from datetime import date from types import SimpleNamespace from unittest.mock import MagicMock, patch -from pipeline.stages.products import ( - ProductIndex, - ProductRow, - excluded_active_ingredients_for_last_treatment, - prefilter_products, -) +from pipeline.stages.products import ProductIndex, ProductRow, last_treatment_interval_open _AS_OF = date(2026, 4, 16) _SPRAY_DATE = date(2026, 4, 6) # 10 days before as_of @@ -24,39 +19,33 @@ def _product( *, product_id: str = "p1", product_name: str = "LIETO", - active_ingredients: list[str] | None = None, - systemicity: str = "contact", - preventive_action: bool = True, - curative_action: bool = False, - eradicant_action: bool = False, - organic_certified: bool = False, ) -> ProductRow: return ProductRow( product_id=product_id, product_name=product_name, registration_number=None, - organic_certified=organic_certified, - systemicity=systemicity, - preventive_action=preventive_action, - curative_action=curative_action, - eradicant_action=eradicant_action, + organic_certified=False, + systemicity="contact", + preventive_action=True, + curative_action=False, + eradicant_action=False, target_crops=[_CROP], target_diseases=[_DISEASE], - active_ingredients=active_ingredients or ["mancozeb"], + active_ingredients=["mancozeb"], frac_groups=[], ) -def _excluded( +def _interval_open( index: ProductIndex, *, names: list[str], uses: list[SimpleNamespace], spray_date: date = _SPRAY_DATE, as_of: date = _AS_OF, -) -> frozenset[str]: +) -> bool: with patch("pipeline.stages.products.fetch_all", return_value=uses) as fetch: - result = excluded_active_ingredients_for_last_treatment( + result = last_treatment_interval_open( MagicMock(), index, crop_english=_CROP, @@ -68,11 +57,11 @@ def _excluded( return result -class ExcludedActiveIngredientsTests(unittest.TestCase): - def test_none_spray_returns_empty_and_does_not_query(self) -> None: +class LastTreatmentIntervalOpenTests(unittest.TestCase): + def test_none_spray_is_closed_and_does_not_query(self) -> None: index = ProductIndex([_product()]) with patch("pipeline.stages.products.fetch_all") as fetch: - result = excluded_active_ingredients_for_last_treatment( + result = last_treatment_interval_open( MagicMock(), index, crop_english=_CROP, @@ -81,91 +70,88 @@ class ExcludedActiveIngredientsTests(unittest.TestCase): as_of=_AS_OF, ) fetch.assert_not_called() - self.assertEqual(result, frozenset()) + self.assertFalse(result) - def test_null_interval_returns_empty(self) -> None: + def test_null_interval_is_closed(self) -> None: index = ProductIndex([_product()]) - result = _excluded( - index, - names=["LIETO"], - uses=[SimpleNamespace(product_id="p1", treatment_interval_min_days=None)], + self.assertFalse( + _interval_open( + index, + names=["LIETO"], + uses=[SimpleNamespace(product_id="p1", treatment_interval_min_days=None)], + ) ) - self.assertEqual(result, frozenset()) - def test_interval_less_or_equal_days_elapsed_does_not_lock(self) -> None: + def test_interval_less_or_equal_days_elapsed_is_closed(self) -> None: index = ProductIndex([_product()]) - equal = _excluded( + equal = _interval_open( index, names=["LIETO"], uses=[SimpleNamespace(product_id="p1", treatment_interval_min_days=10)], ) - shorter = _excluded( + shorter = _interval_open( index, names=["LIETO"], uses=[SimpleNamespace(product_id="p1", treatment_interval_min_days=7)], ) - self.assertEqual(equal, frozenset()) - self.assertEqual(shorter, frozenset()) + self.assertFalse(equal) + self.assertFalse(shorter) - def test_interval_greater_than_days_elapsed_locks_ingredients(self) -> None: - index = ProductIndex([_product(active_ingredients=["Mancozeb", "copper"])]) - result = _excluded( - index, - names=["LIETO"], - uses=[SimpleNamespace(product_id="p1", treatment_interval_min_days=14)], - ) - self.assertEqual(result, frozenset({"mancozeb", "copper"})) - - def test_any_qualifying_use_row_locks_the_product(self) -> None: + def test_interval_greater_than_days_elapsed_is_open(self) -> None: index = ProductIndex([_product()]) - result = _excluded( - index, - names=["LIETO"], - uses=[ - SimpleNamespace(product_id="p1", treatment_interval_min_days=None), - SimpleNamespace(product_id="p1", treatment_interval_min_days=21), - ], + self.assertTrue( + _interval_open( + index, + names=["LIETO"], + uses=[SimpleNamespace(product_id="p1", treatment_interval_min_days=14)], + ) ) - self.assertEqual(result, frozenset({"mancozeb"})) - def test_tank_mix_only_locked_partner_contributes_ingredients(self) -> None: + def test_any_qualifying_use_row_opens_the_interval(self) -> None: + index = ProductIndex([_product()]) + self.assertTrue( + _interval_open( + index, + names=["LIETO"], + uses=[ + SimpleNamespace(product_id="p1", treatment_interval_min_days=None), + SimpleNamespace(product_id="p1", treatment_interval_min_days=21), + ], + ) + ) + + def test_tank_mix_one_locked_partner_opens_the_interval(self) -> None: index = ProductIndex( [ - _product( - product_id="p1", - product_name="LIETO", - active_ingredients=["mancozeb"], - ), - _product( - product_id="p2", - product_name="TWINGO", - active_ingredients=["metalaxyl"], - ), + _product(product_id="p1", product_name="LIETO"), + _product(product_id="p2", product_name="TWINGO"), ] ) - result = _excluded( - index, - names=["LIETO", "TWINGO"], - uses=[ - SimpleNamespace(product_id="p1", treatment_interval_min_days=14), - SimpleNamespace(product_id="p2", treatment_interval_min_days=None), - ], + self.assertTrue( + _interval_open( + index, + names=["LIETO", "TWINGO"], + uses=[ + SimpleNamespace(product_id="p1", treatment_interval_min_days=14), + SimpleNamespace(product_id="p2", treatment_interval_min_days=None), + ], + ) ) - self.assertEqual(result, frozenset({"mancozeb"})) - def test_catalog_miss_is_ignored_other_partner_still_locks(self) -> None: + def test_catalog_miss_is_ignored_other_partner_still_opens(self) -> None: index = ProductIndex([_product()]) - result = _excluded( - index, - names=["UNKNOWN", "LIETO"], - uses=[SimpleNamespace(product_id="p1", treatment_interval_min_days=14)], + self.assertTrue( + _interval_open( + index, + names=["UNKNOWN", "LIETO"], + uses=[SimpleNamespace(product_id="p1", treatment_interval_min_days=14)], + ) ) - self.assertEqual(result, frozenset({"mancozeb"})) def test_catalog_miss_only_does_not_query_uses(self) -> None: index = ProductIndex([_product()]) with patch("pipeline.stages.products.fetch_all") as fetch: - result = excluded_active_ingredients_for_last_treatment( + result = last_treatment_interval_open( MagicMock(), index, crop_english=_CROP, @@ -174,51 +160,7 @@ class ExcludedActiveIngredientsTests(unittest.TestCase): as_of=_AS_OF, ) fetch.assert_not_called() - self.assertEqual(result, frozenset()) - - -class PrefilterRotationGateTests(unittest.TestCase): - def test_drops_candidates_sharing_an_excluded_ingredient(self) -> None: - index = ProductIndex( - [ - _product( - product_id="a", - product_name="A", - active_ingredients=["mancozeb"], - ), - _product( - product_id="b", - product_name="B", - active_ingredients=["copper hydroxide"], - ), - ] - ) - candidates = prefilter_products( - index, - crop_english=_CROP, - disease_english=_DISEASE, - organic=False, - rule=None, - excluded_active_ingredients={"MANCOZEB"}, - ) - self.assertEqual([item.product_name for item in candidates], ["B"]) - - def test_empty_exclusion_set_leaves_candidates_unchanged(self) -> None: - index = ProductIndex( - [ - _product(product_id="a", product_name="A"), - _product(product_id="b", product_name="B", active_ingredients=["copper"]), - ] - ) - candidates = prefilter_products( - index, - crop_english=_CROP, - disease_english=_DISEASE, - organic=False, - rule=None, - excluded_active_ingredients=frozenset(), - ) - self.assertEqual([item.product_name for item in candidates], ["A", "B"]) + self.assertFalse(result) if __name__ == "__main__":