diff --git a/CODE_WALKTHROUGH.md b/CODE_WALKTHROUGH.md index bf4f176..d07a9fd 100644 --- a/CODE_WALKTHROUGH.md +++ b/CODE_WALKTHROUGH.md @@ -1831,3 +1831,64 @@ Stage 3 специально держит это правило жёстким: - cooldown не скрывает нарушение качества; - journal пишет summary причин отбраковки; - selected повторно проходит constraints. + +## 22. Что добавил Stage 4 + +Stage 4 показывает связанную цепочку от прогноза гидроочистки к модельному +блендингу: + +```text +Hydrotreater forecast +-> apply_hydrotreater_forecast() +-> calculate_mass_blend() +-> sulfur_constraint_status() +-> rank_feasible_blends() +``` + +### `source/ml/blending.py` + +`HybridComponentForecast` описывает прогноз серы гидроочищенного компонента. В нём +есть point sulfur, upper sulfur, `source_state_id`, ссылка на входное качество и +диапазон транспортного лага. + +`apply_hydrotreater_forecast()` заменяет только один компонент смеси. Сейчас это +компонент `A` в `hybrid_blend`. + +`calculate_mass_blend()` считает серу смеси по массовым долям: + +```text +S_mix = sum(w_i * S_i) +``` + +Эта же формула применяется к `upper`, но это не статистический доверительный +интервал смеси. Это консервативная сценарная граница. + +`sulfur_constraint_status()` возвращает: + +- `pass`, если upper sulfur доступна и не выше лимита; +- `fail`, если нарушена сера или запас компонента; +- `unknown`, если upper sulfur отсутствует. + +`rank_feasible_blends()` сначала отбрасывает infeasible рецептуры, потом сортирует +оставшиеся по `(risk, -throughput, cost, change_size, id)`. + +### Gas Context + +`collect_gas_context()` берёт из `config/tags.csv` газовые теги `ht:F9`, `ht:F22`, +`ht:Q21` и возвращает их как `GasContextSignal`. + +Важное ограничение: `GasContextSignal.action_enabled` всегда `False`. Эти сигналы +можно показывать как технологический контекст, но нельзя рекомендовать менять как +уставки, пока не подтверждены единицы, диапазоны и модель эффекта. + +### `global_tests/test_stage4_blending.py` + +Тесты Stage 4 проверяют: + +- замену только нужного компонента прогнозом гидроочистки; +- массовый баланс point/upper sulfur; +- изменение feasible рецептур при изменении качества компонента; +- `UNKNOWN`, если нет upper sulfur; +- stock shortfall; +- запрет невалидных долей; +- context-only статус газовых тегов. diff --git a/PROJECT_GUIDE.md b/PROJECT_GUIDE.md index 60eee21..c28e0a7 100644 --- a/PROJECT_GUIDE.md +++ b/PROJECT_GUIDE.md @@ -1649,3 +1649,27 @@ Stage 3 делает backend-cycle строже. Он не добавляет ML Если следующий этап будет добавлять ML, он должен встроиться в этот контур, а не обходить его. ML может дать прогноз или оценку эффекта действия, но финальное решение всё равно должно пройти `check_constraints()` и журналирование. + +--- + +## 35. Что добавляет Stage 4 + +Stage 4 связывает прогноз гидроочистки с модельным блендингом. + +Практически это означает: + +- прогноз серы гидроочищенного компонента заменяет компонент `A`; +- рецептуры пересчитываются по массовому балансу; +- верхняя граница серы смешивается как консервативная сценарная граница; +- рецепты с нарушением серы, отсутствующей uncertainty или нехваткой запаса не + попадают в ranking; +- `T95`, цетановое число и полный паспорт товарного продукта остаются + `not_assessed`. + +Газовые теги `ht:F9`, `ht:F22`, `ht:Q21` теперь зафиксированы как gas context. +Это наблюдаемые технологические сигналы, а не безопасные controls. Их нельзя +рекомендовать менять, пока нет подтверждённых единиц, инженерных диапазонов, max step +и модели эффекта действия. + +В коде Stage 4 живёт в `source/ml/blending.py`, а regression-тесты - в +`global_tests/test_stage4_blending.py`. diff --git a/README.md b/README.md index eca42e5..77104dc 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - [Stage 1](STAGE1.md) — первый сквозной backend-цикл, demo-сценарии, журнал и ограничения этапа. - [Stage 2](STAGE2.md) — как оригинальные материалы превращаются в prepared dataset и `ProcessState`. - [Stage 3](STAGE3.md) — hard constraints, причины отбраковки кандидатов, materiality и cooldown. +- [Stage 4](STAGE4.md) — hybrid chain, model blending и газовые теги как context-only сигналы. - [Code walkthrough](CODE_WALKTHROUGH.md) — папки, файлы и хронология вызовов почти построчно. - [ML system design](DESIGN.md#8-ml-неопределённость-и-модель-последствий) — обучение, метрики, анализ ошибок и жизненный цикл модели; общие контракты и данные описаны в том же документе. - [Материалы задания](materials/README.md) — ТЗ, схемы и исходные данные. @@ -27,12 +28,19 @@ `ProcessState` для сценария `history`; - backend-срез stage 3: более строгий выбор `hold`/`recommend`/`abstain`, единые hard checks, причины отбраковки кандидатов, повторная проверка selected и trace - в журнале. + в журнале; +- stage 4: модельная связка гидроочистка -> блендинг, массовый баланс рецептур и + gas context для `ht:F9`, `ht:F22`, `ht:Q21` без включения реального управления газом. Полноценной ML-модели, промышленного управления реальными уставками и Streamlit UI пока нет. Текущий backend уже умеет готовить данные и запускать безопасный demo-каркас с guardrails, но не выдаёт промышленную рекомендацию на реальной истории. +Stage 4 показывает связанную цепочку и модельный блендинг. Газовые теги видны как +технологический контекст, но не становятся action controls: нет подтверждённых единиц, +диапазонов и модели эффекта. Полная товарная спецификация также не заявлена: +`T95`, цетановое число и весь паспорт продукта пока `not_assessed`. + ## Проверка Нужны Python 3.11, Git LFS и `tar` с поддержкой RAR. diff --git a/STAGE4.md b/STAGE4.md index 78d1994..03c7794 100644 --- a/STAGE4.md +++ b/STAGE4.md @@ -1,45 +1,82 @@ -# Stage 4: Гибридная цепочка и блендинг +# Stage 4: Hybrid Chain, Blending, Gas Context ## Результат -Добавлен изменяемый hybrid-сценарий: прогноз серы гидроочищенного компонента -заменяет компонент `A`, после чего рецептуры пересчитываются по массовому балансу. -Изменение входного прогноза меняет множество допустимых рецептур. +Stage 4 демонстрирует связанную цепочку: + +```text +history / hydrotreater forecast -> blend component A -> mass-balance recipes -> ranked feasible blends +``` + +Прогноз серы гидроочищенного компонента заменяет компонент `A` в hybrid/blend +сценарии. После этого рецептуры пересчитываются по массовому балансу. Изменение +point/upper sulfur входного прогноза меняет множество допустимых рецептур. + +Газовые теги гидроочистки теперь явно фиксируются как технологический контекст. Это +важно для понимания процесса, но Stage 4 не включает реальное управление газом: +нет подтверждённых единиц, инженерных диапазонов и модели эффекта действия. ## Реализовано - `HybridComponentForecast` хранит point/upper sulfur, `source_state_id`, ссылку на входное качество и явный диапазон транспортного лага. -- Сера смеси рассчитывается как `sum(w_i * S_i)`. Верхние границы смешиваются так - же, но помечены как консервативная сценарная граница без заявления о совместном - статистическом покрытии. -- Доли неотрицательны, обязаны содержать все компоненты и суммироваться в единицу. +- `apply_hydrotreater_forecast()` заменяет только соответствующий компонент смеси. +- Сера смеси считается как `sum(w_i * S_i)`. +- Верхние оценки серы смешиваются тем же массовым балансом, но помечаются как + консервативная сценарная граница без заявления о совместном статистическом покрытии. +- Доли рецептуры неотрицательные, содержат все компоненты и суммируются в единицу. - Расход компонента проверяется против доступного запаса. -- Двухкомпонентный grid имеет изменяемый шаг, детерминированный порядок и явный - предел размера; усечение запрещено. -- Недопустимые по сере, uncertainty или запасу рецептуры отбрасываются до - ранжирования `(risk, -throughput, cost, change_size, id)`. -- В `config/scenarios/hybrid_blend.json` компонент `A` оставлен пустым до runtime - замены прогнозом — отсутствие данных не превращается в ноль. +- Недопустимые по сере, uncertainty или запасу рецептуры отбрасываются до ranking. +- Ranking использует только feasible варианты и стабильный ключ + `(risk, -throughput, cost, change_size, id)`. +- Grid рецептур детерминированный, с явным пределом размера; silent truncation запрещён. +- `GasContextSignal` и `collect_gas_context()` возвращают газовые теги как context-only, + а не как action controls. + +## Gas Context + +Stage 4 отслеживает следующие газовые сигналы из `config/tags.csv`: + +| Signal | Meaning | Stage 4 status | +| --- | --- | --- | +| `ht:F9` | газ поддува / массовый расход | context only | +| `ht:F22` | газ поддува / связанный технологический сигнал | context only | +| `ht:Q21` | газ поддува | context only | + +Для всех этих сигналов: + +- `action_enabled=false`; +- причина фиксируется как `context_only`; +- они не попадают в `ScenarioConfig.controls`; +- backend не рекомендует менять их значения. + +Если позже эксперт подтвердит единицы, диапазоны, max step и модель эффекта, газ можно +будет рассматривать как отдельный action/control этап. Сейчас это только наблюдаемый +контекст процесса. -## Честные границы +## Честные Границы -- Связь качества АВТ с подачей на гидроочистку и лаг `0–180` минут пока являются +- Связь качества АВТ с подачей на гидроочистку и лаг `0-180` минут остаются модельным предположением: подтверждённого flow mapping нет. -- Реальная история рецептур и запасов не предоставлена, поэтому блендинг остаётся +- Реальная история рецептур и запасов не предоставлена, поэтому blending остаётся модельным сценарием. -- Проверяются только сера и запас. `T95` и цетановое число обязательны для товарного - продукта, но не смешиваются линейно без физического или эмпирического основания. - Результат всегда имеет `full_specification_status="not_assessed"`. +- Проверяются только сера и запас компонента. +- `T95`, цетановое число и полная товарная спецификация не оценены: + `full_specification_status="not_assessed"`. +- Отсутствующая верхняя граница серы даёт `UNKNOWN`, а не `PASS`. +- Газовые сигналы не являются безопасными уставками. ## Проверка ```powershell .\.venv\Scripts\python.exe -m pytest global_tests\test_stage4_blending.py -q -.\.venv\Scripts\ruff.exe check source\ml\blending.py global_tests\test_stage4_blending.py -.\.venv\Scripts\mypy.exe source\ml\blending.py +.\.venv\Scripts\python.exe -m pytest global_tests\test_stage3_guardrails.py -q +.\.venv\Scripts\python.exe -m pytest +.\.venv\Scripts\python.exe -m ruff check . +.\.venv\Scripts\python.exe -m mypy source +.\.venv\Scripts\python.exe -m source.main validate-stage0 ``` Acceptance-тесты покрывают provenance, массовый баланс point/upper, изменение допустимого множества рецептур, `UNKNOWN` при отсутствующей upper-границе, запасы, -невалидные доли и отсутствие ложного заявления о полной спецификации. +невалидные доли, ranking только feasible вариантов и context-only статус газовых тегов. diff --git a/global_tests/test_stage4_blending.py b/global_tests/test_stage4_blending.py index 661b5e0..466640c 100644 --- a/global_tests/test_stage4_blending.py +++ b/global_tests/test_stage4_blending.py @@ -4,12 +4,15 @@ import pytest -from source.config import load_scenario +from source.config import load_scenario, load_tag_dictionary from source.contracts import ConstraintStatus, EstimateBasis, IntervalKind, MetricEstimate from source.ml.blending import ( + GAS_CONTEXT_REASON, + GAS_CONTEXT_SIGNAL_IDS, HybridComponentForecast, apply_hydrotreater_forecast, calculate_mass_blend, + collect_gas_context, enumerate_two_component_recipes, rank_feasible_blends, sulfur_constraint_status, @@ -117,3 +120,15 @@ def test_stock_and_recipe_validation_are_enforced() -> None: calculate_mass_blend({"A": 0.8, "B": 0.1}, components, 100.0) with pytest.raises(ValueError, match="nonnegative"): calculate_mass_blend({"A": 1.1, "B": -0.1}, components, 100.0) + + +def test_gas_tags_are_context_only_not_action_controls() -> None: + tags = load_tag_dictionary("config/tags.csv") + + context = collect_gas_context(tags) + + assert tuple(item.signal_id for item in context) == GAS_CONTEXT_SIGNAL_IDS + assert all(not item.action_enabled for item in context) + assert all(item.reason == GAS_CONTEXT_REASON for item in context) + assert all(tags[item.signal_id].controllable is False for item in context) + assert {item.unit for item in context} == {"unknown"} diff --git a/source/ml/__init__.py b/source/ml/__init__.py index 3f9991f..f455bbf 100644 --- a/source/ml/__init__.py +++ b/source/ml/__init__.py @@ -1,37 +1,74 @@ -"""ML layer: formulas, leakage-safe sulfur features, training and artifacts.""" - -from source.ml.artifacts import ModelBundle, load_model -from source.ml.evaluate import evaluate_model -from source.ml.features import ( - FeatureFrame, - SupervisedDataset, - build_features, - build_supervised_dataset, -) -from source.ml.formulas import ( - EXPERT_VAK_CORRECTIONS, - SHEET_LA, - SHEET_VAK, - apply_expert_vak_corrections, - load_lab_parameters, - load_vak_formulas, -) -from source.ml.train import TrainingResult, train_model - -__all__ = [ - "EXPERT_VAK_CORRECTIONS", - "FeatureFrame", - "ModelBundle", - "SHEET_LA", - "SHEET_VAK", - "SupervisedDataset", - "TrainingResult", - "apply_expert_vak_corrections", - "build_features", - "build_supervised_dataset", - "evaluate_model", - "load_lab_parameters", - "load_model", - "load_vak_formulas", - "train_model", -] +"""ML layer facade with lazy exports for optional training dependencies.""" + +from __future__ import annotations + +from importlib import import_module + +_EXPORT_MODULES = { + "EXPERT_VAK_CORRECTIONS": "source.ml.formulas", + "GAS_CONTEXT_SIGNAL_IDS": "source.ml.blending", + "BlendOption": "source.ml.blending", + "BlendResult": "source.ml.blending", + "FeatureFrame": "source.ml.features", + "GasContextSignal": "source.ml.blending", + "HybridComponentForecast": "source.ml.blending", + "ModelBundle": "source.ml.artifacts", + "SHEET_LA": "source.ml.formulas", + "SHEET_VAK": "source.ml.formulas", + "SupervisedDataset": "source.ml.features", + "TrainingResult": "source.ml.train", + "apply_expert_vak_corrections": "source.ml.formulas", + "apply_hydrotreater_forecast": "source.ml.blending", + "build_features": "source.ml.features", + "build_supervised_dataset": "source.ml.features", + "calculate_mass_blend": "source.ml.blending", + "collect_gas_context": "source.ml.blending", + "enumerate_two_component_recipes": "source.ml.blending", + "evaluate_model": "source.ml.evaluate", + "load_lab_parameters": "source.ml.formulas", + "load_model": "source.ml.artifacts", + "load_vak_formulas": "source.ml.formulas", + "rank_feasible_blends": "source.ml.blending", + "sulfur_constraint_status": "source.ml.blending", + "train_model": "source.ml.train", +} + + +def __getattr__(name: str) -> object: + """Load facade exports only when they are requested.""" + module_name = _EXPORT_MODULES.get(name) + if module_name is None: + raise AttributeError(f"module 'source.ml' has no attribute {name!r}") + value = getattr(import_module(module_name), name) + globals()[name] = value + return value + + +__all__ = [ + "EXPERT_VAK_CORRECTIONS", + "GAS_CONTEXT_SIGNAL_IDS", + "BlendOption", + "BlendResult", + "FeatureFrame", + "GasContextSignal", + "HybridComponentForecast", + "ModelBundle", + "SHEET_LA", + "SHEET_VAK", + "SupervisedDataset", + "TrainingResult", + "apply_expert_vak_corrections", + "apply_hydrotreater_forecast", + "build_features", + "build_supervised_dataset", + "calculate_mass_blend", + "collect_gas_context", + "enumerate_two_component_recipes", + "evaluate_model", + "load_lab_parameters", + "load_model", + "load_vak_formulas", + "rank_feasible_blends", + "sulfur_constraint_status", + "train_model", +] diff --git a/source/ml/blending.py b/source/ml/blending.py index 5b180a1..c1de5d9 100644 --- a/source/ml/blending.py +++ b/source/ml/blending.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from typing import Literal @@ -11,10 +12,16 @@ EstimateBasis, IntervalKind, MetricEstimate, + Stage, + TagMeta, Unit, ) FRACTION_TOLERANCE = 1e-9 +GAS_CONTEXT_SIGNAL_IDS = ("ht:F9", "ht:F22", "ht:Q21") +GAS_CONTEXT_REASON = ( + "context_only: gas signals are observed process context, not enabled action controls" +) @dataclass(frozen=True) @@ -82,6 +89,44 @@ def rank_key(self) -> tuple[float, float, float, float, str]: ) +@dataclass(frozen=True) +class GasContextSignal: + """Observed gas signal metadata that is deliberately not an action control.""" + + signal_id: str + meaning: str + unit: str + evidence_ref: str + action_enabled: Literal[False] + reason: str + + +def collect_gas_context( + tag_dictionary: Mapping[str, TagMeta], + signal_ids: tuple[str, ...] = GAS_CONTEXT_SIGNAL_IDS, +) -> tuple[GasContextSignal, ...]: + """Return known hydrotreatment gas tags as context-only process signals.""" + by_signal_id = {tag.signal_id: tag for tag in tag_dictionary.values()} + result: list[GasContextSignal] = [] + for signal_id in signal_ids: + tag = by_signal_id.get(signal_id) + if tag is None: + continue + if tag.stage is not Stage.HYDROTREATMENT: + raise ValueError(f"gas context signal must belong to hydrotreatment: {signal_id}") + result.append( + GasContextSignal( + signal_id=tag.signal_id, + meaning=tag.meaning, + unit=tag.canonical_unit, + evidence_ref=tag.evidence_ref, + action_enabled=False, + reason=GAS_CONTEXT_REASON, + ) + ) + return tuple(result) + + def apply_hydrotreater_forecast( components: tuple[BlendComponent, ...], forecast: HybridComponentForecast ) -> tuple[BlendComponent, ...]: @@ -265,9 +310,13 @@ def rank_feasible_blends( __all__ = [ "BlendOption", "BlendResult", + "GAS_CONTEXT_REASON", + "GAS_CONTEXT_SIGNAL_IDS", + "GasContextSignal", "HybridComponentForecast", "apply_hydrotreater_forecast", "calculate_mass_blend", + "collect_gas_context", "enumerate_two_component_recipes", "rank_feasible_blends", "sulfur_constraint_status",