From 0b8d2599fa92659c61ed1eb93e637d63b829b5f9 Mon Sep 17 00:00:00 2001 From: MatveyVarfolomeev Date: Sat, 12 Sep 2026 19:11:24 +0300 Subject: [PATCH 01/10] Implement stage 6 acceptance workflow --- .github/workflows/ci.yml | 11 +- DESIGN.md | 26 +- IMPLEMENTATION_PLAN.md | 8 +- README.md | 84 ++-- STAGE6.md | 142 ++++--- config/demo_episodes.json | 32 ++ global_tests/test_stage6_acceptance.py | 180 ++++++--- requirements.lock.txt | 18 + source/acceptance.py | 405 +++++++++++++++++++ source/main.py | 519 +++++++++++++++---------- source/ml/evaluate.py | 7 +- source/ml/train.py | 19 +- source/ml/uncertainty.py | 7 +- 13 files changed, 1096 insertions(+), 362 deletions(-) create mode 100644 config/demo_episodes.json create mode 100644 requirements.lock.txt create mode 100644 source/acceptance.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 469eb8f..bc1596b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r requirements.lock.txt - name: Run Ruff (Formatting & Linting) run: | @@ -33,5 +33,10 @@ jobs: - name: Run mypy run: mypy source - - name: Run Pytest - run: pytest + - name: Run Pytest + run: pytest + + - name: Run deterministic acceptance episodes + run: | + python -m source.main validate-stage0 + python -m source.main acceptance --output reports/ci-acceptance diff --git a/DESIGN.md b/DESIGN.md index 7e9aed6..f4006ba 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2,8 +2,9 @@ Версия контракта: **1.0**. Статус: **частично реализованная спецификация**. -Документ содержит целевую архитектуру. Актуальный runtime: Stages 0–5 и Tkinter UI -для `model_demo`; исторический ML-артефакт ещё не подключён к UI. +Документ содержит целевую архитектуру. Актуальный runtime: Stages 0–6, Tkinter UI +для `model_demo` и CLI для frozen training/evaluation/replay. Исторический ML-артефакт +ещё не подключён к UI. Основания: [техническое задание](materials/ТЗ_нефтекод.docx), [материалы](materials/README.md), [поэтапный план](IMPLEMENTATION_PLAN.md). ТЗ определяет обязательные требования, этот документ — технические контракты, план — порядок реализации. При изменении контракта документ и тестовые примеры обновляются в том же PR. @@ -516,17 +517,20 @@ Metadata обязательно содержит: `model_id`, `schema_version`, ### Реализованные и целевые команды Команды выполняются из корня репозитория после активации совместимого окружения. -Сейчас реализованы `validate-stage0`, `run-model-demo`, `prepare`, `build-state`, -`train`, `evaluate`, `run-history` и `python -m source.ui`. `replay` остаётся -целевой командой; `run-history` закрывает один forecast-cycle. - -```bash -python -m pip install -r requirements.txt -git lfs pull +Реализованы `validate-stage0`, `run-model-demo`/`demo`, `prepare`, `build-state`, +`train`, `evaluate`, `replay`, `acceptance`, `verify-model-freeze`, `export-journal` +и `python -m source.ui`. `run-history` сохранён как legacy-алиас forecast-only +пути `replay` и требует явного `--trusted-model`. + +```bash +python -m pip install -r requirements.lock.txt +git lfs pull python -m source.main prepare --materials materials --config config/runtime.toml python -m source.main train --dataset data/processed/ --target-source pak -python -m source.main evaluate --dataset data/processed/ --model artifacts/models/ --split test +python -m source.main evaluate --dataset data/processed/ --model artifacts/models/ --source pak --split test +python -m source.main replay --dataset data/processed/ --model artifacts/models/ --scenario history --at 2026-01-15T12:00:00+03:00 python -m source.main run-history --dataset data/processed/ --model artifacts/models/ --trusted-model --as-of 2026-01-15T12:00:00+03:00 +python -m source.main acceptance --output reports/final-acceptance python -m source.main run-model-demo blend_risk python -m source.ui python -m pytest @@ -556,7 +560,7 @@ CI работает на малых синтетических fixtures без | Пропуск оценки при активном критерии | `unknown` не превращается в допустимость или нулевой риск | Backend + ML | | Очень выгодный, но недопустимый вариант | Экономика не отменяет жёсткое ограничение | Backend | | Отсутствует action capability | Нельзя менять уставки через обычный прогноз | ML + Backend | -| Три фиксированных сценария раздела 9 | `hold`, `recommend`, `abstain` с известными численными результатами | Оба | +| Три фиксированных сценария раздела 9 | `abstain` с известными серными counterfactuals и reason codes | Оба | | Исключение агента и ошибка записи | Техническая ошибка не замаскирована технологическим отказом | Backend | | Повтор запуска | Совпадают решение и численные оценки, кроме служебных ID/времени | Оба | diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 63a91fe..80caa22 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -6,10 +6,10 @@ Основа — [ТЗ из репозитория](materials/ТЗ_нефтекод.docx), [технологические схемы](materials/АВТ_схемы.pdf) и [исходные данные](materials/README.md). Архитектура, контракты и порядок совместной работы зафиксированы в [техническом дизайне](DESIGN.md). Блендинг включаем как **явно обозначенный модельный сценарий**, поскольку отдельных исторических рецептур в пакете не найдено. -Этот раздел фиксирует исходную точку плана. Актуальный статус: реализованы Stages 0–5, -включая preparation/state pipeline, safety guardrails, Stage-5 uncertainty layer и -локальный Tkinter UI для model-demo. Интеграция исторического артефакта в UI и action -model остаются следующими задачами. +Этот раздел фиксирует исходную точку плана. Актуальный статус: реализованы Stages 0–6, +включая preparation/state pipeline, safety guardrails, uncertainty, frozen evaluation, +приёмочные эпизоды и локальный Tkinter UI для model-demo. Интеграция исторического +артефакта в UI и action model остаются за границей версии 1. В данных: diff --git a/README.md b/README.md index 6edbd17..3e4df2a 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,18 @@ - [Stage 3](STAGE3.md) — hard constraints, причины отбраковки кандидатов, materiality и cooldown. - [Stage 4](STAGE4.md) — hybrid chain, model blending и газовые теги как context-only сигналы. - [Stage 5](STAGE5.md) — uncertainty, applicability, robustness и policy guardrails без action model. -- [Stage 6](STAGE6.md) — acceptance pack, reproducible demo checks and final handoff limits. -- [Stage 7](STAGE7.md) — trusted local history artifact serving through CLI. +- [Stage 6](STAGE6.md) — чистый запуск, frozen models, исторические метрики и приёмочная демонстрация. +- [Stage 7](STAGE7.md) — trusted local history artifact serving через CLI/UI в forecast-only режиме. - [Code walkthrough](CODE_WALKTHROUGH.md) — папки, файлы и хронология вызовов почти построчно. - [ML system design](DESIGN.md#8-ml-неопределённость-и-модель-последствий) — обучение, метрики, анализ ошибок и жизненный цикл модели; общие контракты и данные описаны в том же документе. - [Материалы задания](materials/README.md) — ТЗ, схемы и исходные данные. ## Текущее состояние проекта -Реализация дошла до Stage 7 и содержит desktop UI. Часть команд и возможностей в -дизайн-документе по-прежнему целевые; актуальный исполняемый контракт описан ниже. +Реализация дошла до Stage 6 и содержит desktop UI, воспроизводимое обучение, +историческую оценку, replay и приёмочную демонстрацию. Промышленная action model не +заявлена. Stage 7 forecast-only history path сохранён как legacy `run-history`; +актуальный исполняемый контракт описан ниже. Сейчас реализованы: @@ -37,10 +39,10 @@ gas context для `ht:F9`, `ht:F22`, `ht:Q21` без включения реального управления газом. - stage 5: empirical upper estimate для прогноза серы, applicability/OOD gate, robustness reporting и materiality/cooldown policy helpers без включения action model. -- stage 6: приемочный контур `accept-stage6`, воспроизводимый прогон трех model-demo - сценариев, проверка journal-файлов и явная фиксация ограничений финальной демонстрации. -- stage 7: CLI `run-history` для подготовленного historical dataset и явно доверенного - локального model artifact с проверкой metadata перед загрузкой `joblib`. +- stage 6: точные версии зависимостей, команды `train`/`evaluate`/`replay`, каталог + демонстрационных эпизодов, проверка frozen models и экспорт полного журнала. +- stage 7: legacy CLI `run-history` для подготовленного historical dataset и явно + доверенного локального model artifact с проверкой metadata перед загрузкой `joblib`. Полноценной промышленной ML-модели и управления реальными уставками пока нет. Доступен локальный desktop UI на Python: он запускает model-demo сценарии, показывает @@ -59,27 +61,30 @@ quality-agent сначала проверяет область применим upper sulfur. Missing/OOD/отсутствующий upper не превращаются в pass. Это не action model: backend по-прежнему не рекомендует реальные setpoint-изменения и не управляет газом. -Stage 6 упаковывает финальную приемку: команда `accept-stage6` валидирует конфигурацию, -прогоняет три model-demo сценария, проверяет ожидаемые `hold`/`recommend`/`abstain` -и наличие journal-файлов. +Stage 6 упаковывает финальную приемку: команда `acceptance` прогоняет три +зафиксированных model-demo эпизода, проверяет ожидаемый `abstain`, reason codes, +сохраняет полный ZIP журналов и fingerprint решения. -Stage 7 подключает history artifact serving через CLI: `run-history` загружает -prepared dataset, проверяет metadata доверенного artifact, строит serving features и -сохраняет обычный journal. Desktop UI имеет отдельный forecast-only экран для этого пути. +Stage 7/history path подключает artifact serving через CLI: `replay` и legacy +`run-history` загружают prepared dataset, проверяют metadata доверенного artifact, +строят serving features и сохраняют обычный journal. Desktop UI имеет отдельный +forecast-only экран для этого пути. ## Проверка -Нужны совместимое с проектом Python-окружение, Git LFS и `tar` с поддержкой RAR. -Локальный артефакт Stage 5 был собран в Python 3.12.3 со scikit-learn 1.9.0; перед -воспроизведением или переобучением нужно сверять версии из metadata артефакта. +Нужны Python 3.11 или 3.12, Git LFS и `tar` с поддержкой RAR. Для приёмочного запуска +используется точный набор прямых зависимостей из `requirements.lock.txt`. ```bash +git lfs install +git lfs pull python -m venv .venv -python -m pip install -r requirements.txt +python -m pip install --upgrade pip +python -m pip install -r requirements.lock.txt python -m source.main validate-stage0 -python -m source.main accept-stage6 python -m source.main train --dataset data/processed/ --target-source pak -python -m source.main evaluate --dataset data/processed/ --model artifacts/models/ --split test +python -m source.main evaluate --dataset data/processed/ --model artifacts/models/ --source pak --split test +python -m source.main acceptance --output reports/final-acceptance python -m source.main run-history --dataset data/processed/ --model artifacts/models/ --trusted-model --as-of 2026-01-15T09:00:00Z python -m pytest python -m pytest global_tests/test_stage7_history_serving.py @@ -106,12 +111,12 @@ python -m source.main run-model-demo blend_missing | Сценарий | Статус | Что показывает | | --- | --- | --- | -| `blend_normal` | `hold` | текущая модельная рецептура проходит доступные проверки, лишнее действие не создаётся | -| `blend_risk` | `recommend` | текущая рецептура нарушает sulfur limit, выбран безопасный synthetic blend | +| `blend_normal` | `abstain` | серная граница текущей смеси 9.4 мг/кг, но полный паспорт T95/CN не подтверждён | +| `blend_risk` | `abstain` | текущая серная граница 14.2 мг/кг, серный synthetic counterfactual A=90%/B=10% даёт 9.4 мг/кг, но не является советом оператору | | `blend_missing` | `abstain` | при нехватке обязательной серы система отказывается от рискованной рекомендации | -`recommend` в model-demo относится только к синтетическому блендингу. Реальные -setpoint-рекомендации для history остаются отключены. +Серные counterfactuals в model-demo относятся только к синтетическому блендингу. +Реальные setpoint-рекомендации для history остаются отключены. Stage 3 не меняет публичные demo-команды. Он делает внутренний выбор строже: infeasible-кандидаты не ранжируются, причины отказа сохраняются в журнале, selected @@ -164,6 +169,37 @@ python -m source.main build-state --dataset data/processed/ --scenar доступны к `as_of`. Если нужного сигнала нет или он устарел, это фиксируется в issues, а не заменяется придуманным значением. +## Stage 6: обучение, оценка и приёмка + +Обучение запускается только из чистого Git worktree; test не участвует в выборе модели: + +```bash +python -m source.main train --dataset data/processed/ --with-uncertainty +``` + +Сравнить frozen point model с persistence baseline на одинаковых timestamp: + +```bash +python -m source.main evaluate --dataset data/processed/ --model artifacts/models/ --source pak --split test +python -m source.main evaluate --dataset data/processed/ --model artifacts/models/ --source lims --split test +``` + +Воспроизвести историческую точку и отдельно прогнать фиксированные модельные эпизоды: + +```bash +python -m source.main replay --dataset data/processed/ --model artifacts/models/ --scenario history --at 2026-01-15T12:00:00+03:00 +python -m source.main acceptance --output reports/final-acceptance +python -m source.main verify-model-freeze +``` + +`acceptance` создаёт `summary.json`, каталоги полных запусков и `journals.zip`. Повторный +запуск требует нового output-каталога, поэтому ранее полученное доказательство не +перезаписывается. Один или несколько обычных журналов экспортируются отдельно: + +```bash +python -m source.main export-journal --run --output reports/journal-export.zip +``` + ## Данные Канонический исходник телеметрии — `materials/data.rar` в Git LFS. Распакованные CSV, diff --git a/STAGE6.md b/STAGE6.md index d818521..e886a64 100644 --- a/STAGE6.md +++ b/STAGE6.md @@ -1,73 +1,119 @@ -# Stage 6: acceptance and demonstration +# Stage 6: приёмка и демонстрация -> Current status: Stage 6 is an acceptance pack. It does not add a new ML model, -> action model, or real setpoint control. History artifact serving is available -> separately as forecast-only CLI/UI functionality. +## Что зафиксировано -## Purpose +Этап 6 не добавляет новую технологическую модель. Он фиксирует и проверяет уже +реализованный прототип: -Stage 6 turns the implemented prototype into a reproducible handoff package. A -second participant should be able to create an environment, run the documented -checks, reproduce the model-demo scenarios, inspect journals, and understand the -remaining limits without reading the whole codebase first. +- точные версии прямых зависимостей в `requirements.lock.txt`; +- явные CLI-команды подготовки, обучения, оценки и historical replay; +- модельные артефакты с `supports_actions=false` и проверяемыми SHA-256; +- три неизменяемых демонстрационных эпизода в `config/demo_episodes.json`; +- атомарный приёмочный отчёт и полный ZIP журналов; +- раздельное представление исторической точности и условного эффекта блендинга. -## What Is Implemented +`run-history` сохранён как legacy-алиас forecast-only replay. Он требует +`--trusted-model`, проверяет metadata локального artifact и не включает action controls. -- `python -m source.main accept-stage6` runs the final acceptance smoke. -- The command validates configs, contracts and fixtures through `validate_stage0()`. -- It runs `blend_normal`, `blend_risk` and `blend_missing` through the existing - deterministic model-demo cycle. -- It verifies the expected status for each scenario: `hold`, `recommend` and - `abstain`. -- It verifies that each run writes the required journal files: - `result.json`, `input.json`, `trace.jsonl` and `candidates.jsonl`. -- It prints a JSON summary with `passed`, `validation`, `scenarios`, - `environment`, `limitations` and `issues`. +## Чистый запуск -By default, acceptance journals are written under `runs/stage6/`. For tests or -clean demos, pass an explicit temporary directory: +Из корня репозитория: ```bash -python -m source.main accept-stage6 --run-dir .test_tmp/stage6-runs +git lfs install +git lfs pull +python -m venv .venv +python -m pip install --upgrade pip +python -m pip install -r requirements.lock.txt +python -m source.main validate-stage0 +python -m pytest +python -m source.main prepare --materials materials --config config/runtime.toml ``` -## Demonstration Flow +На Linux системный пакет Tk может называться `python3-tk`; он нужен только для окна UI, +но не для CLI и тестов ML. -Recommended clean-run sequence: +## Обучение без утечки финального test ```bash -python -m venv .venv -python -m pip install -r requirements.txt -python -m source.main validate-stage0 -python -m source.main accept-stage6 -python -m source.ui --smoke --scenario blend_risk -python -m pytest +python -m source.main train \ + --dataset data/processed/ \ + --target-source pak \ + --with-uncertainty ``` -Desktop UI remains available: +Команда требует чистый worktree и сохраняет точный Git commit в metadata. Point model +выбирается на validation; upper model выбирается и калибруется на двух непересекающихся +половинах validation. Финальный test используется только для однократной оценки. + +Полная проверка зафиксированных локальных артефактов: ```bash -python -m source.ui --scenario blend_risk +python -m source.main verify-model-freeze --manifest config/model_freeze.json ``` -The UI shows the current model-demo capability: model blending diagnostics, -constraint checks, result export, and journal inspection. +Проверяются хеши `model.joblib`, metadata, metrics и финальных агрегированных отчётов, +dataset ID, запрет action capability и флаги неиспользования test при выборе/настройке. + +## Историческая оценка -## Limits +```bash +python -m source.main evaluate \ + --dataset data/processed/ \ + --model artifacts/models/ \ + --source pak --split test + +python -m source.main evaluate \ + --dataset data/processed/ \ + --model artifacts/models/ \ + --source lims --split test +``` -- Stage 6 does not create or retrain a model. -- Stage 6 does not recommend industrial setpoint changes. -- T95 and cetane number in model-demo are synthetic scenario values; the complete - industrial product passport remains not assessed. -- `0.95` uncertainty coverage remains an empirical historical estimate, not an - industrial safety guarantee. +Baseline и модель сравниваются только на общих timestamp. ПАК и ЛИМС не усредняются. +Строковые residuals сохраняются локально для разбора ошибок, агрегированные метрики +перечислены ниже после финального frozen run. -## How To Check +## Воспроизводимые демонстрационные эпизоды ```bash -python -m pytest global_tests/test_stage6_acceptance.py -python -m pytest global_tests/test_ui.py global_tests/test_stage5_uncertainty_policy.py -python -m pytest -python -m ruff check . -python -m mypy source +python -m source.main acceptance --output reports/final-acceptance ``` + +Каталог содержит: + +1. `stable_blend` — серная граница текущей смеси 9.4 мг/кг; +2. `sulfur_risk` — текущая граница 14.2 мг/кг, серный контрфактуал A=90%, B=10% + даёт 9.4 мг/кг; +3. `missing_component_quality` — отсутствующая оценка компонента блокирует расчёт. + +Во всех трёх эпизодах итоговый статус — `abstain`, поскольку обязательные T95 и +цетановое число не оценены. Серный контрфактуал сохраняется как результат синтетической +модели, но явно имеет `operator_recommendation=false`. Это не всеотказывающаяся ошибка: +система вычисляет доступную часть, показывает допустимые по сере варианты и отказывает +только в полном операторском решении, для которого не хватает обязательного паспорта. + +`summary.json` хранит численные результаты, причины, время цикла и fingerprint решения +без случайного `run_id`. `journals.zip` содержит все шесть файлов каждого запуска и +manifest с SHA-256. + +## Frozen результаты + +Конкретные model IDs, хеши, исторические метрики и измеренное время приёмочного прогона +фиксируются в `config/model_freeze.json` и в итоговой таблице этого раздела после запуска +на чистом commit этапа 6. + +## Ограничения и основные ошибки + +- Финальная point model может остаться persistence baseline: более сложная модель не + принимается, если увеличивает число пропущенных превышений на validation. +- Историческая точность ПАК не равна точности относительно контрольного ЛИМС; результаты + источников показываются раздельно. +- Верхняя граница 0.95 — эмпирическое покрытие на истории, не промышленная гарантия. +- Applicability использует одномерные train-квантили признаков и может отклонять много + test-точек при сдвиге режима. +- T95 и цетановое число не имеют валидированной модели, поэтому соответствие полного + товарного паспорта не заявляется. +- `P8`, `T11`, `F19` известны как управляющие переменные, но модель причинного эффекта + действий не подтверждена. Реальные setpoint-рекомендации запрещены. +- Условный эффект блендинга доказывается только внутри синтетической массовой модели; + исторические данные не подтверждают невыполненные воздействия. diff --git a/config/demo_episodes.json b/config/demo_episodes.json new file mode 100644 index 0000000..a7f6e8e --- /dev/null +++ b/config/demo_episodes.json @@ -0,0 +1,32 @@ +{ + "schema_version": "1.0", + "episodes": [ + { + "id": "stable_blend", + "scenario": "blend_normal", + "expected_status": "abstain", + "expected_reason_codes": ["UNASSESSED_REQUIRED_PROPERTY"], + "expected_baseline_upper": 9.4, + "expected_sulfur_only_upper": 9.4, + "expected_sulfur_only_blend": null + }, + { + "id": "sulfur_risk", + "scenario": "blend_risk", + "expected_status": "abstain", + "expected_reason_codes": ["UNASSESSED_REQUIRED_PROPERTY", "QUALITY_LIMIT"], + "expected_baseline_upper": 14.2, + "expected_sulfur_only_upper": 9.4, + "expected_sulfur_only_blend": {"A": 0.9, "B": 0.1} + }, + { + "id": "missing_component_quality", + "scenario": "blend_missing", + "expected_status": "abstain", + "expected_reason_codes": ["MISSING_REQUIRED_SIGNAL"], + "expected_baseline_upper": null, + "expected_sulfur_only_upper": null, + "expected_sulfur_only_blend": null + } + ] +} diff --git a/global_tests/test_stage6_acceptance.py b/global_tests/test_stage6_acceptance.py index 7914ec8..26f74e1 100644 --- a/global_tests/test_stage6_acceptance.py +++ b/global_tests/test_stage6_acceptance.py @@ -1,74 +1,128 @@ -"""Stage-6 acceptance command tests.""" +"""Stage-6 acceptance, freeze and journal-export tests.""" from __future__ import annotations import json +import zipfile from pathlib import Path -from source.main import STAGE6_SCENARIOS, accept_stage6, main +import pytest -EXPECTED = { - "blend_normal": "hold", - "blend_risk": "recommend", - "blend_missing": "abstain", -} +from source.acceptance import ( + JOURNAL_FILES, + load_episode_specs, + run_acceptance_suite, + verify_model_freeze, +) +from source.ml.artifacts import sha256_file +PROJECT_ROOT = Path(__file__).resolve().parents[1] -def test_accept_stage6_cli_writes_journals_to_requested_run_dir( - tmp_path: Path, capsys: object -) -> None: - """Verify the public CLI performs the reproducible acceptance pass.""" - run_dir = tmp_path / "stage6-runs" - - exit_code = main(["accept-stage6", "--run-dir", str(run_dir)]) - - captured = capsys.readouterr() - payload = json.loads(captured.out) - assert exit_code == 0 - assert payload["passed"] is True - assert payload["validation"]["model_demo_fixtures"] == 3 - assert {item["scenario_id"] for item in payload["scenarios"]} == set(STAGE6_SCENARIOS) - for scenario in payload["scenarios"]: - assert scenario["status"] == EXPECTED[scenario["scenario_id"]] - assert scenario["passed"] is True - journal_dir = Path(scenario["journal_dir"]) - assert journal_dir.is_relative_to(run_dir) - for path in scenario["journal_files"].values(): - assert Path(path).is_file() - - -def test_accept_stage6_reports_failed_status_without_hiding_journal( + +def test_episode_catalog_is_explicit_and_complete() -> None: + episodes = load_episode_specs(PROJECT_ROOT / "config/demo_episodes.json") + + assert [episode.id for episode in episodes] == [ + "stable_blend", + "sulfur_risk", + "missing_component_quality", + ] + assert all(episode.expected_status == "abstain" for episode in episodes) + + +def test_acceptance_suite_reproduces_decisions_and_exports_full_journals( tmp_path: Path, ) -> None: - """Verify a status mismatch turns the acceptance result red.""" - expected = dict(EXPECTED) - expected["blend_risk"] = "hold" - - payload = accept_stage6(run_dir=tmp_path, expected_statuses=expected) - - assert payload["passed"] is False - failed = [item for item in payload["scenarios"] if not item["passed"]] - assert [item["scenario_id"] for item in failed] == ["blend_risk"] - assert failed[0]["status"] == "recommend" - assert failed[0]["expected_status"] == "hold" - assert failed[0]["missing_journal_files"] == [] - assert any("blend_risk" in issue for issue in payload["issues"]) - - -def test_accept_stage6_summary_shape(tmp_path: Path) -> None: - """Verify the acceptance JSON exposes the fields used by README and demos.""" - payload = accept_stage6(run_dir=tmp_path) - - assert set(payload) == { - "passed", - "validation", - "scenarios", - "environment", - "limitations", - "issues", - } - assert isinstance(payload["validation"], dict) - assert isinstance(payload["scenarios"], list) - assert isinstance(payload["environment"]["python_version"], str) - assert isinstance(payload["environment"]["sklearn_version"], str) - assert payload["limitations"] + output = tmp_path / "acceptance" + report = run_acceptance_suite( + PROJECT_ROOT, + episodes_path=PROJECT_ROOT / "config/demo_episodes.json", + output_dir=output, + ) + + assert report["episode_count"] == 3 + assert report["max_cycle_seconds"] < 5 + risk = next(item for item in report["episodes"] if item["episode_id"] == "sulfur_risk") + assert risk["baseline_upper_mg_kg"] == pytest.approx(14.2) + assert risk["sulfur_only_counterfactual"]["upper_mg_kg"] == pytest.approx(9.4) + assert risk["sulfur_only_counterfactual"]["operator_recommendation"] is False + with zipfile.ZipFile(output / "journals.zip") as archive: + names = set(archive.namelist()) + assert "manifest.json" in names + for filename in JOURNAL_FILES: + assert f"sulfur_risk/{filename}" in names + + +def test_acceptance_decision_fingerprints_repeat(tmp_path: Path) -> None: + reports = [ + run_acceptance_suite( + PROJECT_ROOT, + episodes_path=PROJECT_ROOT / "config/demo_episodes.json", + output_dir=tmp_path / f"run-{index}", + ) + for index in range(2) + ] + + assert [item["decision_fingerprint"] for item in reports[0]["episodes"]] == [ + item["decision_fingerprint"] for item in reports[1]["episodes"] + ] + + +def test_model_freeze_verifies_hashes_and_rejects_tampering(tmp_path: Path) -> None: + artifact = tmp_path / "artifacts/models/frozen-test" + artifact.mkdir(parents=True) + model = artifact / "model.joblib" + metadata = artifact / "metadata.json" + metrics = artifact / "metrics.json" + model.write_bytes(b"trusted-local-model") + model_hash = sha256_file(model) + metadata.write_text( + json.dumps( + { + "model_id": "frozen-test", + "model_sha256": model_hash, + "training_dataset_id": "dataset00001", + "git_commit": "a" * 40, + "capabilities": {"supports_actions": False}, + } + ), + encoding="utf-8", + ) + metrics.write_text(json.dumps({"test_used_for_selection": False}), encoding="utf-8") + freeze = tmp_path / "freeze.json" + freeze.write_text( + json.dumps( + { + "schema_version": "1.0", + "training_dataset_id": "dataset00001", + "artifacts": [ + { + "model_id": "frozen-test", + "path": "artifacts/models/frozen-test", + "model_sha256": model_hash, + "metadata_sha256": sha256_file(metadata), + "metrics_sha256": sha256_file(metrics), + } + ], + "evaluation_reports": [], + } + ), + encoding="utf-8", + ) + + assert verify_model_freeze(tmp_path, freeze)["verified_models"] == ["frozen-test"] + model.write_bytes(b"tampered") + with pytest.raises(ValueError, match="checksum mismatch"): + verify_model_freeze(tmp_path, freeze) + + +def test_failed_acceptance_does_not_publish_partial_directory(tmp_path: Path) -> None: + bad_catalog = tmp_path / "episodes.json" + payload = json.loads((PROJECT_ROOT / "config/demo_episodes.json").read_text(encoding="utf-8")) + payload["episodes"][0]["expected_status"] = "hold" + bad_catalog.write_text(json.dumps(payload), encoding="utf-8") + output = tmp_path / "failed" + + with pytest.raises(ValueError, match="status changed"): + run_acceptance_suite(PROJECT_ROOT, episodes_path=bad_catalog, output_dir=output) + assert not output.exists() diff --git a/requirements.lock.txt b/requirements.lock.txt new file mode 100644 index 0000000..c1613d7 --- /dev/null +++ b/requirements.lock.txt @@ -0,0 +1,18 @@ +# Exact direct dependency versions verified for the Stage-6 release. +# CI validates this set on Python 3.11; the local final evaluation used Python 3.12.3. +pandas==2.3.3 +numpy==1.26.4 +pydantic==2.13.5 +openpyxl==3.1.5 +tzdata==2026.3 +scikit-learn==1.9.0 +joblib==1.6.0 + +# Development and acceptance tools +mypy==1.20.2 +pandas-stubs==2.3.3.260113 +ruff==0.16.6 +pytest==8.4.2 +pre-commit==4.6.2 +types-pytz==2026.3.1.20260727 +types-openpyxl==3.1.5.20260827 diff --git a/source/acceptance.py b/source/acceptance.py new file mode 100644 index 0000000..b5c53ae --- /dev/null +++ b/source/acceptance.py @@ -0,0 +1,405 @@ +"""Stage-6 reproducible demonstrations, model freeze checks and journal export.""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import tempfile +import time +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Mapping + +from source.config import load_runtime_config, load_scenario +from source.contracts import ( + CandidateEvaluation, + ConstraintStatus, + DecisionContext, + ProcessState, + Recommendation, +) +from source.ml.artifacts import sha256_file +from source.orchestrator import run_cycle + +JOURNAL_FILES = ( + "metadata.json", + "input.json", + "features.json", + "trace.jsonl", + "candidates.jsonl", + "result.json", +) + + +@dataclass(frozen=True) +class EpisodeSpec: + """One checked-in acceptance episode and its stable expectations.""" + + id: str + scenario: str + expected_status: str + expected_reason_codes: tuple[str, ...] + expected_baseline_upper: float | None + expected_sulfur_only_upper: float | None + expected_sulfur_only_blend: dict[str, float] | None + + +def load_episode_specs(path: Path) -> tuple[EpisodeSpec, ...]: + """Read and strictly validate the small Stage-6 episode catalog.""" + payload = json.loads(Path(path).read_text(encoding="utf-8")) + if payload.get("schema_version") != "1.0": + raise ValueError("unsupported episode catalog schema_version") + raw_episodes = payload.get("episodes") + if not isinstance(raw_episodes, list) or not raw_episodes: + raise ValueError("episode catalog must contain a non-empty episodes list") + episodes: list[EpisodeSpec] = [] + for raw in raw_episodes: + if not isinstance(raw, Mapping): + raise ValueError("each episode must be an object") + required = { + "id", + "scenario", + "expected_status", + "expected_reason_codes", + "expected_baseline_upper", + "expected_sulfur_only_upper", + "expected_sulfur_only_blend", + } + missing = required.difference(raw) + if missing: + raise ValueError(f"episode is missing fields: {sorted(missing)}") + blend = raw["expected_sulfur_only_blend"] + episodes.append( + EpisodeSpec( + id=str(raw["id"]), + scenario=str(raw["scenario"]), + expected_status=str(raw["expected_status"]), + expected_reason_codes=tuple(str(item) for item in raw["expected_reason_codes"]), + expected_baseline_upper=_optional_float(raw["expected_baseline_upper"]), + expected_sulfur_only_upper=_optional_float(raw["expected_sulfur_only_upper"]), + expected_sulfur_only_blend=( + None + if blend is None + else {str(key): float(value) for key, value in dict(blend).items()} + ), + ) + ) + if len({episode.id for episode in episodes}) != len(episodes): + raise ValueError("episode ids must be unique") + return tuple(episodes) + + +def recommendation_fingerprint(result: Recommendation) -> str: + """Hash stable decision content while excluding the random run identifier.""" + payload = result.model_dump(mode="json") + payload.pop("run_id", None) + encoded = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def run_acceptance_suite( + root: Path, + *, + episodes_path: Path, + output_dir: Path, +) -> dict[str, Any]: + """Run checked-in episodes and export their complete journals.""" + root = Path(root) + output_dir = Path(output_dir) + if output_dir.exists(): + raise FileExistsError(f"acceptance output already exists: {output_dir}") + output_dir.parent.mkdir(parents=True, exist_ok=True) + temporary = Path(tempfile.mkdtemp(prefix=f".{output_dir.name}.", dir=output_dir.parent)) + try: + run_root = temporary / "runs" + config = load_runtime_config(root / "config/runtime.toml") + summaries: list[dict[str, Any]] = [] + journal_sources: list[tuple[str, Path]] = [] + started = time.perf_counter() + for episode in load_episode_specs(episodes_path): + scenario = load_scenario(root / f"config/scenarios/{episode.scenario}.json") + fixture = json.loads( + (root / f"global_tests/fixtures/model_demo/{episode.scenario}.json").read_text( + encoding="utf-8" + ) + ) + state = ProcessState.model_validate(fixture["state"]) + cycle_started = time.perf_counter() + result = run_cycle( + data=None, + as_of=state.as_of, + model=None, + scenario=scenario, + config=config, + context=DecisionContext(), + run_dir=run_root, + ) + cycle_seconds = time.perf_counter() - cycle_started + journal_dir = run_root / result.run_id + sulfur_only = _sulfur_only_candidate(journal_dir) + _check_episode(episode, result, sulfur_only) + summaries.append( + { + "episode_id": episode.id, + "scenario": episode.scenario, + "status": result.status.value, + "reason_codes": list(result.reason_codes), + "baseline_upper_mg_kg": _sulfur_upper(result.baseline), + "sulfur_only_counterfactual": sulfur_only, + "decision_fingerprint": recommendation_fingerprint(result), + "cycle_seconds": cycle_seconds, + "run_id": result.run_id, + } + ) + journal_sources.append((episode.id, journal_dir)) + archive = export_journals(journal_sources, temporary / "journals.zip") + report = { + "schema_version": "1.0", + "episodes_file": Path(episodes_path).relative_to(root).as_posix(), + "episode_count": len(summaries), + "elapsed_seconds": time.perf_counter() - started, + "max_cycle_seconds": max(item["cycle_seconds"] for item in summaries), + "episodes": summaries, + "journal_archive": archive.name, + "evidence_boundary": ( + "Historical metrics assess forecast accuracy; sulfur-only counterfactuals assess " + "the declared synthetic blending model and are not operator recommendations." + ), + } + _write_json(temporary / "summary.json", report) + temporary.replace(output_dir) + except Exception: + shutil.rmtree(temporary, ignore_errors=True) + raise + return report + + +def export_journals( + journals: Iterable[tuple[str, Path]], + destination: Path, +) -> Path: + """Export complete journals with checksums and stable archive metadata.""" + destination = Path(destination) + if destination.exists(): + raise FileExistsError(f"journal archive already exists: {destination}") + sources = tuple(journals) + if not sources: + raise ValueError("at least one journal is required") + if len({name for name, _ in sources}) != len(sources): + raise ValueError("journal export names must be unique") + destination.parent.mkdir(parents=True, exist_ok=True) + manifest: dict[str, Any] = {"schema_version": "1.0", "journals": []} + file_descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{destination.name}.", dir=destination.parent + ) + os.close(file_descriptor) + temporary = Path(temporary_name) + try: + with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name, directory in sorted(sources): + files = [] + for filename in JOURNAL_FILES: + path = Path(directory) / filename + if not path.is_file(): + raise FileNotFoundError( + f"incomplete journal {directory}: missing {filename}" + ) + archive_name = f"{name}/{filename}" + _write_zip_bytes(archive, archive_name, path.read_bytes()) + files.append({"path": archive_name, "sha256": sha256_file(path)}) + manifest["journals"].append({"name": name, "files": files}) + _write_zip_bytes( + archive, + "manifest.json", + (json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2) + "\n").encode( + "utf-8" + ), + ) + temporary.replace(destination) + except Exception: + temporary.unlink(missing_ok=True) + raise + return destination + + +def verify_model_freeze(root: Path, manifest_path: Path) -> dict[str, Any]: + """Verify every frozen local artifact and evaluation report by SHA-256.""" + root = Path(root) + payload = json.loads(Path(manifest_path).read_text(encoding="utf-8")) + if payload.get("schema_version") != "1.0": + raise ValueError("unsupported model freeze schema_version") + artifacts = payload.get("artifacts") + if not isinstance(artifacts, list) or not artifacts: + raise ValueError("model freeze must contain artifacts") + verified_ids: list[str] = [] + for frozen in artifacts: + if not isinstance(frozen, Mapping): + raise ValueError("frozen artifact must be an object") + directory = root / str(frozen["path"]) + metadata_path = directory / "metadata.json" + metrics_path = directory / "metrics.json" + model_path = directory / "model.joblib" + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + metrics = json.loads(metrics_path.read_text(encoding="utf-8")) + _require_hash(model_path, str(frozen["model_sha256"])) + _require_hash(metadata_path, str(frozen["metadata_sha256"])) + _require_hash(metrics_path, str(frozen["metrics_sha256"])) + if metadata.get("model_id") != frozen.get("model_id"): + raise ValueError("frozen model_id does not match metadata") + if metadata.get("model_sha256") != frozen.get("model_sha256"): + raise ValueError("frozen model hash does not match metadata") + if metadata.get("training_dataset_id") != payload.get("training_dataset_id"): + raise ValueError("frozen model uses another training dataset") + if metadata.get("capabilities", {}).get("supports_actions") is not False: + raise ValueError("Stage-6 forecast artifacts must not enable action control") + if str(metadata.get("git_commit", "")).endswith("-dirty"): + raise ValueError("frozen model was trained from a dirty worktree") + if "test_used_for_selection" in metrics and metrics["test_used_for_selection"] is not False: + raise ValueError("point model used final test for selection") + if "test_used_for_tuning" in metrics and metrics["test_used_for_tuning"] is not False: + raise ValueError("upper model used final test for tuning") + verified_ids.append(str(frozen["model_id"])) + for report in payload.get("evaluation_reports", []): + _require_hash(root / str(report["path"]), str(report["sha256"])) + return { + "training_dataset_id": payload.get("training_dataset_id"), + "verified_models": verified_ids, + "evaluation_reports": len(payload.get("evaluation_reports", [])), + } + + +def _check_episode( + episode: EpisodeSpec, + result: Recommendation, + sulfur_only: dict[str, Any] | None, +) -> None: + if result.status.value != episode.expected_status: + raise ValueError( + f"episode {episode.id} status changed: {result.status.value} != " + f"{episode.expected_status}" + ) + missing_reasons = set(episode.expected_reason_codes).difference(result.reason_codes) + if missing_reasons: + raise ValueError(f"episode {episode.id} lost reasons: {sorted(missing_reasons)}") + _check_optional_number( + _sulfur_upper(result.baseline), episode.expected_baseline_upper, "baseline upper" + ) + actual_upper = None if sulfur_only is None else sulfur_only["upper_mg_kg"] + _check_optional_number(actual_upper, episode.expected_sulfur_only_upper, "sulfur-only upper") + actual_blend = None if sulfur_only is None else sulfur_only["blend_mass_fractions"] + if actual_blend != episode.expected_sulfur_only_blend: + raise ValueError(f"episode {episode.id} sulfur-only blend changed") + + +def _sulfur_only_candidate(journal_dir: Path) -> dict[str, Any] | None: + candidates = [ + json.loads(line) + for line in (journal_dir / "candidates.jsonl").read_text(encoding="utf-8").splitlines() + if line.strip() + ] + eligible = [] + for candidate in candidates: + checks = candidate["checks"] + if not checks or any(check["status"] != ConstraintStatus.PASS.value for check in checks): + continue + sulfur = _assessment_metric(candidate, "sulfur") + cost = _assessment_metric(candidate, "cost_proxy") + if sulfur is None or sulfur.get("upper") is None or cost is None: + continue + eligible.append((float(cost["value"]), candidate, sulfur)) + if not eligible: + return None + _, candidate, sulfur = min(eligible, key=lambda item: (item[0], item[1]["candidate"]["id"])) + action = candidate["candidate"] + fractions = action["blend_mass_fractions"] or None + return { + "candidate_id": action["id"], + "upper_mg_kg": float(sulfur["upper"]), + "blend_mass_fractions": fractions, + "operator_recommendation": False, + "blocked_by": list( + dict.fromkeys( + issue["code"] + for assessment in candidate["assessments"] + for issue in assessment["issues"] + if issue["severity"] == "blocking" + ) + ), + } + + +def _assessment_metric(candidate: Mapping[str, Any], name: str) -> Mapping[str, Any] | None: + for assessment in candidate["assessments"]: + metric = assessment["metrics"].get(name) + if metric is not None: + if not isinstance(metric, Mapping): + raise ValueError(f"candidate metric {name} must be an object") + return metric + return None + + +def _sulfur_upper(evaluation: CandidateEvaluation | None) -> float | None: + if evaluation is None: + return None + for assessment in evaluation.assessments: + metric = assessment.metrics.get("sulfur") + if metric is not None: + return None if metric.upper is None else float(metric.upper) + return None + + +def _optional_float(value: object) -> float | None: + if value is None: + return None + if not isinstance(value, (int, float)): + raise ValueError("expected a number or null") + return float(value) + + +def _check_optional_number(actual: float | None, expected: float | None, label: str) -> None: + if actual is None or expected is None: + if actual is not expected: + raise ValueError(f"{label} availability changed") + return + if abs(actual - expected) > 1e-9: + raise ValueError(f"{label} changed: {actual} != {expected}") + + +def _require_hash(path: Path, expected: str) -> None: + actual = sha256_file(path) + if actual != expected: + raise ValueError(f"checksum mismatch for {path}: {actual} != {expected}") + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.write_text( + json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2, allow_nan=False) + "\n", + encoding="utf-8", + newline="\n", + ) + + +def _write_zip_bytes(archive: zipfile.ZipFile, name: str, content: bytes) -> None: + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o644 << 16 + archive.writestr(info, content) + + +__all__ = [ + "EpisodeSpec", + "export_journals", + "load_episode_specs", + "recommendation_fingerprint", + "run_acceptance_suite", + "verify_model_freeze", +] diff --git a/source/main.py b/source/main.py index db2ad5d..adcea33 100644 --- a/source/main.py +++ b/source/main.py @@ -3,42 +3,51 @@ from __future__ import annotations import argparse +import hashlib import json import platform import subprocess import sys from datetime import datetime from pathlib import Path -from typing import Any, Literal, Mapping, Sequence, cast +from typing import TYPE_CHECKING, Literal, Sequence from source.config import load_runtime_config, load_scenario, load_tag_dictionary -from source.contracts import ( - DecisionContext, - ProcessState, - Recommendation, - RecommendationStatus, - ScenarioConfig, - SourceKind, +from source.contracts import DecisionContext, ProcessState, Recommendation, SourceKind +from source.data import ( + PreparedData, + build_state, + load_prepared_dataset, + prepare_dataset, + write_prepared_dataset, ) -from source.data import build_state, load_prepared_dataset, prepare_dataset, write_prepared_dataset + +if TYPE_CHECKING: + from source.ml.artifacts import ModelBundle + from source.ml.features import SupervisedDataset + +SplitName = Literal["train", "validation", "test"] PROJECT_ROOT = Path(__file__).resolve().parent.parent -STAGE6_SCENARIOS: tuple[str, ...] = ("blend_normal", "blend_risk", "blend_missing") +MODEL_DEMO_SCENARIOS: tuple[str, ...] = ("blend_normal", "blend_risk", "blend_missing") +STAGE6_SCENARIOS = MODEL_DEMO_SCENARIOS STAGE6_EXPECTED_STATUSES: dict[str, str] = { - "blend_normal": RecommendationStatus.HOLD.value, - "blend_risk": RecommendationStatus.RECOMMEND.value, - "blend_missing": RecommendationStatus.ABSTAIN.value, + "blend_normal": "abstain", + "blend_risk": "abstain", + "blend_missing": "abstain", } STAGE6_JOURNAL_FILES: tuple[str, ...] = ( - "result.json", + "metadata.json", "input.json", + "features.json", "trace.jsonl", "candidates.jsonl", + "result.json", ) STAGE6_LIMITATIONS: tuple[str, ...] = ( - "Stage 6 is an acceptance and demonstration layer, not a new ML or action model.", + "Stage 6 is an acceptance and demonstration layer, not a production action model.", "Real setpoint recommendations remain disabled until a validated action model exists.", - "Model-demo recommendations are synthetic; history artifact serving remains forecast-only.", + "Model-demo counterfactuals are synthetic; history artifact serving remains forecast-only.", ) @@ -148,223 +157,267 @@ def build_state_command( return build_state(data, as_of, scenario_config, config) -def _git_commit(root: Path) -> str: - """Return a best-effort commit id for model metadata.""" +def _git_revision(root: Path) -> str: + """Return the exact clean revision used to create a model artifact.""" + revision = subprocess.run( + ["git", "-c", f"safe.directory={root.as_posix()}", "rev-parse", "HEAD"], + cwd=root, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + dirty = subprocess.run( + ["git", "-c", f"safe.directory={root.as_posix()}", "status", "--porcelain"], + cwd=root, + check=True, + capture_output=True, + text=True, + ).stdout + if dirty: + raise ValueError("training requires a clean worktree so the model can be frozen") + return revision + + +def _source_or_output( + value: SourceKind | str | Path | None, + output: str | Path | None, +) -> tuple[SourceKind, str | Path | None]: + """Keep the old positional train API while accepting the Stage-6 CLI shape.""" + if value is None: + return SourceKind.PAK, output try: - completed = subprocess.run( - ["git", "rev-parse", "HEAD"], - cwd=root, - check=True, - capture_output=True, - text=True, - ) - except (OSError, subprocess.CalledProcessError): - return "unknown" - return completed.stdout.strip() or "unknown" + return SourceKind(value), output + except ValueError: + if output is None: + return SourceKind.PAK, value + raise + + +def _supervised_dataset( + data: PreparedData, + target_source: SourceKind, + target_signal: str = "ht:2:Mg.Sulfur", +) -> SupervisedDataset: + from source.ml.features import build_supervised_dataset + + return build_supervised_dataset( + data, + target_signal_id=target_signal, + target_source=target_source, + feature_source=SourceKind.PAK, + horizon_minutes=60, + ) def train_command( dataset: str | Path, - target_source: str, + target_source: SourceKind | str | Path | None = SourceKind.PAK, output: str | Path | None = None, + *, target_signal: str = "ht:2:Mg.Sulfur", + with_uncertainty: bool = False, config_path: str | Path = "config/runtime.toml", root: Path = PROJECT_ROOT, ) -> dict[str, object]: - """Train a reproducible local sulfur forecast artifact from prepared data.""" - from source.ml.features import build_supervised_dataset + """Train the selected point model and optionally its frozen upper model.""" from source.ml.train import train_model + from source.ml.uncertainty import save_stage5_model + source, target_output = _source_or_output(target_source, output) config = load_runtime_config(_resolve_path(config_path, root)) + revision = _git_revision(root) data = load_prepared_dataset(_resolve_path(dataset, root)) - supervised = build_supervised_dataset( - data, - target_signal_id=target_signal, - target_source=SourceKind(target_source), - horizon_minutes=config.horizon_minutes, - ) - result = train_model( - cast(Any, supervised), - models_root=_resolve_path(output if output is not None else config.models_dir, root), + supervised = _supervised_dataset(data, source, target_signal) + models_root = _resolve_path(target_output if target_output is not None else config.models_dir, root) + point = train_model( + supervised, + models_root=models_root, training_dataset_id=data.manifest.dataset_id, tag_dictionary_sha256=data.manifest.tag_dictionary_sha256, - git_commit=_git_commit(root), + git_commit=revision, source_timezone=config.source_timezone, horizon_minutes=config.horizon_minutes, seed=config.seed, ) - return { - "model_id": result.bundle.metadata.model_id, - "artifact_dir": result.artifact_dir.as_posix(), - "selected_model": result.metrics.get("selected_model"), - "target_signal": result.bundle.metadata.target_signal, - "target_source": result.bundle.metadata.target_source, + result: dict[str, object] = { + "dataset_id": data.manifest.dataset_id, + "point_model_id": point.bundle.metadata.model_id, + "point_model_path": point.artifact_dir.as_posix(), + "selected_model": point.metrics["selected_model"], + "target_signal": point.bundle.metadata.target_signal, + "target_source": point.bundle.metadata.target_source, } + if with_uncertainty: + recipe = f"{data.manifest.dataset_id}:{point.bundle.metadata.model_id}:upper:0.95" + upper_id = f"sulfur-upper-{hashlib.sha256(recipe.encode()).hexdigest()[:12]}" + upper_path = models_root / upper_id + upper, fitted = save_stage5_model( + upper_path, + supervised, + point.bundle, + source_timezone=config.source_timezone, + seed=config.seed, + ) + result.update( + { + "upper_model_id": upper.metadata.model_id, + "upper_model_path": upper_path.as_posix(), + "test_coverage": fitted.report["test_coverage"], + "test_applicability_rate": fitted.report["test_applicability_rate"], + } + ) + return result + + +def _load_trusted_model(model_path: str | Path, data: PreparedData, root: Path) -> ModelBundle: + from source.ml.artifacts import load_model + + manifest = data.manifest + return load_model( + _resolve_path(model_path, root), + trusted=True, + expected_horizon_minutes=60, + expected_tag_dictionary_sha256=manifest.tag_dictionary_sha256, + expected_target_signal="ht:2:Mg.Sulfur", + expected_target_unit="mg/kg", + ) + + +def _coerce_split_or_source( + value: SourceKind | str | None, + split: SplitName, +) -> tuple[SourceKind | None, SplitName]: + if isinstance(value, str) and value in {"train", "validation", "test"}: + return None, value + if value is None: + return None, split + return SourceKind(value), split def evaluate_command( dataset: str | Path, - model: str | Path, - split: Literal["validation", "test"] = "test", + model_path: str | Path, + target_source: SourceKind | str | None = SourceKind.PAK, output: str | Path | None = None, + *, + split: SplitName = "test", config_path: str | Path = "config/runtime.toml", root: Path = PROJECT_ROOT, ) -> dict[str, object]: - """Evaluate a trusted local forecast artifact on a temporal split.""" - from source.ml.artifacts import load_model + """Evaluate a frozen model and baseline on identical historical timestamps.""" from source.ml.evaluate import evaluate_model, write_evaluation - from source.ml.features import build_supervised_dataset config = load_runtime_config(_resolve_path(config_path, root)) data = load_prepared_dataset(_resolve_path(dataset, root)) - bundle = load_model( - _resolve_path(model, root), - trusted=True, - expected_horizon_minutes=config.horizon_minutes, - expected_tag_dictionary_sha256=data.manifest.tag_dictionary_sha256, - ) - supervised = build_supervised_dataset( - data, - target_signal_id=bundle.metadata.target_signal, - target_source=SourceKind(bundle.metadata.target_source), - horizon_minutes=bundle.metadata.horizon_minutes, + model = _load_trusted_model(model_path, data, root) + source, selected_split = _coerce_split_or_source(target_source, split) + evaluation_source = source or SourceKind(model.metadata.target_source) + supervised = _supervised_dataset(data, evaluation_source, model.metadata.target_signal) + evaluation = evaluate_model( + supervised, + model, + split=selected_split, + source_timezone=config.source_timezone, ) - result = evaluate_model( - cast(Any, supervised), bundle, split=split, source_timezone=config.source_timezone + destination = ( + _resolve_path(output, root) + if output is not None + else root + / config.reports_dir + / "final" + / model.metadata.model_id + / f"{evaluation_source.value}_{selected_split}" ) - output_root = _resolve_path(output if output is not None else config.reports_dir, root) - report_dir = write_evaluation(output_root / f"{bundle.metadata.model_id}-{split}", result) + write_evaluation(destination, evaluation) + metrics = evaluation.report["metrics"] return { - "model_id": bundle.metadata.model_id, - "split": split, - "report_dir": report_dir.as_posix(), - "metrics": result.report["metrics"], + "model_id": model.metadata.model_id, + "target_source": evaluation_source.value, + "split": selected_split, + "report_path": (destination / "metrics.json").as_posix(), + "baseline_mae": metrics["baseline"]["mae"], + "model_mae": metrics["model"]["mae"], + "coverage": evaluation.report["coverage"], } -def _scenario_target_signal(scenario: ScenarioConfig) -> str | None: - """Return the single forecast target expected by the current history scenario.""" - return scenario.required_signals[0] if len(scenario.required_signals) == 1 else None - - -def _scenario_target_unit(scenario: ScenarioConfig) -> str | None: - """Return the sulfur unit that the history artifact must serve, when configured.""" - sulfur_constraints = [item for item in scenario.constraints if item.metric == "sulfur"] - return sulfur_constraints[0].unit if sulfur_constraints else None - - -def run_history_command( +def replay_command( dataset: str | Path, - model: str | Path, + model_path: str | Path, + scenario: str | Path, as_of: datetime, *, - trusted_model: bool = False, - scenario: str | Path = "history", config_path: str | Path = "config/runtime.toml", run_dir: str | Path | None = None, root: Path = PROJECT_ROOT, ) -> Recommendation: - """Run the history serving path with an explicitly trusted local model artifact.""" - from source.ml.artifacts import load_model + """Replay one historical point with a compatible frozen forecast model.""" from source.orchestrator import run_cycle config = load_runtime_config(_resolve_path(config_path, root)) + data = load_prepared_dataset(_resolve_path(dataset, root)) + model = _load_trusted_model(model_path, data, root) scenario_path = Path(scenario) if not scenario_path.suffix: scenario_path = Path("config/scenarios") / f"{scenario}.json" scenario_config = load_scenario(_resolve_path(scenario_path, root)) - data = load_prepared_dataset(_resolve_path(dataset, root)) - model_bundle = load_model( - _resolve_path(model, root), - trusted=trusted_model, - expected_horizon_minutes=config.horizon_minutes, - expected_tag_dictionary_sha256=data.manifest.tag_dictionary_sha256, - expected_target_signal=_scenario_target_signal(scenario_config), - expected_target_unit=_scenario_target_unit(scenario_config), - ) - target_run_dir = _resolve_path(run_dir if run_dir is not None else config.runs_dir, root) return run_cycle( data=data, as_of=as_of, - model=model_bundle, + model=model, scenario=scenario_config, config=config, context=DecisionContext(), - run_dir=target_run_dir, + run_dir=_resolve_path(run_dir if run_dir is not None else config.runs_dir, root), + ) + + +def run_history_command( + dataset: str | Path, + model: str | Path, + as_of: datetime, + *, + trusted_model: bool = False, + scenario: str | Path = "history", + config_path: str | Path = "config/runtime.toml", + run_dir: str | Path | None = None, + root: Path = PROJECT_ROOT, +) -> Recommendation: + """Compatibility wrapper for the older ``run-history`` command.""" + if not trusted_model: + raise ValueError("run-history requires --trusted-model for local joblib artifacts") + return replay_command( + dataset, + model, + scenario, + as_of, + config_path=config_path, + run_dir=run_dir, + root=root, ) def accept_stage6( root: Path = PROJECT_ROOT, run_dir: str | Path | None = None, - expected_statuses: Mapping[str, str] = STAGE6_EXPECTED_STATUSES, ) -> dict[str, object]: - """Run the Stage-6 reproducibility and demonstration acceptance checks.""" - try: - import sklearn - - sklearn_version = sklearn.__version__ - except ImportError: - sklearn_version = "unavailable" - acceptance_run_dir = _resolve_path(run_dir if run_dir is not None else "runs/stage6", root) - issues: list[str] = [] - validation: dict[str, object] = {} - try: - validation.update(validate_stage0(root)) - except Exception as exc: - validation = {"error": str(exc)} - issues.append(f"validate_stage0 failed: {exc}") - - scenario_results: list[dict[str, object]] = [] - for scenario_id in STAGE6_SCENARIOS: - expected = expected_statuses[scenario_id] - try: - result = run_model_demo(scenario_id, root=root, run_dir=acceptance_run_dir) - journal_dir = acceptance_run_dir / result.run_id - missing = [name for name in STAGE6_JOURNAL_FILES if not (journal_dir / name).is_file()] - status = result.status.value - passed = status == expected and not missing - if status != expected: - issues.append(f"{scenario_id}: expected status {expected!r}, received {status!r}") - if missing: - issues.append(f"{scenario_id}: missing journal files {', '.join(missing)}") - scenario_results.append( - { - "scenario_id": scenario_id, - "expected_status": expected, - "status": status, - "passed": passed, - "run_id": result.run_id, - "journal_dir": journal_dir.as_posix(), - "journal_files": { - name: (journal_dir / name).as_posix() for name in STAGE6_JOURNAL_FILES - }, - "missing_journal_files": missing, - } - ) - except Exception as exc: - issues.append(f"{scenario_id}: {exc}") - scenario_results.append( - { - "scenario_id": scenario_id, - "expected_status": expected, - "status": None, - "passed": False, - "error": str(exc), - } - ) - - passed = not issues and all(bool(item.get("passed")) for item in scenario_results) + """Compatibility wrapper around the Stage-6 acceptance package.""" + from source.acceptance import run_acceptance_suite + + output_dir = _resolve_path(run_dir if run_dir is not None else "runs/stage6", root) + report = run_acceptance_suite( + root, + episodes_path=root / "config/demo_episodes.json", + output_dir=output_dir, + ) return { - "passed": passed, - "validation": validation, - "scenarios": scenario_results, + "passed": True, + "report": report, "environment": { "python_version": platform.python_version(), - "sklearn_version": sklearn_version, }, "limitations": list(STAGE6_LIMITATIONS), - "issues": issues, + "issues": [], } @@ -376,11 +429,9 @@ def main(argv: Sequence[str] | None = None) -> int: subparsers = parser.add_subparsers(dest="command", required=True) subparsers.add_parser("validate-stage0", help="validate contracts, configs and fixtures") demo = subparsers.add_parser("run-model-demo", help="run a stage-1 model-demo scenario") - demo.add_argument( - "scenario", - choices=("blend_normal", "blend_risk", "blend_missing"), - help="scenario id from config/scenarios", - ) + demo.add_argument("scenario", choices=MODEL_DEMO_SCENARIOS, help="scenario id from config") + demo_alias = subparsers.add_parser("demo", help="run a deterministic model-demo episode") + demo_alias.add_argument("scenario", choices=MODEL_DEMO_SCENARIOS) prepare = subparsers.add_parser( "prepare", help="prepare original materials into data/processed" ) @@ -394,42 +445,47 @@ def main(argv: Sequence[str] | None = None) -> int: "--as-of", required=True, type=_parse_as_of, help="timezone-aware ISO datetime" ) state.add_argument("--config", default="config/runtime.toml", help="runtime config path") - train = subparsers.add_parser("train", help="train a local forecast artifact") + train = subparsers.add_parser("train", help="train and persist the frozen sulfur models") train.add_argument("--dataset", required=True, help="prepared dataset directory") train.add_argument( "--target-source", - required=True, choices=(SourceKind.PAK.value, SourceKind.LIMS.value), + default=SourceKind.PAK.value, help="quality source used as supervised target", ) train.add_argument("--target-signal", default="ht:2:Mg.Sulfur", help="target signal id") + train.add_argument("--output", default=None, help="model artifacts root") train.add_argument("--config", default="config/runtime.toml", help="runtime config path") - train.add_argument("--output", default=None, help="model artifact root") - evaluate = subparsers.add_parser("evaluate", help="evaluate a local forecast artifact") - evaluate.add_argument("--dataset", required=True, help="prepared dataset directory") - evaluate.add_argument("--model", required=True, help="local model artifact directory") - evaluate.add_argument( - "--split", choices=("validation", "test"), default="test", help="temporal split" - ) - evaluate.add_argument("--config", default="config/runtime.toml", help="runtime config path") - evaluate.add_argument("--output", default=None, help="report root") - accept = subparsers.add_parser( - "accept-stage6", help="run Stage-6 acceptance and demonstration checks" + train.add_argument( + "--with-uncertainty", + action="store_true", + help="also fit the Stage-5 empirical upper model", ) - accept.add_argument( - "--run-dir", - default="runs/stage6", - help="directory for acceptance journals, relative to project root unless absolute", + evaluate = subparsers.add_parser( + "evaluate", help="compare a frozen model with persistence on one temporal split" ) + evaluate.add_argument("--dataset", required=True, help="prepared dataset directory") + evaluate.add_argument("--model", required=True, help="trusted local model directory") + evaluate.add_argument("--source", choices=("pak", "lims"), required=True) + evaluate.add_argument("--split", choices=("train", "validation", "test"), default="test") + evaluate.add_argument("--output", default=None, help="new evaluation directory") + evaluate.add_argument("--config", default="config/runtime.toml", help="runtime config path") + replay = subparsers.add_parser("replay", help="replay one historical forecast point") + replay.add_argument("--dataset", required=True, help="prepared dataset directory") + replay.add_argument("--model", required=True, help="trusted local model directory") + replay.add_argument("--scenario", default="history", help="scenario id or JSON path") + replay.add_argument("--at", required=True, type=_parse_as_of, help="timezone-aware ISO time") + replay.add_argument("--config", default="config/runtime.toml", help="runtime config path") + replay.add_argument("--run-dir", default=None, help="journal directory override") history = subparsers.add_parser( - "run-history", help="run history mode with a trusted local forecast artifact" + "run-history", help="legacy alias for replay with a trusted forecast artifact" ) history.add_argument("--dataset", required=True, help="prepared dataset directory") - history.add_argument("--model", required=True, help="local model artifact directory") + history.add_argument("--model", required=True, help="trusted local model directory") history.add_argument( "--trusted-model", action="store_true", - help="allow loading the local joblib artifact after path and metadata checks", + help="explicitly trust this local joblib artifact after metadata checks", ) history.add_argument( "--as-of", required=True, type=_parse_as_of, help="timezone-aware ISO datetime" @@ -437,13 +493,31 @@ def main(argv: Sequence[str] | None = None) -> int: history.add_argument("--scenario", default="history", help="scenario id or JSON path") history.add_argument("--config", default="config/runtime.toml", help="runtime config path") history.add_argument("--run-dir", default=None, help="journal directory override") + acceptance = subparsers.add_parser( + "acceptance", help="run fixed Stage-6 episodes and export their journals" + ) + acceptance.add_argument("--episodes", default="config/demo_episodes.json") + acceptance.add_argument("--output", required=True, help="new acceptance output directory") + accept = subparsers.add_parser( + "accept-stage6", help="legacy alias for Stage-6 acceptance checks" + ) + accept.add_argument( + "--run-dir", + default="runs/stage6", + help="fresh output directory for acceptance journals", + ) + freeze = subparsers.add_parser("verify-model-freeze", help="verify frozen artifact hashes") + freeze.add_argument("--manifest", default="config/model_freeze.json") + export = subparsers.add_parser("export-journal", help="export complete journals to ZIP") + export.add_argument("--run", action="append", required=True, help="run id or directory") + export.add_argument("--output", required=True, help="new ZIP path") args = parser.parse_args(argv) try: if args.command == "validate-stage0": validation_result = validate_stage0() print(json.dumps(validation_result, ensure_ascii=False, sort_keys=True)) return 0 - if args.command == "run-model-demo": + if args.command in {"run-model-demo", "demo"}: recommendation = run_model_demo(args.scenario) print(recommendation.model_dump_json(indent=2)) return 0 @@ -458,29 +532,38 @@ def main(argv: Sequence[str] | None = None) -> int: if args.command == "train": training_result = train_command( args.dataset, - args.target_source, + SourceKind(args.target_source), args.output, target_signal=args.target_signal, + with_uncertainty=args.with_uncertainty, config_path=args.config, ) - print(json.dumps(training_result, ensure_ascii=False, indent=2, sort_keys=True)) + print(json.dumps(training_result, ensure_ascii=False, indent=2)) return 0 if args.command == "evaluate": evaluation_result = evaluate_command( args.dataset, args.model, - split=cast(Literal["validation", "test"], args.split), - output=args.output, + SourceKind(args.source), + args.output, + split=args.split, config_path=args.config, ) - print(json.dumps(evaluation_result, ensure_ascii=False, indent=2, sort_keys=True)) + print(json.dumps(evaluation_result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "replay": + replay_result = replay_command( + args.dataset, + args.model, + args.scenario, + args.at, + config_path=args.config, + run_dir=args.run_dir, + ) + print(replay_result.model_dump_json(indent=2)) return 0 - if args.command == "accept-stage6": - acceptance_result = accept_stage6(run_dir=args.run_dir) - print(json.dumps(acceptance_result, ensure_ascii=False, indent=2, sort_keys=True)) - return 0 if acceptance_result["passed"] else 1 if args.command == "run-history": - recommendation = run_history_command( + history_result = run_history_command( args.dataset, args.model, args.as_of, @@ -489,9 +572,45 @@ def main(argv: Sequence[str] | None = None) -> int: config_path=args.config, run_dir=args.run_dir, ) - print(recommendation.model_dump_json(indent=2)) + print(history_result.model_dump_json(indent=2)) + return 0 + if args.command == "acceptance": + from source.acceptance import run_acceptance_suite + + acceptance_result = run_acceptance_suite( + PROJECT_ROOT, + episodes_path=_resolve_path(args.episodes), + output_dir=_resolve_path(args.output), + ) + print(json.dumps(acceptance_result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "accept-stage6": + acceptance_result = accept_stage6(run_dir=args.run_dir) + print(json.dumps(acceptance_result, ensure_ascii=False, indent=2)) + return 0 if acceptance_result["passed"] else 1 + if args.command == "verify-model-freeze": + from source.acceptance import verify_model_freeze + + freeze_result = verify_model_freeze(PROJECT_ROOT, _resolve_path(args.manifest)) + print(json.dumps(freeze_result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "export-journal": + from source.acceptance import export_journals + + config = load_runtime_config(PROJECT_ROOT / "config/runtime.toml") + journals = [] + for value in args.run: + requested = Path(value) + directory = ( + _resolve_path(requested) + if requested.is_absolute() or len(requested.parts) > 1 + else PROJECT_ROOT / config.runs_dir / requested + ) + journals.append((directory.name, directory)) + archive = export_journals(journals, _resolve_path(args.output)) + print(json.dumps({"journal_archive": archive.as_posix()}, ensure_ascii=False)) return 0 - except (FileExistsError, FileNotFoundError, ValueError) as exc: + except (OSError, subprocess.SubprocessError, ValueError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 return 1 diff --git a/source/ml/evaluate.py b/source/ml/evaluate.py index 233b88f..a9c7041 100644 --- a/source/ml/evaluate.py +++ b/source/ml/evaluate.py @@ -22,8 +22,11 @@ class SupervisedDatasetLike(Protocol): """Small structural contract shared with ``ml.features``.""" - frame: pd.DataFrame - feature_names: tuple[str, ...] + @property + def frame(self) -> pd.DataFrame: ... + + @property + def feature_names(self) -> tuple[str, ...]: ... @dataclass(frozen=True) diff --git a/source/ml/train.py b/source/ml/train.py index 1f0acce..294faa7 100644 --- a/source/ml/train.py +++ b/source/ml/train.py @@ -53,11 +53,20 @@ class TrainingDatasetLike(Protocol): """Structural contract supplied by ``ml.features.SupervisedDataset``.""" - frame: pd.DataFrame - feature_names: tuple[str, ...] - target_signal_id: str - target_unit: str - target_source: object + @property + def frame(self) -> pd.DataFrame: ... + + @property + def feature_names(self) -> tuple[str, ...]: ... + + @property + def target_signal_id(self) -> str: ... + + @property + def target_unit(self) -> str: ... + + @property + def target_source(self) -> object: ... @dataclass(frozen=True) diff --git a/source/ml/uncertainty.py b/source/ml/uncertainty.py index d4be19f..ff49c13 100644 --- a/source/ml/uncertainty.py +++ b/source/ml/uncertainty.py @@ -64,8 +64,11 @@ class RobustnessCaseResult: class UncertaintyDataset(Protocol): - frame: pd.DataFrame - feature_names: tuple[str, ...] + @property + def frame(self) -> pd.DataFrame: ... + + @property + def feature_names(self) -> tuple[str, ...]: ... @dataclass(frozen=True) From b5d0f1775d11afa3982d31580c21b7f9918fd70a Mon Sep 17 00:00:00 2001 From: MatveyVarfolomeev Date: Sat, 12 Sep 2026 19:15:49 +0300 Subject: [PATCH 02/10] Use curated telemetry in final training --- source/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/source/main.py b/source/main.py index adcea33..b2173af 100644 --- a/source/main.py +++ b/source/main.py @@ -27,6 +27,7 @@ from source.ml.features import SupervisedDataset SplitName = Literal["train", "validation", "test"] +TRAINING_TELEMETRY_SIGNALS = ("ht:P8", "ht:T11", "ht:F19") PROJECT_ROOT = Path(__file__).resolve().parent.parent MODEL_DEMO_SCENARIOS: tuple[str, ...] = ("blend_normal", "blend_risk", "blend_missing") @@ -206,6 +207,7 @@ def _supervised_dataset( target_source=target_source, feature_source=SourceKind.PAK, horizon_minutes=60, + telemetry_signals=TRAINING_TELEMETRY_SIGNALS, ) From 933098ff439c7286677c68dd8d497de4a2a89c96 Mon Sep 17 00:00:00 2001 From: MatveyVarfolomeev Date: Sat, 12 Sep 2026 19:25:04 +0300 Subject: [PATCH 03/10] Freeze final models and acceptance evidence --- README.md | 4 ++ STAGE6.md | 48 ++++++++++++++++-- config/model_freeze.json | 44 +++++++++++++++++ global_tests/test_stage6_acceptance.py | 68 ++++++++++++++++++++++++++ source/acceptance.py | 6 +++ source/orchestrator.py | 43 ++++++++++++---- 6 files changed, 200 insertions(+), 13 deletions(-) create mode 100644 config/model_freeze.json diff --git a/README.md b/README.md index 3e4df2a..d3d58e7 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,10 @@ python -m source.main acceptance --output reports/final-acceptance python -m source.main verify-model-freeze ``` +`config/model_freeze.json` относится к артефактам, обученным на указанном в нём +`training_git_commit`. Модели не коммитятся; для точного воспроизведения нужно обучить их +на этом commit, затем вернуться в финальную ветку и выполнить проверку хешей. + `acceptance` создаёт `summary.json`, каталоги полных запусков и `journals.zip`. Повторный запуск требует нового output-каталога, поэтому ранее полученное доказательство не перезаписывается. Один или несколько обычных журналов экспортируются отдельно: diff --git a/STAGE6.md b/STAGE6.md index e886a64..4e982e7 100644 --- a/STAGE6.md +++ b/STAGE6.md @@ -98,9 +98,51 @@ manifest с SHA-256. ## Frozen результаты -Конкретные model IDs, хеши, исторические метрики и измеренное время приёмочного прогона -фиксируются в `config/model_freeze.json` и в итоговой таблице этого раздела после запуска -на чистом commit этапа 6. +Артефакты обучены на неизменённом commit +`ff6085d24ec53deb227f5aa3ab64bd3aab214bd0`, dataset `aacc7c1ab3d9`, Python 3.12.3, +scikit-learn 1.9.0. Полные SHA-256 находятся в `config/model_freeze.json`. + +| Роль | Model ID | Возможности | +| --- | --- | --- | +| Point forecast | `last_value-848324f28111` | forecast=true, uncertainty=false, actions=false | +| Point + upper | `sulfur-upper-e753ac99b59e` | forecast=true, uncertainty=true, actions=false | + +Validation сохранил persistence baseline: сложные модели имели больше пропущенных +превышений, поэтому не прошли защитный критерий. + +| Validation, ПАК | MAE, мг/кг | Доля пропущенных превышений | +| --- | ---: | ---: | +| Last value, выбран | 0.689 | 55.11% | +| Ridge | 0.773 | 83.64% | +| HGB | 0.768 | 85.41% | + +Итоговая историческая оценка на test 2026: + +| Источник цели | N | Baseline MAE | Model MAE | MAE около 8–12 | Пропуски >10 | Ложные тревоги | Coverage | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| ПАК | 31 819 | 0.684 | 0.684 | 0.790 | 28.58% | 4.96% | 100% | +| ЛИМС | 253 | 1.781 | 1.781 | 1.517 | 83.33% | 6.91% | 100% | + +Главный негативный срез — май 2026: MAE 0.889 по ПАК (`n=4464`) и 2.496 по ЛИМС +(`n=43`). Расхождение подтверждает, что хорошая оперативная метрика ПАК не заменяет +контроль по ЛИМС. + +Upper-модель на test ПАК: `n=31819`, coverage 96.34%, средняя ширина 1.944 мг/кг, +доля точек внутри train-only applicability bounds 63.36%, in-domain coverage 95.99%. +При стресс-сдвиге факта `+0.5 мг/кг` coverage падает до 84.33%; на последней четверти +test составляет 97.80%. Это диагностические показатели, а не safety guarantee. + +Historical replay на `2026-01-15T12:00:00+03:00` даёт point 6.223 мг/кг и upper +8.949 мг/кг. Ограничение серы проходит, но итог остаётся `abstain`: action capability +отключена, а подтверждённые факторы индекса надёжности отсутствуют. + +Приёмочный model-demo прогон на Intel64 Family 6 Model 186, 20 логических процессорах и +15.7 GiB RAM занял менее 0.02 с на один цикл; проверялся максимум 21 кандидат. Это время +короткого синтетического цикла, а не подготовки данных или обучения. + +Модели и полные residuals остаются локальными производными файлами согласно `.gitignore`. +Для точного восстановления frozen артефакта нужно обучать на указанном training commit; +финальная ветка проверяет полученные файлы командой `verify-model-freeze`. ## Ограничения и основные ошибки diff --git a/config/model_freeze.json b/config/model_freeze.json new file mode 100644 index 0000000..d65cfaf --- /dev/null +++ b/config/model_freeze.json @@ -0,0 +1,44 @@ +{ + "schema_version": "1.0", + "frozen_on": "2026-09-12", + "training_dataset_id": "aacc7c1ab3d9", + "training_git_commit": "ff6085d24ec53deb227f5aa3ab64bd3aab214bd0", + "python_version": "3.12.3", + "sklearn_version": "1.9.0", + "artifacts": [ + { + "role": "point_forecast", + "model_id": "last_value-848324f28111", + "path": "artifacts/models/last_value-848324f28111", + "model_sha256": "f6a2648569562fbdf452f7e3c12324d5b3da1876a528aad6377b49c2b0ceea37", + "metadata_sha256": "49aeaacf784df33c3dfab62c0db312c53e30fd24477fd0ea06d9b3cbfa7181e6", + "metrics_sha256": "c5a3db197430723a9f125383ada58f359748c885426192e3ca7e34bb3cb70bbf" + }, + { + "role": "point_and_upper_forecast", + "model_id": "sulfur-upper-e753ac99b59e", + "path": "artifacts/models/sulfur-upper-e753ac99b59e", + "model_sha256": "744b4ed2b29419104b11234cc6ec8b976346e655a1504a06c86528e7d8407f2c", + "metadata_sha256": "9b9716ff6035fa9bd0347057dc713b8c5021406aa148abc0aa85d95150586825", + "metrics_sha256": "4a853c515fe5648c05f0f8b2c15ed8d95bae58b5a125d275ccf477a0f408e283" + } + ], + "evaluation_reports": [ + { + "target_source": "pak", + "split": "test", + "path": "reports/final/last_value-848324f28111/pak_test/metrics.json", + "sha256": "ca770a1b7479ebec6c0b46a4ff902a60772828824c544c93c1284f03429743f6" + }, + { + "target_source": "lims", + "split": "test", + "path": "reports/final/last_value-848324f28111/lims_test/metrics.json", + "sha256": "345232219cf907f5dd35f9c6b8586e471fbf9d111ffddf49fb71141b12836123" + } + ], + "evidence_boundary": { + "history": "Forecast accuracy only; no causal action claim.", + "model_demo": "Synthetic blending effect only; no plant guarantee." + } +} diff --git a/global_tests/test_stage6_acceptance.py b/global_tests/test_stage6_acceptance.py index 26f74e1..4cee6a2 100644 --- a/global_tests/test_stage6_acceptance.py +++ b/global_tests/test_stage6_acceptance.py @@ -4,6 +4,7 @@ import json import zipfile +from datetime import UTC, datetime from pathlib import Path import pytest @@ -14,7 +15,19 @@ run_acceptance_suite, verify_model_freeze, ) +from source.config import load_runtime_config, load_scenario +from source.contracts import ( + DecisionContext, + Observation, + OperationMode, + ProcessState, + SignalSnapshot, + SourceKind, + Stage, + Validity, +) from source.ml.artifacts import sha256_file +from source.orchestrator import run_cycle PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -83,6 +96,8 @@ def test_model_freeze_verifies_hashes_and_rejects_tampering(tmp_path: Path) -> N "model_sha256": model_hash, "training_dataset_id": "dataset00001", "git_commit": "a" * 40, + "python_version": "3.12.3", + "sklearn_version": "1.9.0", "capabilities": {"supports_actions": False}, } ), @@ -95,6 +110,9 @@ def test_model_freeze_verifies_hashes_and_rejects_tampering(tmp_path: Path) -> N { "schema_version": "1.0", "training_dataset_id": "dataset00001", + "training_git_commit": "a" * 40, + "python_version": "3.12.3", + "sklearn_version": "1.9.0", "artifacts": [ { "model_id": "frozen-test", @@ -126,3 +144,53 @@ def test_failed_acceptance_does_not_publish_partial_directory(tmp_path: Path) -> with pytest.raises(ValueError, match="status changed"): run_acceptance_suite(PROJECT_ROOT, episodes_path=bad_catalog, output_dir=output) assert not output.exists() + + +def test_history_forecast_without_action_capability_abstains_explicitly( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + as_of = datetime(2026, 1, 15, 9, tzinfo=UTC) + observation = Observation( + id="pak-sulfur", + signal_id="ht:2:Mg.Sulfur", + stage=Stage.HYDROTREATMENT, + source=SourceKind.PAK, + measured_at=as_of, + available_at=as_of, + value=8.0, + unit="mg/kg", + validity=Validity.VALID, + source_ref="fixture", + ) + state = ProcessState( + state_id="history-state", + as_of=as_of, + dataset_id="dataset00001", + mode=OperationMode.HISTORY, + signals={ + "ht:2:Mg.Sulfur": SignalSnapshot( + selected=observation, + alternatives=(), + age_seconds=0, + fresh=True, + issues=(), + ) + }, + issues=(), + ) + monkeypatch.setattr("source.orchestrator._build_cycle_state", lambda *_: state) + + result = run_cycle( + data=None, + as_of=as_of, + model=None, + scenario=load_scenario(PROJECT_ROOT / "config/scenarios/history.json"), + config=load_runtime_config(PROJECT_ROOT / "config/runtime.toml"), + context=DecisionContext(), + run_dir=tmp_path, + ) + + assert result.status.value == "abstain" + assert result.selected is None + assert "ACTION_MODEL_UNAVAILABLE" in result.reason_codes diff --git a/source/acceptance.py b/source/acceptance.py index b5c53ae..06cb0d5 100644 --- a/source/acceptance.py +++ b/source/acceptance.py @@ -259,6 +259,12 @@ def verify_model_freeze(root: Path, manifest_path: Path) -> dict[str, Any]: raise ValueError("frozen model hash does not match metadata") if metadata.get("training_dataset_id") != payload.get("training_dataset_id"): raise ValueError("frozen model uses another training dataset") + if metadata.get("git_commit") != payload.get("training_git_commit"): + raise ValueError("frozen model uses another training code revision") + if metadata.get("python_version") != payload.get("python_version"): + raise ValueError("frozen model uses another Python version") + if metadata.get("sklearn_version") != payload.get("sklearn_version"): + raise ValueError("frozen model uses another scikit-learn version") if metadata.get("capabilities", {}).get("supports_actions") is not False: raise ValueError("Stage-6 forecast artifacts must not enable action control") if str(metadata.get("git_commit", "")).endswith("-dirty"): diff --git a/source/orchestrator.py b/source/orchestrator.py index fe351f4..a787fb6 100644 --- a/source/orchestrator.py +++ b/source/orchestrator.py @@ -183,6 +183,20 @@ def _required_rank_key(evaluation: CandidateEvaluation) -> tuple[float, float, f return evaluation.rank_key +def _supports_actions(model: object | None) -> bool: + if model is None: + return False + metadata = getattr(model, "metadata", None) + capabilities = ( + metadata.get("capabilities") + if isinstance(metadata, dict) + else getattr(metadata, "capabilities", None) + ) + if isinstance(capabilities, dict): + return capabilities.get("supports_actions") is True + return getattr(capabilities, "supports_actions", False) is True + + def _recheck_selected( state: ProcessState, selected: CandidateEvaluation | None, @@ -221,17 +235,26 @@ def run_cycle( status, selected, reason_codes, selection_reason = _select_result( evaluations, scenario, context, as_of ) + if scenario.mode is OperationMode.HISTORY and not _supports_actions(model): + status = RecommendationStatus.ABSTAIN + selected = None + reason_codes = tuple(dict.fromkeys((*reason_codes, "ACTION_MODEL_UNAVAILABLE"))) + selection_reason = "action_model_unavailable" _recheck_selected(state, selected, scenario) - alternatives = tuple( - item - for item in sorted( - ( - candidate - for candidate in evaluations - if candidate.feasible and candidate != selected - ), - key=_required_rank_key, - )[:3] + alternatives = ( + () + if status is RecommendationStatus.ABSTAIN + else tuple( + item + for item in sorted( + ( + candidate + for candidate in evaluations + if candidate.feasible and candidate != selected + ), + key=_required_rank_key, + )[:3] + ) ) result = Recommendation( run_id=str(uuid4()), From ac499f195910efbe8951c601a06a3958df2e3d7f Mon Sep 17 00:00:00 2001 From: bug00n Date: Mon, 21 Sep 2026 19:45:19 +0300 Subject: [PATCH 04/10] fix --- .gitignore | 1 + DESIGN.md | 1286 +++++++++-------- IMPLEMENTATION_PLAN.md | 451 +++--- PROJECT_GUIDE.md | 37 +- README.md | 44 +- STAGE1.md | 12 +- STAGE3.md | 10 +- STAGE4.md | 10 +- STAGE5.md | 8 +- STAGE6.md | 44 +- config/demo_episodes.json | 49 +- config/scenarios/blend_cetane_risk.json | 47 + config/scenarios/blend_missing.json | 115 +- config/scenarios/blend_normal.json | 115 +- config/scenarios/blend_risk.json | 115 +- config/scenarios/blend_t95_risk.json | 47 + .../fixtures/contracts/recommendation.json | 85 +- .../model_demo/blend_cetane_risk.json | 4 + .../fixtures/model_demo/blend_normal.json | 37 +- .../fixtures/model_demo/blend_risk.json | 43 +- .../fixtures/model_demo/blend_t95_risk.json | 4 + global_tests/test_stage0.py | 697 ++++----- global_tests/test_stage1_cycle.py | 65 +- global_tests/test_stage1_ml.py | 32 +- global_tests/test_stage1_ml_gaps.py | 2 +- global_tests/test_stage3_guardrails.py | 11 +- global_tests/test_stage4_blending.py | 35 +- global_tests/test_stage6_acceptance.py | 25 +- global_tests/test_ui.py | 25 +- source/acceptance.py | 181 ++- source/agents/effects.py | 176 ++- source/agents/optimizer.py | 45 +- source/constraints.py | 51 +- source/contracts.py | 1142 ++++++++------- source/data/prepare.py | 21 + source/explain.py | 36 +- source/main.py | 32 +- source/ml/blending.py | 198 ++- source/orchestrator.py | 51 +- source/ui.py | 122 +- 40 files changed, 3180 insertions(+), 2331 deletions(-) create mode 100644 config/scenarios/blend_cetane_risk.json create mode 100644 config/scenarios/blend_t95_risk.json create mode 100644 global_tests/fixtures/model_demo/blend_cetane_risk.json create mode 100644 global_tests/fixtures/model_demo/blend_t95_risk.json diff --git a/.gitignore b/.gitignore index b6662c6..97d5ca4 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ coverage.xml .pytest_cache/ .pytest_tmp/ .test_tmp/ +.test_tmp_seq_*/ cover/ # Translations diff --git a/DESIGN.md b/DESIGN.md index f4006ba..4745367 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,670 +1,672 @@ -# Технический дизайн системы рекомендаций Нефтекода - +# Технический дизайн системы рекомендаций Нефтекода + Версия контракта: **1.0**. Статус: **частично реализованная спецификация**. - -Документ содержит целевую архитектуру. Актуальный runtime: Stages 0–6, Tkinter UI -для `model_demo` и CLI для frozen training/evaluation/replay. Исторический ML-артефакт -ещё не подключён к UI. - -Основания: [техническое задание](materials/ТЗ_нефтекод.docx), [материалы](materials/README.md), [поэтапный план](IMPLEMENTATION_PLAN.md). ТЗ определяет обязательные требования, этот документ — технические контракты, план — порядок реализации. При изменении контракта документ и тестовые примеры обновляются в том же PR. - -## 1. Зафиксированные решения - -| Вопрос | Решение для версии 1 | -| --- | --- | -| Команда и ресурсы | Два исполнителя (человека или агента): backend и ML; 1–2 недели; CPU; без обязательных платных API | + +Документ содержит целевую архитектуру. Актуальный runtime: Stages 0–6, полный +синтетический паспорт S/T95/CN с присадкой в Tkinter UI и CLI для frozen +training/evaluation/replay. Исторический ML-артефакт ещё не подключён к UI. + +Основания: [техническое задание](materials/ТЗ_нефтекод.docx), [материалы](materials/README.md), [поэтапный план](IMPLEMENTATION_PLAN.md). ТЗ определяет обязательные требования, этот документ — технические контракты, план — порядок реализации. При изменении контракта документ и тестовые примеры обновляются в том же PR. + +## 1. Зафиксированные решения + +| Вопрос | Решение для версии 1 | +| --- | --- | +| Команда и ресурсы | Два исполнителя (человека или агента): backend и ML; 1–2 недели; CPU; без обязательных платных API | | Развёртывание | Одно локальное Python-приложение; toolchain нацелен на Python 3.11, локальный Stage-5 artifact собран в Python 3.12.3 | -| Мультиагентность | Агент качества, агент надёжности, агент оптимизации и оркестратор с явными входами и выходами | -| Взаимодействие | Синхронные вызовы Python-функций в одном процессе; структурированные результаты | +| Мультиагентность | Агент качества, агент надёжности, агент оптимизации и оркестратор с явными входами и выходами | +| Взаимодействие | Синхронные вызовы Python-функций в одном процессе; структурированные результаты | | UI | Tkinter desktop UI для model-demo; CLI для `prepare`, `build-state` и demo | -| Хранение | Исходные файлы; подготовленные CSV.gz; артефакты модели; JSON/JSONL с результатами | -| Модели | Сохранение последнего значения → Ridge → HistGradientBoostingRegressor; улучшение допускается по результатам временной валидации | -| Оптимизация | Детерминированный ограниченный перебор; жёсткий фильтр перед ранжированием | -| Блендинг | Модель массового смешения с явно заданными компонентами и допущениями | -| Объяснение | Шаблон из численных результатов и причин проверок; не влияет на решение | -| Управление установкой | Только рекомендации оператору; команд исполнительным устройствам нет | - -В версии 1 не вводятся HTTP API, брокер сообщений, микросервисы, СУБД, оркестратор LLM, обучение в интерфейсе или собственный фреймворк агентов. Новые зависимости: `openpyxl` для Excel; `tzdata` для часовых поясов на Windows, где системная база IANA может отсутствовать ([документация Python](https://docs.python.org/3.11/library/zoneinfo.html)). Остальной обязательный стек уже указан в репозитории. - -### Три режима работы - -| Режим | Что является фактом | Что система вправе утверждать | -| --- | --- | --- | -| `history` | Исторические измерения, доступные к выбранному моменту | Текущее состояние, прогноз и предупреждения. Изменения реальных уставок разрешены только при прошедшей проверку модели последствий | -| `model_demo` | Численные входы из сценария, помеченные как модельные | Допустимость и эффект внутри объявленной модели; не промышленная гарантия | -| `hybrid` | История АВТ/гидроочистки плюс модельная рецептура | Прогноз качества реального потока и условный результат его модельного смешения | - -Прогноз `y(t + h)` на истории и оценка `y(t + h | действие)` — разные возможности. У модели отдельные признаки `supports_forecast` и `supports_actions`. Хороший MAE не включает вторую возможность автоматически. При `supports_actions=false` оптимизатор не подставляет новые уставки в обычный предиктор. - -В режиме `history` без модели последствий возвращается `abstain` с причиной `ACTION_MODEL_UNAVAILABLE`, но оценки состояния и качества сохраняются и показываются. Проверенный пример `hold` демонстрируется в `model_demo`; после проверки модели последствий он доступен и на истории. - -## 2. Компоненты и зависимости - -```mermaid -flowchart TD - Raw[CSV и Excel из materials] --> Prepare[Подготовка данных] - Prepare --> Store[Подготовленные данные и manifest] - Store --> State[Состояние на момент t] - Store --> Train[Офлайн обучение и оценка] - Train --> Model[Модель и metadata] +| Хранение | Исходные файлы; подготовленные CSV.gz; артефакты модели; JSON/JSONL с результатами | +| Модели | Сохранение последнего значения → Ridge → HistGradientBoostingRegressor; улучшение допускается по результатам временной валидации | +| Оптимизация | Детерминированный ограниченный перебор; жёсткий фильтр перед ранжированием | +| Блендинг | Модель S/T95/CN, сценарные границы и конфигурируемая цетановая присадка | +| Объяснение | Шаблон из численных результатов и причин проверок; не влияет на решение | +| Управление установкой | Только рекомендации оператору; команд исполнительным устройствам нет | + +В версии 1 не вводятся HTTP API, брокер сообщений, микросервисы, СУБД, оркестратор LLM, обучение в интерфейсе или собственный фреймворк агентов. Новые зависимости: `openpyxl` для Excel; `tzdata` для часовых поясов на Windows, где системная база IANA может отсутствовать ([документация Python](https://docs.python.org/3.11/library/zoneinfo.html)). Остальной обязательный стек уже указан в репозитории. + +### Три режима работы + +| Режим | Что является фактом | Что система вправе утверждать | +| --- | --- | --- | +| `history` | Исторические измерения, доступные к выбранному моменту | Текущее состояние, прогноз и предупреждения. Изменения реальных уставок разрешены только при прошедшей проверку модели последствий | +| `model_demo` | Численные входы из сценария, помеченные как модельные | Допустимость и эффект внутри объявленной модели; не промышленная гарантия | +| `hybrid` | История АВТ/гидроочистки плюс модельная рецептура | Прогноз качества реального потока и условный результат его модельного смешения | + +Прогноз `y(t + h)` на истории и оценка `y(t + h | действие)` — разные возможности. У модели отдельные признаки `supports_forecast` и `supports_actions`. Хороший MAE не включает вторую возможность автоматически. При `supports_actions=false` оптимизатор не подставляет новые уставки в обычный предиктор. + +В режиме `history` без модели последствий возвращается `abstain` с причиной `ACTION_MODEL_UNAVAILABLE`, но оценки состояния и качества сохраняются и показываются. Проверенный пример `hold` демонстрируется в `model_demo`; после проверки модели последствий он доступен и на истории. + +## 2. Компоненты и зависимости + +```mermaid +flowchart TD + Raw[CSV и Excel из materials] --> Prepare[Подготовка данных] + Prepare --> Store[Подготовленные данные и manifest] + Store --> State[Состояние на момент t] + Store --> Train[Офлайн обучение и оценка] + Train --> Model[Модель и metadata] UI[Tkinter UI или CLI] --> Orch[Оркестратор] - State --> Orch - Model --> Quality[Агент качества] - Orch --> Quality - Orch --> Reliability[Агент надёжности] - Orch --> Optimizer[Агент оптимизации] - Optimizer --> Effects[Расчёт последствий и блендинг] - Effects --> Quality - Effects --> Reliability - Optimizer --> Constraints[Единая проверка ограничений] - Orch --> Constraints - Orch --> Explain[Шаблон объяснения] - Orch --> Journal[Журнал решения] - Explain --> UI -``` - -Диаграмма — поток данных и вызовов, не сетевые сервисы. Результат возвращается через `return`. - -### Правила импортов - -- `contracts.py` не импортирует другие модули проекта. -- `config.py` зависит только от контрактов и стандартного чтения TOML/JSON. -- `data/*` и `ml/features.py` не импортируют UI, оркестратор или оптимизатор. -- `ml/train.py` и `ml/evaluate.py` используются только офлайн; при открытии UI они не импортируются. + State --> Orch + Model --> Quality[Агент качества] + Orch --> Quality + Orch --> Reliability[Агент надёжности] + Orch --> Optimizer[Агент оптимизации] + Optimizer --> Effects[Расчёт последствий и блендинг] + Effects --> Quality + Effects --> Reliability + Optimizer --> Constraints[Единая проверка ограничений] + Orch --> Constraints + Orch --> Explain[Шаблон объяснения] + Orch --> Journal[Журнал решения] + Explain --> UI +``` + +Диаграмма — поток данных и вызовов, не сетевые сервисы. Результат возвращается через `return`. + +### Правила импортов + +- `contracts.py` не импортирует другие модули проекта. +- `config.py` зависит только от контрактов и стандартного чтения TOML/JSON. +- `data/*` и `ml/features.py` не импортируют UI, оркестратор или оптимизатор. +- `ml/train.py` и `ml/evaluate.py` используются только офлайн; при открытии UI они не импортируются. - `agents/*` используют контракты, признаки и численные модели, но не UI и не журнал. -- `constraints.py` — единственная реализация жёстких проверок; его вызывают оптимизатор и оркестратор. -- `orchestrator.py` координирует расчёт, не содержит формул обучения и смешения. -- `main.py` и `ui.py` собирают зависимости явно и вызывают общий `run_cycle`; DI-контейнер не нужен. -- Только слой подготовки пишет подготовленные данные; только обучение пишет модели; только `journal.py` пишет журналы решений. - -## 3. Структура файлов и владельцы - -Файлы создаются по этапам, без пустых заготовок «на будущее». - -```text -HackathonPetrolCode/ -├── README.md # запуск и ограничения готовой версии -├── IMPLEMENTATION_PLAN.md # сроки, этапы и критерии готовности -├── DESIGN.md # архитектура и контракты -├── pyproject.toml # Ruff и pytest; заменяет config.toml -├── requirements.txt # проверенные зависимости Python 3.11 -├── .github/workflows/ci.yml # проверки кода и малых сценариев -├── config/ -│ ├── runtime.toml # общие экспериментальные настройки -│ ├── tags.csv # подтверждённые соответствия и единицы -│ └── scenarios/ -│ ├── history.json # режим истории; реальные controls отключены -│ ├── blend_normal.json # нормальный модельный период -│ ├── blend_risk.json # модельное превышение серы -│ └── blend_missing.json # модельная нехватка данных -├── source/ -│ ├── __init__.py -│ ├── main.py # CLI и сборка зависимостей +- `constraints.py` — единственная реализация жёстких проверок; его вызывают оптимизатор и оркестратор. +- `orchestrator.py` координирует расчёт, не содержит формул обучения и смешения. +- `main.py` и `ui.py` собирают зависимости явно и вызывают общий `run_cycle`; DI-контейнер не нужен. +- Только слой подготовки пишет подготовленные данные; только обучение пишет модели; только `journal.py` пишет журналы решений. + +## 3. Структура файлов и владельцы + +Файлы создаются по этапам, без пустых заготовок «на будущее». + +```text +HackathonPetrolCode/ +├── README.md # запуск и ограничения готовой версии +├── IMPLEMENTATION_PLAN.md # сроки, этапы и критерии готовности +├── DESIGN.md # архитектура и контракты +├── pyproject.toml # Ruff и pytest; заменяет config.toml +├── requirements.txt # проверенные зависимости Python 3.11 +├── .github/workflows/ci.yml # проверки кода и малых сценариев +├── config/ +│ ├── runtime.toml # общие экспериментальные настройки +│ ├── tags.csv # подтверждённые соответствия и единицы +│ └── scenarios/ +│ ├── history.json # режим истории; реальные controls отключены +│ ├── blend_normal.json # нормальный модельный период +│ ├── blend_risk.json # модельное превышение серы +│ ├── blend_t95_risk.json # модельное превышение T95 +│ ├── blend_cetane_risk.json # модельный дефицит цетанового числа +│ └── blend_missing.json # модельная нехватка данных +├── source/ +│ ├── __init__.py +│ ├── main.py # CLI и сборка зависимостей │ ├── ui.py # Tkinter; model-demo и диагностические команды -│ ├── contracts.py # Pydantic-типы обмена -│ ├── config.py # чтение и валидация настроек -│ ├── constraints.py # единый фильтр допустимости -│ ├── orchestrator.py # run_cycle и выбор результата -│ ├── explain.py # русские шаблоны объяснений -│ ├── journal.py # JSON/JSONL и атомарная запись -│ ├── data/ -│ │ ├── __init__.py -│ │ ├── ingest.py # чтение исходных CSV и Excel -│ │ ├── prepare.py # нормализация, manifest, диагностика -│ │ └── state.py # доступные на момент t данные -│ ├── ml/ -│ │ ├── __init__.py -│ │ ├── features.py # общие признаки обучения и применения -│ │ ├── train.py # baselines, подбор, сохранение модели -│ │ ├── evaluate.py # временная оценка и отчёты -│ │ └── artifacts.py # ModelBundle, загрузка и metadata -│ └── agents/ -│ ├── __init__.py -│ ├── quality.py # текущее качество и прогноз -│ ├── reliability.py # тяжесть режима и объясняющие факторы -│ ├── optimizer.py # кандидаты, оценка и ранжирование -│ └── effects.py # последствия действий и формулы смеси -├── global_tests/ -│ ├── workflow_test.py # существующий smoke заменяется запуском цикла -│ ├── test_data.py # источники, единицы, время -│ ├── test_features.py # отсутствие утечек и train/serve parity -│ ├── test_decisions.py # ограничения, выбор, отказы -│ ├── test_artifacts.py # несовместимые и отсутствующие модели -│ └── fixtures/ # малые синтетические данные для CI -├── materials/ # неизменяемые выданные материалы -├── data/processed// # локальные производные данные -├── artifacts/models// # локальные артефакты моделей -├── reports// # метрики и параметры эксперимента -└── runs// # воспроизводимый журнал одного запуска -``` - -| Владелец | Файлы и обязательства | -| --- | --- | -| Backend | `main`, `ui`, `contracts`, `config`, `constraints`, `orchestrator`, `explain`, `journal`, `data/*`, CI и запуск | -| ML | `ml/*`, `agents/*`, смысл `tags.csv`, формулы, выбранные ограничения, модельные сценарии и отчёты | -| Общие файлы с одним автором | `contracts.py`, `runtime.toml`, зависимости и этот дизайн редактирует backend после согласования семантики с ML; `tags.csv` и JSON-сценарии редактирует ML после проверки совместимости с backend | - -Backend не дублирует признаки в UI; ML не пишет альтернативный загрузчик данных в ноутбуке. Ноутбуки — для исследования; итоговый запуск использует указанные `.py`-модули. - -Производные данные, модели и журналы исключаются из обычного Git. Малые синтетические fixtures, настройки, отчёты с агрегированными метриками и документация могут храниться в Git. Существующий `materials/data.rar` остаётся в Git LFS; его не дублируют распакованными CSV в коммитах. - -## 4. Подготовленные данные и время - -### Форматы на диске - -| Файл в наборе данных | Формат и назначение | -| --- | --- | -| `telemetry.csv.gz` | Одна строка на timestamp; `timestamp` и численные колонки с ключами `avt:T33`, `ht:F26` и т. п. | -| `quality.csv.gz` | Длинная таблица: `observation_id, signal_id, stage, source, measured_at, available_at, value, unit, validity, source_ref` | -| `issues.csv.gz` | `source_ref, code, detail`; ошибки и конфликты исходников, без молчаливого удаления | -| `manifest.json` | SHA-256 исходников, настроек и словаря; временные диапазоны, число строк, версия подготовки, допущения | - -`dataset_id` — первые 12 символов SHA-256 от отсортированного списка полных хешей исходников, конфигурации подготовки, словаря и версии кода подготовки. Полные хеши сохраняются в manifest; при совпадении короткого ID и разных полных хешах запись запрещается. Кэш переиспользуется только при полном совпадении manifest. - -### Нормализация - -1. RAR распаковывается локально в отдельный каталог; ожидаются только `data/avt_tags.csv` и `data/242000_tags.csv`. Имена и пути проверяются до извлечения. Исходный архив остаётся неизменным. Для Windows использовать доступный `tar`, для Linux в инструкции указать `bsdtar`. -2. CSV объединяются по `date`. Из первой таблицы удаляются `Unnamed:*`, из второй — пустой индексный заголовок. Технологические теги получают префикс установки. -3. Excel читается по заголовкам точек и показателей. Каждая пара «время — значение» преобразуется независимо; номера строк разных показателей не связываются. -4. `Pt Created`, текст вместо числа, бесконечности и повреждённые даты получают запись в `issues`. Численное значение становится отсутствующим, не нулём. -5. Одинаковые дубликаты схлопываются с сохранением происхождения. Противоречащие значения одного источника в один момент получают `conflict` и не выбираются произвольно. -6. Единицы преобразуются только по подтверждённому правилу: массовые ppm → мг/кг без изменения числа; массовые проценты серы → мг/кг умножением на 10 000. Для неоднозначного `ppm` сначала подтверждается массовая база. Массовый и объёмный расход не отождествляются. -7. Значения вне привычного диапазона не удаляются автоматически. Ошибка датчика, насыщение анализатора и реальная аварийная ситуация различаются только при наличии основания; иначе остаётся предупреждение. - -### Словарь тегов - -`config/tags.csv` содержит поля `signal_id, raw_name, stage, meaning, raw_unit, canonical_unit, conversion, mapping_status, controllable, evidence_ref`. - -- `mapping_status`: `confirmed`, `ambiguous`, `excluded`. -- `conversion`: именованная функция из фиксированного набора, а не исполняемая строка. -- `controllable=true` требует подтверждения в `evidence_ref`; наличие численного ряда этого не подтверждает. -- В формулах и UI используются канонические ключи. Короткие имена вроде `T6` не определяют физический смысл. -- Некорректную формулу ВАК не исправляют догадкой. Её исключают до проверки; утверждённые формулы переносят в явные Python-функции, без `eval` содержимого Excel. - -### Временные правила - -Внутри приложения — timezone-aware UTC. Исходные даты без timezone интерпретируются согласно `source_timezone`. `Europe/Moscow` по умолчанию — **экспериментальное допущение**, не установленное свойство пакета; записывается в manifest. UI показывает часовой пояс рядом со временем. - -- `measured_at` — время измерения/отбора пробы. -- `available_at` — время, с которого результат мог быть известен системе. -- Для телеметрии и ПАК начальное допущение: `available_at = measured_at`. -- Для ЛИМС при отсутствии точного времени публикации: `available_at = measured_at + lims_delay_hours`. По ответу эксперта от 10.09.2026 публикация занимает до 4 часов; консервативное значение по умолчанию — 4 часа. Чувствительность к меньшей задержке оценивается без подбора на финальном test. -- В состоянии на `t` разрешены только записи с `measured_at <= t` и `available_at <= t`. -- Возраст всегда считается от `measured_at`, не от времени загрузки файла. -- Для каждого сигнала выбирается последнее доступное измерение по времени. -- Начальные пределы свежести: телеметрия 20 минут, ПАК 30 минут, ЛИМС 48 часов. Это модельные настройки, проверяемые на валидации, а не технологические нормативы. -- Пригодный свежий ЛИМС имеет приоритет над ПАК; затем идёт проверенный ВАК. Остальные источники сохраняются для сравнения. -- Устаревший ЛИМС показывается отдельно. Свежий пригодный ПАК может стать оперативным источником с предупреждением; устаревший анализ не становится текущей истиной. -- Расхождение источников не проверяется на произвольных несинхронных значениях: ПАК сопоставляется с временем отбора лабораторной пробы, но сам факт конфликта появляется только после `available_at` ЛИМС. Допуск расхождения обязателен в настройках активного показателя; неподтверждённый допуск не придумывается в коде. -- Неразрешённый конфликт обязательного показателя блокирует рекомендацию. Лабораторный результат остаётся контрольным фактом. - -Отсутствие необязательного сигнала даёт предупреждение. Отсутствие обязательного входа модели или ограничения блокирует соответствующий расчёт. Условия определяются явно в scenario/model metadata. - -## 5. Типы обмена между компонентами - -Типы реализуются в `contracts.py` через Pydantic. Числа конечны; отсутствие — `None`. Во внешнем JSON — `null`, никогда `NaN`. Неизвестные поля отклоняются. Строковые перечисления сериализуются своими значениями. Агентам передаются новые результаты; они не меняют входной `ProcessState` и конфигурацию. - -Обозначения: `Timestamp` — timezone-aware `datetime`; `SignalId`, `CandidateId` и `RunId` — строки; `Unit` — каноническая строка единицы. Поля таблиц обязательны; `| None` разрешает отсутствие значения, но поле остаётся в JSON. - -### Состояние и измерения - -| Тип | Поля | -| --- | --- | -| `Issue` | `code: str`, `severity: Literal['warning','blocking']`, `signal_id: str \| None`, `detail: str`, `source_ref: str \| None` | -| `Observation` | `id: str`, `signal_id: str`, `stage: Literal['avt','ht','blend']`, `source: Literal['telemetry','lims','pak','vak','scenario']`, `measured_at: Timestamp`, `available_at: Timestamp`, `value: float \| None`, `unit: str`, `validity: Literal['valid','missing','invalid','conflict']`, `source_ref: str` | -| `SignalSnapshot` | `selected: Observation \| None`, `alternatives: list[Observation]`, `age_seconds: float \| None`, `fresh: bool`, `issues: list[Issue]` | -| `ProcessState` | `schema_version: Literal['1.0']`, `state_id: str`, `as_of: Timestamp`, `dataset_id: str`, `mode: Literal['history','model_demo','hybrid']`, `signals: dict[str, SignalSnapshot]`, `issues: list[Issue]` | - -`signals` содержит все ожидаемые сценарием сигналы, в том числе отсутствующие. Исторические окна для модели не вкладываются целиком в состояние: признаки строятся через ограниченный моментом `as_of` доступ к подготовленным данным и отдельно сохраняются в журнале. - -### Действия, оценки и проверки - -| Тип | Поля | -| --- | --- | -| `CandidateAction` | `id: str`, `kind: Literal['hold','setpoints','blend']`, `setpoints: dict[str,float]`, `blend_mass_fractions: dict[str,float]`, `horizon_minutes: int`, `is_model_scenario: bool` | -| `MetricEstimate` | `value: float \| None`, `lower: float \| None`, `upper: float \| None`, `unit: str`, `basis: Literal['measured','forecast','formula','proxy']`, `interval_kind: Literal['none','empirical','scenario_bound']`, `interval_level: float \| None`, `reference: str`, `assumptions: list[str]` | -| `AgentAssessment` | `agent: Literal['quality','reliability','optimizer']`, `state_id: str`, `candidate_id: str`, `evaluated_for: Timestamp`, `status: Literal['ok','degraded','unavailable']`, `metrics: dict[str,MetricEstimate]`, `issues: list[Issue]` | -| `ConstraintResult` | `constraint_id: str`, `candidate_id: str`, `status: Literal['pass','fail','unknown']`, `actual: float \| None`, `lower: float \| None`, `upper: float \| None`, `unit: str \| None`, `basis: Literal['tz','confirmed','model_assumption']`, `evidence_ref: str`, `reason_code: str` | -| `CandidateEvaluation` | `candidate: CandidateAction`, `assessments: list[AgentAssessment]`, `checks: list[ConstraintResult]`, `feasible: bool`, `rank_key: tuple[float,float,float,float,str] \| None` | - -Правила: - -- `setpoints` содержит **новые абсолютные значения**, не приращения; единицы определяются `ControlSpec`. Разницу UI вычисляет относительно состояния. -- Для `hold` обе карты пустые: сохраняются текущие уставки и текущая рецептура. Для `blend` задаётся полная рецептура, не только изменённые доли. В первой версии кандидат не совмещает изменение уставок и рецептуры. -- У всех оценок кандидата совпадают `state_id`, `candidate_id` и горизонт. Прогноз вычисляется на `as_of + horizon`; измерение текущего состояния подписывается отдельно. -- `MetricEstimate` с `basis='proxy'` не называется физической величиной без установленного соответствия. Прокси затрат имеют единицу `proxy_unit`, риска — `index_0_1`. -- `interval_kind='scenario_bound'` — заданная граница сценария, не статистическая вероятность. `interval_level` для неё равен `None`. -- `feasible=true` только если все обязательные проверки имеют `pass`. `unknown` не равно успешной проверке. При отсутствии обязательной оценки `rank_key=None`. - -### Итог цикла - -| Тип | Поля | -| --- | --- | -| `Recommendation` | `schema_version: Literal['1.0']`, `run_id: str`, `state_id: str`, `as_of: Timestamp`, `scenario_id: str`, `mode: str`, `status: Literal['recommend','hold','abstain']`, `baseline: CandidateEvaluation \| None`, `selected: CandidateEvaluation \| None`, `alternatives: list[CandidateEvaluation]`, `reason_codes: list[str]`, `explanation: str`, `assumptions: list[str]`, `model_id: str \| None` | -| `RunFailure` | `run_id: str`, `occurred_at: Timestamp`, `error_code: str`, `stage: str`, `message: str` | - -Инварианты: при `recommend` выбран допустимый ненулевой кандидат; при `hold` выбран допустимый кандидат `hold`; при `abstain` `selected=None` и есть причина. `baseline` может быть недопустимым — это исходная точка сравнения, а не разрешённое действие. В `alternatives` идут до трёх допустимых альтернатив, полный список сохраняется в журнале. - -`RunFailure` — техническая ошибка выполнения, не технологический отказ. Система не превращает исключение Python в ответ «безопасных режимов нет». - -## 6. Конфигурация и интерфейсы функций - -### Конфигурационные типы - -| Тип | Содержание | -| --- | --- | -| `RuntimeConfig` | `source_timezone`, задержки и пределы свежести; `horizon_minutes=60`; `seed=42`; `max_candidates=125`; пути к данным/моделям/журналам | -| `ControlSpec` | `signal_id`, `unit`, `lower`, `upper`, `max_step`, `step`, `evidence_ref`, `basis`, `enabled` | -| `ConstraintSpec` | `id`, `metric`, `stage`, `lower`, `upper`, `unit`, `use_upper_estimate`, `required`, `basis`, `evidence_ref` | -| `BlendComponent` | `id`, `sulfur: MetricEstimate`, `available_mass_t`, `cost_proxy_per_t`, `risk_index`, `source_state_id: str \| None` | -| `ScenarioConfig` | `id`, `mode`, обязательные сигналы, controls, constraints, компоненты, `total_mass_t`, текущая рецептура, список активных критериев, пороги существенности улучшения, `require_upper_bound`, `action_cooldown_minutes`, допущения | -| `DecisionContext` | `last_recommended_at: Timestamp \| None`; время последней выданной рекомендации для подавления повторов | - -Численные технологические границы без источника не имеют значения по умолчанию. Тогда `enabled=false`. Активное ограничение без предела, единицы или обоснования — ошибка конфигурации до запуска цикла. Граница серы смеси `upper=10`, `unit='mg/kg'`, `basis='tz'` обязательна для любого сценария товарного смешения. - -Доли считаются равными 1 при абсолютной погрешности не более `1e-9`. Этот допуск используется только для арифметики долей, не для разрешения серы выше 10 мг/кг. Сравнение качества выполняется до округления UI. - -### Вызываемые интерфейсы - -`PreparedData` и `ModelBundle` — обычные локальные dataclass-контейнеры, а не сетевые DTO. `FeatureFrame` — `pandas.DataFrame` с фиксированным порядком признаков. - -```text -prepare_dataset(materials_dir: Path, config: RuntimeConfig) -> PreparedData -build_state(data: PreparedData, as_of: datetime, - scenario: ScenarioConfig, config: RuntimeConfig) -> ProcessState -build_features(data: PreparedData, as_of: datetime, - state: ProcessState, model: ModelBundle) -> FeatureFrame -predict_quality(state: ProcessState, features: FeatureFrame, - model: ModelBundle | None, scenario: ScenarioConfig) -> AgentAssessment -assess_reliability(state: ProcessState, - scenario: ScenarioConfig) -> AgentAssessment -generate_candidates(state: ProcessState, - scenario: ScenarioConfig) -> list[CandidateAction] -evaluate_candidates(state: ProcessState, features: FeatureFrame, - candidates: list[CandidateAction], model: ModelBundle | None, - scenario: ScenarioConfig) -> list[CandidateEvaluation] -check_constraints(state: ProcessState, candidate: CandidateAction, - assessments: list[AgentAssessment], - scenario: ScenarioConfig) -> list[ConstraintResult] -run_cycle(data: PreparedData, as_of: datetime, model: ModelBundle | None, - scenario: ScenarioConfig, config: RuntimeConfig, - context: DecisionContext, run_dir: Path) -> Recommendation -``` - -`predict_quality` и `assess_reliability` оценивают исходное состояние с `candidate_id='hold'`. `effects.py` оценивает действие в отдельном прогнозном состоянии/оценках; исходный объект не изменяется. Данные, рассчитанные моделью, получают соответствующий `basis`, а не `measured`. - -В `model_demo` без ML-модели `build_features` не вызывается: передаётся пустой `FeatureFrame`, а качество считается из компонентов сценария. Baseline на истории оформляется как `ModelBundle` с явным типом predictor; отсутствие необходимой модели не переключает режим автоматически. - -`PreparedData` хранит отсортированную телеметрию, длинную таблицу качества, issues и manifest. `ModelBundle` хранит predictor, порядок признаков, preprocessing, метаданные и при наличии модель последствий. Backend не должен знать внутреннюю структуру sklearn estimator. - -## 7. Один цикл принятия решения - -```mermaid -sequenceDiagram - participant UI as UI или CLI - participant O as Оркестратор - participant D as Данные и признаки - participant Q as Качество - participant R as Надёжность - participant P as Оптимизатор - participant C as Ограничения - participant J as Журнал - UI->>O: run_cycle(t, scenario, model) - O->>D: build_state и build_features только до t - D-->>O: состояние и признаки - O->>Q: predict_quality - Q-->>O: качество, доверие, причины - O->>R: assess_reliability - R-->>O: индекс и факторы - alt Обязательных данных или модели последствий нет - O->>J: оценки и abstain с причиной - else Оценка действий доступна - O->>P: кандидаты, включая hold - loop Каждый кандидат - P->>Q: прогноз качества последствий - P->>R: оценка риска последствий - P->>C: check_constraints - C-->>P: pass, fail или unknown - end - P-->>O: оценки и ранжирование - O->>C: повторная проверка выбранного кандидата - O->>J: входы, все оценки, решение - end - O-->>UI: Recommendation и объяснение -``` - -Порядок внутри `run_cycle`: - -1. Проверить совместимость config/scenario/model и создать `run_id`. -2. Построить состояние и признаки. Сохранить применённые допущения и идентификаторы выбранных наблюдений. -3. Получить оценки качества и надёжности. Если роль недоступна, сохранить доступные результаты остальных. -4. При блокирующих входах вернуть `abstain`; не генерировать видимость оптимизации. -5. Создать `hold` и разрешённые кандидаты. Предварительно отсеять нарушение формата, управления и рецептуры. -6. Рассчитать последствия, затем выполнить полный `check_constraints`. Никакие баллы не используются до этого фильтра. -7. Выбрать лучший допустимый вариант по правилу ниже. При отсутствии вариантов — `abstain`. -8. Повторно проверить выбранный кандидат тем же кодом ограничений. Расхождение результатов — `RunFailure`, а не автоматический переход к другому ответу. -9. Построить объяснение исключительно из сохранённых оценок; записать журнал; вернуть результат UI/CLI. - -### Правило выбора - -Ключ сортировки: `(risk_index, -throughput, cost_proxy, change_size, candidate_id)`, по возрастанию. Последний элемент разрешает равенство. Индексы/затраты сравниваются только внутри одного сценария с одинаковыми определениями. Если критерий не обеспечен данными, его **заранее** отключают в конфигурации для всех кандидатов с нейтральным местом в ключе; это отображается в UI. Пропуск у отдельного кандидата по активному критерию делает его оценку недоступной. - -`change_size` для уставок — сумма `abs(new-current)/step`; для рецептуры — сумма абсолютных изменений массовых долей. - -Если `hold` допустим, изменение выдаётся только при существенном улучшении первого отличающегося активного критерия. Начальные экспериментальные пороги: риск 0.02 абсолютных пункта, выпуск 2%, затраты 2%. При сравнении около нулевой базы используются обязательные абсолютные допуски сценария. Разница только в `change_size` не является улучшением процесса. Иначе возвращается `hold`. - -Если `hold` недопустим, порог экономической существенности не мешает выбрать допустимое действие. Начальный cooldown — 60 минут: он подавляет повторную выдачу изменений только при допустимом `hold`. При нарушении качества система повторно предупреждает и оценивает варианты независимо от cooldown. Совет не означает его выполнение: состояние меняется только по новым данным/явно запущенной симуляции. - -`DecisionContext` относится к одному сценарию и последовательному воспроизведению. При смене сценария или переходе назад по времени он сбрасывается; иначе обновляется только после `recommend`. Контекст сохраняется в журнале и учитывается при сравнении повторных запусков. - -## 8. ML, неопределённость и модель последствий - -### Признаки и обучение - -- Первая цель — сера на выходе гидроочистки через 60 минут. Эта точка не отождествляется автоматически с товарной смесью. -- Телеметрические лаги первой версии: 10, 30, 60 и 180 минут; прошлые средние и стандартные отклонения в окнах 60 и 180 минут. Окно заканчивается на `as_of`, будущие значения не участвуют. -- Исходные признаки берутся только из подтверждённого словаря. Сера анализаторов и её лаги явно маркируются как autoregressive-признаки; тот же будущий целевой анализ не может попасть в входы. -- Признаки, отсутствующие во всём обучающем периоде, исключаются. Появление ПАК-плотности в 2025 году не позволяет использовать её как обученный признак модели 2023–2024 без отдельного эксперимента. -- Imputer/scaler и отбор признаков обучаются только на train и сохраняются вместе с моделью. Для Ridge — медианная импутация и масштабирование; для HGB — фиксированная обработка пропусков. Строки без цели не становятся обучающими примерами. -- Для ПАК цель выбирается на сетке `t+60 минут`; без точного допустимого наблюдения пример исключается; цель не переносится из будущего. Для ЛИМС создаётся один пример на реальный анализ: `as_of = measured_at - 60 минут`. -- ПАК и ЛИМС не объединяются в одну безымянную цель. Модель ПАК оценивается по ПАК и отдельно по контрольным ЛИМС; перенос источника отражается в отчёте. -- Начальное разделение: train до 2025-01-01; validation до 2026-01-01; test с 2026-01-01 по конец доступной телеметрии. Границы задаются в исходном часовом поясе и преобразуются в UTC. -- Внутри train — три последовательных временных блока для подбора. Цели, пересекающие границу следующего блока, исключаются. Внутреннее early stopping у HGB отключается (`early_stopping=False`); число итераций выбирается временной валидацией, без автоматической внутренней проверочной выборки ([описание параметров](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.HistGradientBoostingRegressor.html)). -- Метрики: MAE, MAE в области 8–12 мг/кг, пропущенные превышения 10 мг/кг, ложные тревоги; для каждого показателя число примеров. Если превышений нет, соответствующая доля — `null` с пояснением. -- Выбор модели выполняется до test: минимизировать MAE при числе пропущенных превышений не больше baseline; при равенстве оставить более простую модель. Если подходящих моделей нет — baseline сохраняется, ограничение качества указывается в отчёте. - -### Неопределённость - -До этапа 5 прогноз без интервала имеет `interval_kind='none'`. Он не удовлетворяет сценарию с `require_upper_bound=true`. Первая сквозная демонстрация использует явные границы модельных компонентов, поэтому не зависит от готовности статистических интервалов. - -На этапе 5: верхняя квантильная оценка 0.95; первая половина validation по времени — выбор настроек, вторая — проверка покрытия/калибровка. Сдвиг границы равен `max(0, quantile_0.95(y - upper_raw))` на калибровочной части. Финальный test не участвует в настройке. Реальное покрытие и средняя ширина интервала публикуются; 0.95 — номинальный уровень, не гарантия безопасности при сдвиге режима. - -Без требуемой верхней оценки кандидат получает `unknown` по ограничению серы. Обычная ошибка модели не трактуется как вероятность отказа оборудования. - -### Модель последствий - -`effects.py` имеет два явных пути: формулы модельного смешения и подтверждённая модель изменений уставок. Это обычное ветвление по режиму/возможностям, без реестра плагинов. - -Для `supports_actions=true` по реальным уставкам нужны: подтверждённый смысл и управляемость, описанные пределы шага, проверка совместной области применимости, отдельная временная оценка на эпизодах изменений, описанные задержки и ограничения причинной интерпретации. ML сохраняет ссылку на этот отчёт в metadata. До этого реальные controls отключены. - -За пределами области применимости модель не экстраполирует. Результат — `unavailable` с причиной. Сравнение прогнозов на истории не выдаётся за доказанный производственный эффект вмешательства. - -### Надёжность - -Без разметки отказов используется индекс тяжести режима, а не модель вероятности аварии. Для подтверждённых факторов ML задаёт нормальную область, неблагоприятное направление и модельную границу. Вклад фактора растёт линейно от 0 на краю нормальной области до 1 на модельной границе; итог — максимум вкладов. Отдельные жёсткие пределы проверяются независимо от индекса. - -Области, оценённые на истории, вычисляются только по train и помечаются `model_assumption`. Нет подтверждённых факторов — оценка надёжности `unavailable`; нулевой риск не подставляется. В чистом модельном сценарии разрешён явно заданный индекс с `basis='proxy'`. - -### Проверка качества и жизненный цикл ML - -Пользователь — оператор. ML предупреждает о риске качества; допустимость совета проверяется детерминированно. Пропущенное превышение опаснее ложной тревоги; стоимость ошибки в рублях без экономических данных не оценивается. - -| Аспект | Решение | -| --- | --- | -| Функции потерь | Ridge — сумма квадратов ошибок с L2-регуляризацией; точечный HGB — `squared_error`; верхняя граница — квантильная потеря уровня 0.95. Минимум обучающей потери не заменяет критерий выбора модели выше. | -| Определения метрик | MAE — среднее `abs(y-y_hat)`; область 8–12 мг/кг определяется фактическим `y`. Для точечного прогноза тревога — `y_hat > 10`; доля пропусков — `FN/(TP+FN)`, ложных тревог — `FP/(FP+TN)`. Нулевой знаменатель даёт `null`. Решения по верхней границе оцениваются отдельно. | -| Честное сравнение | Baseline и кандидат сравниваются на одних timestamp и одном источнике цели. Отдельно указываются исключённые примеры, недоступные прогнозы и покрытие: число прогнозов / число пригодных целей. Отказы не считаются правильными прогнозами. Результаты ПАК и ЛИМС не усредняются между собой. | -| Разбор ошибок | `ml/evaluate.py` сохраняет остатки и ошибки по месяцам, источнику цели, возрасту анализа, области около порога и наличию пропусков. Для каждого среза — размер; отдельно разобрать крупнейшие недооценки, пропущенные превышения и возможное насыщение ПАК. Это гипотезы для проверки, не основания автоматически удалить выбросы. | -| Эксперименты с признаками | Сравнить baseline, только прошлую серу и её лаги, затем добавленную телеметрию на одинаковом временном разбиении. ML фиксирует, улучшает ли телеметрия прогноз относительно авторегрессии; корреляция не доказывает эффект управления. | -| Замена модели | ML передаёт проверенный каталог модели и отчёт; backend проверяет совместимость, три сценария и общую историческую точку. Новая модель выбирается явно в CLI/UI, предыдущая сохраняется. После ошибки — явный возврат к совместимой предыдущей версии или baseline; если такой версии нет, действуют существующие отказ/RunFailure. | -| Контроль после замены | Backend проверяет ошибки, задержку и отсутствие обязательных входов при каждом запуске; применяются существующие временные пределы и бюджет цикла. ML при поступлении новых ЛИМС пересчитывает метрики на общем с baseline наборе и разбирает недооценки/покрытие. Схемная несовместимость блокирует запуск; потеря критерия выбора модели блокирует её дальнейшее продвижение и запускает разбор. Автоматического переобучения нет. | -| Граница доказательств | История подтверждает точность прогноза, модельный replay — эффект внутри модели. Реальная экономия и полезность оператору потребуют отдельного пилота с технологом; хакатонный прототип их не доказывает. Обучение новой версии запускается явно после разбора ошибок, с новым `model_id` и временным тестом. | - -Верхняя граница проверяется на отложенном test; оценка покрытия на калибровочных данных не считается независимой. Для этапа 5 выбор настроек ограничен первой половиной validation, вторая используется только для калибровки; обе части сохраняются в metadata. Финальный test не используется для исправления выбранной версии. - -## 9. Блендинг и фиксированный демонстрационный пример - -Первая модель смешивает два компонента по массе. Сера смеси `S = Σ(w_i × S_i)`, верхняя граница `S_upper = Σ(w_i × S_upper_i)` при неотрицательных долях. Это консервативная операция над границами, а не утверждение о совместном статистическом покрытии. - -Для каждого компонента `w_i × total_mass_t <= available_mass_t`. Дозировка присадок, плотность смеси и низкотемпературные свойства не моделируются без отдельного обоснования. Активный профиль ограничений перечисляет, что проверено; формулировка «полностью соответствует товарному стандарту» запрещена, если проверена только сера. - -### Параметры синтетического примера - -Все числа таблицы — **заданные параметры демонстрации**, не оценка заводских данных. - -| Параметр | Компонент A | Компонент B | -| --- | --- | --- | -| Сера, мг/кг | 6 | 30 | -| Верхняя граница серы, мг/кг | 7 | 31 | -| Доступная масса, т | 100 | 30 | -| Стоимостной прокси на тонну | 1.0 | 0.8 | -| Индекс риска сценария | 0 | 0 | - -Партия — 100 т; горизонт — 60 минут. Выпуск модели — масса партии за горизонт, одинаковая для всех рецептур. Критерий риска — взвешенный индекс компонентов. Генерируются доли B от 0 до 1 с шагом 0.05; доля A равна `1-B`. Проверки доступности и качества отбрасывают неподходящие варианты. Добавляется отдельный `hold`, дубликаты рецептуры схлопываются в пользу `hold`. - -- Model-demo рецептуры рассчитывают серу, сценарные `T95`, сценарное цетановое число - и запас компонентов. Эти `T95`/цетановые значения нужны только для демонстрации - `hold`/`recommend`/`abstain`; они не являются промышленным паспортом продукта. - -Для уставок после их подтверждения — до трёх параметров, до пяти значений каждого вокруг текущего режима, не больше 125 комбинаций плюс `hold`. При превышении бюджета запуск останавливается с понятной ошибкой, не обрезает пространство скрыто. Реальные шаги и границы фиксируются после анализа источников, без выдуманных температур и давлений в этом документе. - -В `hybrid` качество компонента A заменяется прогнозом гидроочистки с его доверительной границей; остальные свойства компонентов и рецепт остаются модельными. История АВТ используется как входной контекст модели гидроочистки только при подтверждённом соответствии потоков и задержек. Если связь не установлена, UI показывает доступный контекст АВТ отдельно и не рисует доказанный перенос влияния. - -## 10. Ошибки, отказ и журнал - -### Коды причин - -| Код | Поведение | -| --- | --- | -| `MISSING_REQUIRED_SIGNAL`, `STALE_REQUIRED_SIGNAL` | `abstain`, список входов и их возраст | -| `SOURCE_CONFLICT`, `UNIT_UNCONFIRMED`, `TAG_UNCONFIRMED` | Блокирование зависящего расчёта; при обязательности — `abstain` | -| `UNIT_MISMATCH`, `UNASSESSED_REQUIRED_PROPERTY` | `unknown`/`abstain`; численное сравнение или операторское действие запрещено | -| `ACTION_MODEL_UNAVAILABLE`, `OUT_OF_DOMAIN` | Прогноз состояния можно показать, рекомендация изменения блокируется | -| `NON_FINITE_ACTION_FORECAST`, `ACTION_EFFECT_METRICS_NON_FINITE` | Action-кандидат или evidence отклоняется fail-closed | -| `QUALITY_LIMIT`, `CONTROL_LIMIT`, `BLEND_SUM`, `COMPONENT_STOCK` | Кандидат отклоняется с actual/limit | -| `UNCERTAINTY_UNAVAILABLE`, `RELIABILITY_UNAVAILABLE` | `unknown` по обязательной проверке | -| `NO_FEASIBLE_CANDIDATE` | `abstain` и сводка причин отбраковки | -| `NO_MATERIAL_IMPROVEMENT`, `ACTION_COOLDOWN` | `hold` только если исходный вариант прошёл ограничения | -| `MODEL_INCOMPATIBLE`, `CONFIG_INVALID`, `INPUT_CORRUPT` | Технический `RunFailure`, CLI exit code 1 | -| `AGENT_ERROR`, `JOURNAL_WRITE_FAILED` | Технический `RunFailure`; решение не отображается как успешно завершённое | - -Необязательные предупреждения не останавливают весь запуск. При отказе одного кандидата остальные проверяются; при неожиданном исключении агента цикл останавливается. - -### Содержимое запуска - -```text -runs// -├── metadata.json # git SHA, dataset/model/scenario/config hashes, версии -├── input.json # ProcessState, ScenarioConfig, DecisionContext -├── features.json # фактические значения и порядок признаков -├── trace.jsonl # вызовы ролей, результаты, причины, длительности -├── candidates.jsonl # все кандидаты, оценки и проверки -└── result.json # Recommendation либо RunFailure -``` - -`run_id` — UUID; `state_id` — хеш нормализованного состояния. Время и UUID исключены из критерия численной воспроизводимости. На одинаковых входах, версиях и `DecisionContext` совпадают оценки, выбранный кандидат, статусы и причины. - -Итоговые JSON записываются через временный файл и переименование в пределах каталога запуска. Если журнал не записался, UI сообщает об ошибке сохранения; успешный завершённый запуск не заявляется. Логи не содержат credentials и не отправляются во внешние сервисы. - -## 11. Артефакты модели - -```text -artifacts/models// -├── model.joblib # predictor и preprocessing -├── metadata.json # версии и совместимость -└── metrics.json # baseline, validation и ограничения -``` - -Joblib используется из стека scikit-learn. Загружаются только локально созданные доверенные артефакты; UI не принимает произвольные пользовательские файлы моделей. - -Metadata обязательно содержит: `model_id`, `schema_version`, `model_sha256`, `training_dataset_id`, `git_commit`, версии Python/sklearn, `target_signal`, `target_source`, `target_unit`, `horizon_minutes`, упорядоченные `feature_names`, хеш словаря, `feature_schema_hash`, параметры обработки/времени, границы train/validation/calibration, `seed`, capabilities, ограничения применимости и ссылки на отчёты. - -Режим применения проверяет версии сериализации, схему признаков, единицы, горизонт, словарь и параметры подготовки. `training_dataset_id` сохраняется для происхождения, но не обязан совпадать с набором применения: оценка на другом периоде допустима при совместимой схеме. Нельзя переобучать модель автоматически при несовместимости. Если baseline нужен без обученной модели, он выбирается явно в сценарии и подписывается как baseline. - -## 12. Интерфейс и команды запуска - +│ ├── contracts.py # Pydantic-типы обмена +│ ├── config.py # чтение и валидация настроек +│ ├── constraints.py # единый фильтр допустимости +│ ├── orchestrator.py # run_cycle и выбор результата +│ ├── explain.py # русские шаблоны объяснений +│ ├── journal.py # JSON/JSONL и атомарная запись +│ ├── data/ +│ │ ├── __init__.py +│ │ ├── ingest.py # чтение исходных CSV и Excel +│ │ ├── prepare.py # нормализация, manifest, диагностика +│ │ └── state.py # доступные на момент t данные +│ ├── ml/ +│ │ ├── __init__.py +│ │ ├── features.py # общие признаки обучения и применения +│ │ ├── train.py # baselines, подбор, сохранение модели +│ │ ├── evaluate.py # временная оценка и отчёты +│ │ └── artifacts.py # ModelBundle, загрузка и metadata +│ └── agents/ +│ ├── __init__.py +│ ├── quality.py # текущее качество и прогноз +│ ├── reliability.py # тяжесть режима и объясняющие факторы +│ ├── optimizer.py # кандидаты, оценка и ранжирование +│ └── effects.py # последствия действий и формулы смеси +├── global_tests/ +│ ├── workflow_test.py # существующий smoke заменяется запуском цикла +│ ├── test_data.py # источники, единицы, время +│ ├── test_features.py # отсутствие утечек и train/serve parity +│ ├── test_decisions.py # ограничения, выбор, отказы +│ ├── test_artifacts.py # несовместимые и отсутствующие модели +│ └── fixtures/ # малые синтетические данные для CI +├── materials/ # неизменяемые выданные материалы +├── data/processed// # локальные производные данные +├── artifacts/models// # локальные артефакты моделей +├── reports// # метрики и параметры эксперимента +└── runs// # воспроизводимый журнал одного запуска +``` + +| Владелец | Файлы и обязательства | +| --- | --- | +| Backend | `main`, `ui`, `contracts`, `config`, `constraints`, `orchestrator`, `explain`, `journal`, `data/*`, CI и запуск | +| ML | `ml/*`, `agents/*`, смысл `tags.csv`, формулы, выбранные ограничения, модельные сценарии и отчёты | +| Общие файлы с одним автором | `contracts.py`, `runtime.toml`, зависимости и этот дизайн редактирует backend после согласования семантики с ML; `tags.csv` и JSON-сценарии редактирует ML после проверки совместимости с backend | + +Backend не дублирует признаки в UI; ML не пишет альтернативный загрузчик данных в ноутбуке. Ноутбуки — для исследования; итоговый запуск использует указанные `.py`-модули. + +Производные данные, модели и журналы исключаются из обычного Git. Малые синтетические fixtures, настройки, отчёты с агрегированными метриками и документация могут храниться в Git. Существующий `materials/data.rar` остаётся в Git LFS; его не дублируют распакованными CSV в коммитах. + +## 4. Подготовленные данные и время + +### Форматы на диске + +| Файл в наборе данных | Формат и назначение | +| --- | --- | +| `telemetry.csv.gz` | Одна строка на timestamp; `timestamp` и численные колонки с ключами `avt:T33`, `ht:F26` и т. п. | +| `quality.csv.gz` | Длинная таблица: `observation_id, signal_id, stage, source, measured_at, available_at, value, unit, validity, source_ref` | +| `issues.csv.gz` | `source_ref, code, detail`; ошибки и конфликты исходников, без молчаливого удаления | +| `manifest.json` | SHA-256 исходников, настроек и словаря; временные диапазоны, число строк, версия подготовки, допущения | + +`dataset_id` — первые 12 символов SHA-256 от отсортированного списка полных хешей исходников, конфигурации подготовки, словаря и версии кода подготовки. Полные хеши сохраняются в manifest; при совпадении короткого ID и разных полных хешах запись запрещается. Кэш переиспользуется только при полном совпадении manifest. + +### Нормализация + +1. RAR распаковывается локально в отдельный каталог; ожидаются только `data/avt_tags.csv` и `data/242000_tags.csv`. Имена и пути проверяются до извлечения. Исходный архив остаётся неизменным. Для Windows использовать доступный `tar`, для Linux в инструкции указать `bsdtar`. +2. CSV объединяются по `date`. Из первой таблицы удаляются `Unnamed:*`, из второй — пустой индексный заголовок. Технологические теги получают префикс установки. +3. Excel читается по заголовкам точек и показателей. Каждая пара «время — значение» преобразуется независимо; номера строк разных показателей не связываются. +4. `Pt Created`, текст вместо числа, бесконечности и повреждённые даты получают запись в `issues`. Численное значение становится отсутствующим, не нулём. +5. Одинаковые дубликаты схлопываются с сохранением происхождения. Противоречащие значения одного источника в один момент получают `conflict` и не выбираются произвольно. +6. Единицы преобразуются только по подтверждённому правилу: массовые ppm → мг/кг без изменения числа; массовые проценты серы → мг/кг умножением на 10 000. Для неоднозначного `ppm` сначала подтверждается массовая база. Массовый и объёмный расход не отождествляются. +7. Значения вне привычного диапазона не удаляются автоматически. Ошибка датчика, насыщение анализатора и реальная аварийная ситуация различаются только при наличии основания; иначе остаётся предупреждение. + +### Словарь тегов + +`config/tags.csv` содержит поля `signal_id, raw_name, stage, meaning, raw_unit, canonical_unit, conversion, mapping_status, controllable, evidence_ref`. + +- `mapping_status`: `confirmed`, `ambiguous`, `excluded`. +- `conversion`: именованная функция из фиксированного набора, а не исполняемая строка. +- `controllable=true` требует подтверждения в `evidence_ref`; наличие численного ряда этого не подтверждает. +- В формулах и UI используются канонические ключи. Короткие имена вроде `T6` не определяют физический смысл. +- Некорректную формулу ВАК не исправляют догадкой. Её исключают до проверки; утверждённые формулы переносят в явные Python-функции, без `eval` содержимого Excel. + +### Временные правила + +Внутри приложения — timezone-aware UTC. Исходные даты без timezone интерпретируются согласно `source_timezone`. `Europe/Moscow` по умолчанию — **экспериментальное допущение**, не установленное свойство пакета; записывается в manifest. UI показывает часовой пояс рядом со временем. + +- `measured_at` — время измерения/отбора пробы. +- `available_at` — время, с которого результат мог быть известен системе. +- Для телеметрии и ПАК начальное допущение: `available_at = measured_at`. +- Для ЛИМС при отсутствии точного времени публикации: `available_at = measured_at + lims_delay_hours`. По ответу эксперта от 10.09.2026 публикация занимает до 4 часов; консервативное значение по умолчанию — 4 часа. Чувствительность к меньшей задержке оценивается без подбора на финальном test. +- В состоянии на `t` разрешены только записи с `measured_at <= t` и `available_at <= t`. +- Возраст всегда считается от `measured_at`, не от времени загрузки файла. +- Для каждого сигнала выбирается последнее доступное измерение по времени. +- Начальные пределы свежести: телеметрия 20 минут, ПАК 30 минут, ЛИМС 48 часов. Это модельные настройки, проверяемые на валидации, а не технологические нормативы. +- Пригодный свежий ЛИМС имеет приоритет над ПАК; затем идёт проверенный ВАК. Остальные источники сохраняются для сравнения. +- Устаревший ЛИМС показывается отдельно. Свежий пригодный ПАК может стать оперативным источником с предупреждением; устаревший анализ не становится текущей истиной. +- Расхождение источников не проверяется на произвольных несинхронных значениях: ПАК сопоставляется с временем отбора лабораторной пробы, но сам факт конфликта появляется только после `available_at` ЛИМС. Допуск расхождения обязателен в настройках активного показателя; неподтверждённый допуск не придумывается в коде. +- Неразрешённый конфликт обязательного показателя блокирует рекомендацию. Лабораторный результат остаётся контрольным фактом. + +Отсутствие необязательного сигнала даёт предупреждение. Отсутствие обязательного входа модели или ограничения блокирует соответствующий расчёт. Условия определяются явно в scenario/model metadata. + +## 5. Типы обмена между компонентами + +Типы реализуются в `contracts.py` через Pydantic. Числа конечны; отсутствие — `None`. Во внешнем JSON — `null`, никогда `NaN`. Неизвестные поля отклоняются. Строковые перечисления сериализуются своими значениями. Агентам передаются новые результаты; они не меняют входной `ProcessState` и конфигурацию. + +Обозначения: `Timestamp` — timezone-aware `datetime`; `SignalId`, `CandidateId` и `RunId` — строки; `Unit` — каноническая строка единицы. Поля таблиц обязательны; `| None` разрешает отсутствие значения, но поле остаётся в JSON. + +### Состояние и измерения + +| Тип | Поля | +| --- | --- | +| `Issue` | `code: str`, `severity: Literal['warning','blocking']`, `signal_id: str \| None`, `detail: str`, `source_ref: str \| None` | +| `Observation` | `id: str`, `signal_id: str`, `stage: Literal['avt','ht','blend']`, `source: Literal['telemetry','lims','pak','vak','scenario']`, `measured_at: Timestamp`, `available_at: Timestamp`, `value: float \| None`, `unit: str`, `validity: Literal['valid','missing','invalid','conflict']`, `source_ref: str` | +| `SignalSnapshot` | `selected: Observation \| None`, `alternatives: list[Observation]`, `age_seconds: float \| None`, `fresh: bool`, `issues: list[Issue]` | +| `ProcessState` | `schema_version: Literal['1.0']`, `state_id: str`, `as_of: Timestamp`, `dataset_id: str`, `mode: Literal['history','model_demo','hybrid']`, `signals: dict[str, SignalSnapshot]`, `issues: list[Issue]` | + +`signals` содержит все ожидаемые сценарием сигналы, в том числе отсутствующие. Исторические окна для модели не вкладываются целиком в состояние: признаки строятся через ограниченный моментом `as_of` доступ к подготовленным данным и отдельно сохраняются в журнале. + +### Действия, оценки и проверки + +| Тип | Поля | +| --- | --- | +| `CandidateAction` | `id: str`, `kind: Literal['hold','setpoints','blend']`, `setpoints: dict[str,float]`, `blend_mass_fractions: dict[str,float]`, `additive_mass_fraction: float`, `horizon_minutes: int`, `is_model_scenario: bool` | +| `MetricEstimate` | `value: float \| None`, `lower: float \| None`, `upper: float \| None`, `unit: str`, `basis: Literal['measured','forecast','formula','proxy']`, `interval_kind: Literal['none','empirical','scenario_bound']`, `interval_level: float \| None`, `reference: str`, `assumptions: list[str]` | +| `AgentAssessment` | `agent: Literal['quality','reliability','optimizer']`, `state_id: str`, `candidate_id: str`, `evaluated_for: Timestamp`, `status: Literal['ok','degraded','unavailable']`, `metrics: dict[str,MetricEstimate]`, `issues: list[Issue]` | +| `ConstraintResult` | `constraint_id: str`, `candidate_id: str`, `status: Literal['pass','fail','unknown']`, `actual: float \| None`, `lower: float \| None`, `upper: float \| None`, `unit: str \| None`, `basis: Literal['tz','confirmed','model_assumption']`, `evidence_ref: str`, `reason_code: str` | +| `CandidateEvaluation` | `candidate: CandidateAction`, `assessments: list[AgentAssessment]`, `checks: list[ConstraintResult]`, `feasible: bool`, `rank_key: tuple[float,float,float,float,str] \| None` | + +Правила: + +- `setpoints` содержит **новые абсолютные значения**, не приращения; единицы определяются `ControlSpec`. Разницу UI вычисляет относительно состояния. +- Для `hold` обе карты пустые: сохраняются текущие уставки, рецептура и доза присадки. Для `blend` задаётся полная рецептура и доза, не только изменения. Сумма долей компонентов и присадки равна 1. Кандидат не совмещает изменение уставок и рецептуры. +- У всех оценок кандидата совпадают `state_id`, `candidate_id` и горизонт. Прогноз вычисляется на `as_of + horizon`; измерение текущего состояния подписывается отдельно. +- `MetricEstimate` с `basis='proxy'` не называется физической величиной без установленного соответствия. Прокси затрат имеют единицу `proxy_unit`, риска — `index_0_1`. +- `interval_kind='scenario_bound'` — заданная граница сценария, не статистическая вероятность. `interval_level` для неё равен `None`. +- `feasible=true` только если все обязательные проверки имеют `pass`. `unknown` не равно успешной проверке. При отсутствии обязательной оценки `rank_key=None`. + +### Итог цикла + +| Тип | Поля | +| --- | --- | +| `Recommendation` | `schema_version: Literal['1.0','1.1']` (новая выдача `1.1`, чтение старых журналов `1.0` сохранено), `run_id: str`, `state_id: str`, `as_of: Timestamp`, `scenario_id: str`, `mode: str`, `status: Literal['recommend','hold','abstain']`, `baseline: CandidateEvaluation \| None`, `selected: CandidateEvaluation \| None`, `alternatives: list[CandidateEvaluation]`, `reason_codes: list[str]`, `explanation: str`, `assumptions: list[str]`, `model_id: str \| None` | +| `RunFailure` | `run_id: str`, `occurred_at: Timestamp`, `error_code: str`, `stage: str`, `message: str` | + +Инварианты: при `recommend` выбран допустимый ненулевой кандидат; при `hold` выбран допустимый кандидат `hold`; при `abstain` `selected=None` и есть причина. `baseline` может быть недопустимым — это исходная точка сравнения, а не разрешённое действие. В `alternatives` идут до трёх допустимых альтернатив, полный список сохраняется в журнале. + +`RunFailure` — техническая ошибка выполнения, не технологический отказ. Система не превращает исключение Python в ответ «безопасных режимов нет». + +## 6. Конфигурация и интерфейсы функций + +### Конфигурационные типы + +| Тип | Содержание | +| --- | --- | +| `RuntimeConfig` | `source_timezone`, задержки и пределы свежести; `horizon_minutes=60`; `seed=42`; `max_candidates=125`; пути к данным/моделям/журналам | +| `ControlSpec` | `signal_id`, `unit`, `lower`, `upper`, `max_step`, `step`, `evidence_ref`, `basis`, `enabled` | +| `ConstraintSpec` | `id`, `metric`, `stage`, `lower`, `upper`, `unit`, `use_upper_estimate`, `use_lower_estimate`, `required`, `basis`, `evidence_ref` | +| `BlendComponent` | `id`, оценки `sulfur`, `t95`, `cetane_number`, `available_mass_t`, `cost_proxy_per_t`, `risk_index`, `source_state_id` | +| `CetaneAdditiveSpec` | `id`, максимум/шаг/запас/стоимость, монотонная таблица `mass_fraction -> cetane_gain`, источник и допущения | +| `ScenarioConfig` | `id`, `mode`, сигналы, controls, constraints, компоненты, присадка, `total_mass_t`, текущие рецептура/доза, критерии, пороги, cooldown и допущения | +| `DecisionContext` | `last_recommended_at: Timestamp \| None`; время последней выданной рекомендации для подавления повторов | + +Численные технологические границы без источника не имеют значения по умолчанию. Тогда `enabled=false`. Активное ограничение без предела, единицы или обоснования — ошибка конфигурации до запуска цикла. В `model_demo` обязательны верхние оценки серы и T95 и нижняя оценка цетанового числа. Сера имеет `upper=10 mg/kg` из ТЗ; T95/CN и их границы явно помечены `model_assumption`. + +Доли считаются равными 1 при абсолютной погрешности не более `1e-9`. Этот допуск используется только для арифметики долей, не для разрешения серы выше 10 мг/кг. Сравнение качества выполняется до округления UI. + +### Вызываемые интерфейсы + +`PreparedData` и `ModelBundle` — обычные локальные dataclass-контейнеры, а не сетевые DTO. `FeatureFrame` — `pandas.DataFrame` с фиксированным порядком признаков. + +```text +prepare_dataset(materials_dir: Path, config: RuntimeConfig) -> PreparedData +build_state(data: PreparedData, as_of: datetime, + scenario: ScenarioConfig, config: RuntimeConfig) -> ProcessState +build_features(data: PreparedData, as_of: datetime, + state: ProcessState, model: ModelBundle) -> FeatureFrame +predict_quality(state: ProcessState, features: FeatureFrame, + model: ModelBundle | None, scenario: ScenarioConfig) -> AgentAssessment +assess_reliability(state: ProcessState, + scenario: ScenarioConfig) -> AgentAssessment +generate_candidates(state: ProcessState, + scenario: ScenarioConfig) -> list[CandidateAction] +evaluate_candidates(state: ProcessState, features: FeatureFrame, + candidates: list[CandidateAction], model: ModelBundle | None, + scenario: ScenarioConfig) -> list[CandidateEvaluation] +check_constraints(state: ProcessState, candidate: CandidateAction, + assessments: list[AgentAssessment], + scenario: ScenarioConfig) -> list[ConstraintResult] +run_cycle(data: PreparedData, as_of: datetime, model: ModelBundle | None, + scenario: ScenarioConfig, config: RuntimeConfig, + context: DecisionContext, run_dir: Path) -> Recommendation +``` + +`predict_quality` и `assess_reliability` оценивают исходное состояние с `candidate_id='hold'`. `effects.py` оценивает действие в отдельном прогнозном состоянии/оценках; исходный объект не изменяется. Данные, рассчитанные моделью, получают соответствующий `basis`, а не `measured`. + +В `model_demo` без ML-модели `build_features` не вызывается: передаётся пустой `FeatureFrame`, а качество считается из компонентов сценария. Baseline на истории оформляется как `ModelBundle` с явным типом predictor; отсутствие необходимой модели не переключает режим автоматически. + +`PreparedData` хранит отсортированную телеметрию, длинную таблицу качества, issues и manifest. `ModelBundle` хранит predictor, порядок признаков, preprocessing, метаданные и при наличии модель последствий. Backend не должен знать внутреннюю структуру sklearn estimator. + +## 7. Один цикл принятия решения + +```mermaid +sequenceDiagram + participant UI as UI или CLI + participant O as Оркестратор + participant D as Данные и признаки + participant Q as Качество + participant R as Надёжность + participant P as Оптимизатор + participant C as Ограничения + participant J as Журнал + UI->>O: run_cycle(t, scenario, model) + O->>D: build_state и build_features только до t + D-->>O: состояние и признаки + O->>Q: predict_quality + Q-->>O: качество, доверие, причины + O->>R: assess_reliability + R-->>O: индекс и факторы + alt Обязательных данных или модели последствий нет + O->>J: оценки и abstain с причиной + else Оценка действий доступна + O->>P: кандидаты, включая hold + loop Каждый кандидат + P->>Q: прогноз качества последствий + P->>R: оценка риска последствий + P->>C: check_constraints + C-->>P: pass, fail или unknown + end + P-->>O: оценки и ранжирование + O->>C: повторная проверка выбранного кандидата + O->>J: входы, все оценки, решение + end + O-->>UI: Recommendation и объяснение +``` + +Порядок внутри `run_cycle`: + +1. Проверить совместимость config/scenario/model и создать `run_id`. +2. Построить состояние и признаки. Сохранить применённые допущения и идентификаторы выбранных наблюдений. +3. Получить оценки качества и надёжности. Если роль недоступна, сохранить доступные результаты остальных. +4. При блокирующих входах вернуть `abstain`; не генерировать видимость оптимизации. +5. Создать `hold` и разрешённые кандидаты. Предварительно отсеять нарушение формата, управления и рецептуры. +6. Рассчитать последствия, затем выполнить полный `check_constraints`. Никакие баллы не используются до этого фильтра. +7. Выбрать лучший допустимый вариант по правилу ниже. При отсутствии вариантов — `abstain`. +8. Повторно проверить выбранный кандидат тем же кодом ограничений. Расхождение результатов — `RunFailure`, а не автоматический переход к другому ответу. +9. Построить объяснение исключительно из сохранённых оценок; записать журнал; вернуть результат UI/CLI. + +### Правило выбора + +Ключ сортировки: `(risk_index, -throughput, cost_proxy, change_size, candidate_id)`, по возрастанию. Последний элемент разрешает равенство. Индексы/затраты сравниваются только внутри одного сценария с одинаковыми определениями. Если критерий не обеспечен данными, его **заранее** отключают в конфигурации для всех кандидатов с нейтральным местом в ключе; это отображается в UI. Пропуск у отдельного кандидата по активному критерию делает его оценку недоступной. + +`change_size` для уставок — сумма `abs(new-current)/step`; для рецептуры — сумма абсолютных изменений массовых долей. + +Если `hold` допустим, изменение выдаётся только при существенном улучшении первого отличающегося активного критерия. Начальные экспериментальные пороги: риск 0.02 абсолютных пункта, выпуск 2%, затраты 2%. При сравнении около нулевой базы используются обязательные абсолютные допуски сценария. Разница только в `change_size` не является улучшением процесса. Иначе возвращается `hold`. + +Если `hold` недопустим, порог экономической существенности не мешает выбрать допустимое действие. Начальный cooldown — 60 минут: он подавляет повторную выдачу изменений только при допустимом `hold`. При нарушении качества система повторно предупреждает и оценивает варианты независимо от cooldown. Совет не означает его выполнение: состояние меняется только по новым данным/явно запущенной симуляции. + +`DecisionContext` относится к одному сценарию и последовательному воспроизведению. При смене сценария или переходе назад по времени он сбрасывается; иначе обновляется только после `recommend`. Контекст сохраняется в журнале и учитывается при сравнении повторных запусков. + +## 8. ML, неопределённость и модель последствий + +### Признаки и обучение + +- Первая цель — сера на выходе гидроочистки через 60 минут. Эта точка не отождествляется автоматически с товарной смесью. +- Телеметрические лаги первой версии: 10, 30, 60 и 180 минут; прошлые средние и стандартные отклонения в окнах 60 и 180 минут. Окно заканчивается на `as_of`, будущие значения не участвуют. +- Исходные признаки берутся только из подтверждённого словаря. Сера анализаторов и её лаги явно маркируются как autoregressive-признаки; тот же будущий целевой анализ не может попасть в входы. +- Признаки, отсутствующие во всём обучающем периоде, исключаются. Появление ПАК-плотности в 2025 году не позволяет использовать её как обученный признак модели 2023–2024 без отдельного эксперимента. +- Imputer/scaler и отбор признаков обучаются только на train и сохраняются вместе с моделью. Для Ridge — медианная импутация и масштабирование; для HGB — фиксированная обработка пропусков. Строки без цели не становятся обучающими примерами. +- Для ПАК цель выбирается на сетке `t+60 минут`; без точного допустимого наблюдения пример исключается; цель не переносится из будущего. Для ЛИМС создаётся один пример на реальный анализ: `as_of = measured_at - 60 минут`. +- ПАК и ЛИМС не объединяются в одну безымянную цель. Модель ПАК оценивается по ПАК и отдельно по контрольным ЛИМС; перенос источника отражается в отчёте. +- Начальное разделение: train до 2025-01-01; validation до 2026-01-01; test с 2026-01-01 по конец доступной телеметрии. Границы задаются в исходном часовом поясе и преобразуются в UTC. +- Внутри train — три последовательных временных блока для подбора. Цели, пересекающие границу следующего блока, исключаются. Внутреннее early stopping у HGB отключается (`early_stopping=False`); число итераций выбирается временной валидацией, без автоматической внутренней проверочной выборки ([описание параметров](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.HistGradientBoostingRegressor.html)). +- Метрики: MAE, MAE в области 8–12 мг/кг, пропущенные превышения 10 мг/кг, ложные тревоги; для каждого показателя число примеров. Если превышений нет, соответствующая доля — `null` с пояснением. +- Выбор модели выполняется до test: минимизировать MAE при числе пропущенных превышений не больше baseline; при равенстве оставить более простую модель. Если подходящих моделей нет — baseline сохраняется, ограничение качества указывается в отчёте. + +### Неопределённость + +До этапа 5 прогноз без интервала имеет `interval_kind='none'`. Он не удовлетворяет сценарию с `require_upper_bound=true`. Первая сквозная демонстрация использует явные границы модельных компонентов, поэтому не зависит от готовности статистических интервалов. + +На этапе 5: верхняя квантильная оценка 0.95; первая половина validation по времени — выбор настроек, вторая — проверка покрытия/калибровка. Сдвиг границы равен `max(0, quantile_0.95(y - upper_raw))` на калибровочной части. Финальный test не участвует в настройке. Реальное покрытие и средняя ширина интервала публикуются; 0.95 — номинальный уровень, не гарантия безопасности при сдвиге режима. + +Без требуемой верхней оценки кандидат получает `unknown` по ограничению серы. Обычная ошибка модели не трактуется как вероятность отказа оборудования. + +### Модель последствий + +`effects.py` имеет два явных пути: формулы модельного смешения и подтверждённая модель изменений уставок. Это обычное ветвление по режиму/возможностям, без реестра плагинов. + +Для `supports_actions=true` по реальным уставкам нужны: подтверждённый смысл и управляемость, описанные пределы шага, проверка совместной области применимости, отдельная временная оценка на эпизодах изменений, описанные задержки и ограничения причинной интерпретации. ML сохраняет ссылку на этот отчёт в metadata. До этого реальные controls отключены. + +За пределами области применимости модель не экстраполирует. Результат — `unavailable` с причиной. Сравнение прогнозов на истории не выдаётся за доказанный производственный эффект вмешательства. + +### Надёжность + +Без разметки отказов используется индекс тяжести режима, а не модель вероятности аварии. Для подтверждённых факторов ML задаёт нормальную область, неблагоприятное направление и модельную границу. Вклад фактора растёт линейно от 0 на краю нормальной области до 1 на модельной границе; итог — максимум вкладов. Отдельные жёсткие пределы проверяются независимо от индекса. + +Области, оценённые на истории, вычисляются только по train и помечаются `model_assumption`. Нет подтверждённых факторов — оценка надёжности `unavailable`; нулевой риск не подставляется. В чистом модельном сценарии разрешён явно заданный индекс с `basis='proxy'`. + +### Проверка качества и жизненный цикл ML + +Пользователь — оператор. ML предупреждает о риске качества; допустимость совета проверяется детерминированно. Пропущенное превышение опаснее ложной тревоги; стоимость ошибки в рублях без экономических данных не оценивается. + +| Аспект | Решение | +| --- | --- | +| Функции потерь | Ridge — сумма квадратов ошибок с L2-регуляризацией; точечный HGB — `squared_error`; верхняя граница — квантильная потеря уровня 0.95. Минимум обучающей потери не заменяет критерий выбора модели выше. | +| Определения метрик | MAE — среднее `abs(y-y_hat)`; область 8–12 мг/кг определяется фактическим `y`. Для точечного прогноза тревога — `y_hat > 10`; доля пропусков — `FN/(TP+FN)`, ложных тревог — `FP/(FP+TN)`. Нулевой знаменатель даёт `null`. Решения по верхней границе оцениваются отдельно. | +| Честное сравнение | Baseline и кандидат сравниваются на одних timestamp и одном источнике цели. Отдельно указываются исключённые примеры, недоступные прогнозы и покрытие: число прогнозов / число пригодных целей. Отказы не считаются правильными прогнозами. Результаты ПАК и ЛИМС не усредняются между собой. | +| Разбор ошибок | `ml/evaluate.py` сохраняет остатки и ошибки по месяцам, источнику цели, возрасту анализа, области около порога и наличию пропусков. Для каждого среза — размер; отдельно разобрать крупнейшие недооценки, пропущенные превышения и возможное насыщение ПАК. Это гипотезы для проверки, не основания автоматически удалить выбросы. | +| Эксперименты с признаками | Сравнить baseline, только прошлую серу и её лаги, затем добавленную телеметрию на одинаковом временном разбиении. ML фиксирует, улучшает ли телеметрия прогноз относительно авторегрессии; корреляция не доказывает эффект управления. | +| Замена модели | ML передаёт проверенный каталог модели и отчёт; backend проверяет совместимость, три сценария и общую историческую точку. Новая модель выбирается явно в CLI/UI, предыдущая сохраняется. После ошибки — явный возврат к совместимой предыдущей версии или baseline; если такой версии нет, действуют существующие отказ/RunFailure. | +| Контроль после замены | Backend проверяет ошибки, задержку и отсутствие обязательных входов при каждом запуске; применяются существующие временные пределы и бюджет цикла. ML при поступлении новых ЛИМС пересчитывает метрики на общем с baseline наборе и разбирает недооценки/покрытие. Схемная несовместимость блокирует запуск; потеря критерия выбора модели блокирует её дальнейшее продвижение и запускает разбор. Автоматического переобучения нет. | +| Граница доказательств | История подтверждает точность прогноза, модельный replay — эффект внутри модели. Реальная экономия и полезность оператору потребуют отдельного пилота с технологом; хакатонный прототип их не доказывает. Обучение новой версии запускается явно после разбора ошибок, с новым `model_id` и временным тестом. | + +Верхняя граница проверяется на отложенном test; оценка покрытия на калибровочных данных не считается независимой. Для этапа 5 выбор настроек ограничен первой половиной validation, вторая используется только для калибровки; обе части сохраняются в metadata. Финальный test не используется для исправления выбранной версии. + +## 9. Блендинг и фиксированный демонстрационный пример + +Модель смешивает два дизельных компонента и цетаноповышающую присадку по массе. Сера смеси `S = Σ(w_i × S_i)`, верхняя граница `S_upper = Σ(w_i × S_upper_i)` при неотрицательных долях. Сумма долей дизельных компонентов и присадки равна 1. Это консервативная операция над заданными границами, а не утверждение о статистическом покрытии. + +T95 и базовое цетановое число считаются линейно по нормированным долям дизельных компонентов. Это **явное модельное приближение**, а не физический закон. Присадка считается не влияющей на S/T95 и добавляет к CN величину из монотонной кусочно-линейной таблицы сценария. Доза ограничена 3%; стоимость на тонну равна 100 стоимостным единицам при стоимости ДТ около 1. Сама кривая эффективности синтетическая и изменяемая. + +Для каждого компонента и присадки проверяется `fraction × total_mass_t <= available_mass_t`. Кандидат допустим только при `S_upper <= 10 mg/kg`, `T95_upper <= 360 °C` и `CN_lower >= 51` в текущем синтетическом профиле. Последние две границы — параметры модельного сценария, не универсальная промышленная спецификация. Формулировка «полностью соответствует товарному стандарту» запрещена: система доказывает лишь соответствие объявленной модели. + +### Параметры синтетического примера + +Все числа таблицы — **заданные параметры демонстрации**, не оценка заводских данных. + +| Параметр | Компонент A | Компонент B | +| --- | --- | --- | +| Сера, мг/кг | 6 | 30 | +| Верхняя граница серы, мг/кг | 7 | 31 | +| T95 / верхняя граница, °C | 350 / 352 | 370 / 372 | +| Цетановое / нижняя граница | 50.5 / 50 | 46.5 / 46 | +| Доступная масса, т | 100 | 30 | +| Стоимостной прокси на тонну | 1.0 | 0.8 | +| Индекс риска сценария | 0 | 0 | + +Партия — 100 т; горизонт — 60 минут. Генерируются доли B от 0 до 1 с шагом 0.05 и дозы присадки 0–3% с шагом 1%; доли A/B масштабируются на оставшуюся массу. Всего не более 84 кандидатов с `hold`. Сначала проверяются паспорт и запасы, затем допустимые варианты ранжируются по риску, выпуску, стоимости и размеру изменения. + +`blend_normal` возвращает `hold`; `blend_risk`, `blend_t95_risk` и `blend_cetane_risk` возвращают изменяемые рекомендации; `blend_missing` возвращает `abstain`. Каждый положительный результат проходит все три обязательные границы и явно подписан как результат синтетической модели. + +Для уставок после их подтверждения — до трёх параметров, до пяти значений каждого вокруг текущего режима, не больше 125 комбинаций плюс `hold`. При превышении бюджета запуск останавливается с понятной ошибкой, не обрезает пространство скрыто. Реальные шаги и границы фиксируются после анализа источников, без выдуманных температур и давлений в этом документе. + +В `hybrid` качество компонента A заменяется прогнозом гидроочистки с его доверительной границей; остальные свойства компонентов и рецепт остаются модельными. История АВТ используется как входной контекст модели гидроочистки только при подтверждённом соответствии потоков и задержек. Если связь не установлена, UI показывает доступный контекст АВТ отдельно и не рисует доказанный перенос влияния. + +## 10. Ошибки, отказ и журнал + +### Коды причин + +| Код | Поведение | +| --- | --- | +| `MISSING_REQUIRED_SIGNAL`, `STALE_REQUIRED_SIGNAL` | `abstain`, список входов и их возраст | +| `SOURCE_CONFLICT`, `UNIT_UNCONFIRMED`, `TAG_UNCONFIRMED` | Блокирование зависящего расчёта; при обязательности — `abstain` | +| `UNIT_MISMATCH`, `UNASSESSED_REQUIRED_PROPERTY` | `unknown`/`abstain`; численное сравнение или операторское действие запрещено | +| `ACTION_MODEL_UNAVAILABLE`, `OUT_OF_DOMAIN` | Прогноз состояния можно показать, рекомендация изменения блокируется | +| `NON_FINITE_ACTION_FORECAST`, `ACTION_EFFECT_METRICS_NON_FINITE` | Action-кандидат или evidence отклоняется fail-closed | +| `QUALITY_LIMIT`, `CONTROL_LIMIT`, `BLEND_SUM`, `COMPONENT_STOCK` | Кандидат отклоняется с actual/limit | +| `UNCERTAINTY_UNAVAILABLE`, `RELIABILITY_UNAVAILABLE` | `unknown` по обязательной проверке | +| `NO_FEASIBLE_CANDIDATE` | `abstain` и сводка причин отбраковки | +| `NO_MATERIAL_IMPROVEMENT`, `ACTION_COOLDOWN` | `hold` только если исходный вариант прошёл ограничения | +| `MODEL_INCOMPATIBLE`, `CONFIG_INVALID`, `INPUT_CORRUPT` | Технический `RunFailure`, CLI exit code 1 | +| `AGENT_ERROR`, `JOURNAL_WRITE_FAILED` | Технический `RunFailure`; решение не отображается как успешно завершённое | + +Необязательные предупреждения не останавливают весь запуск. При отказе одного кандидата остальные проверяются; при неожиданном исключении агента цикл останавливается. + +### Содержимое запуска + +```text +runs// +├── metadata.json # git SHA, dataset/model/scenario/config hashes, версии +├── input.json # ProcessState, ScenarioConfig, DecisionContext +├── features.json # фактические значения и порядок признаков +├── trace.jsonl # вызовы ролей, результаты, причины, длительности +├── candidates.jsonl # все кандидаты, оценки и проверки +└── result.json # Recommendation либо RunFailure +``` + +`run_id` — UUID; `state_id` — хеш нормализованного состояния. Время и UUID исключены из критерия численной воспроизводимости. На одинаковых входах, версиях и `DecisionContext` совпадают оценки, выбранный кандидат, статусы и причины. + +Итоговые JSON записываются через временный файл и переименование в пределах каталога запуска. Если журнал не записался, UI сообщает об ошибке сохранения; успешный завершённый запуск не заявляется. Логи не содержат credentials и не отправляются во внешние сервисы. + +## 11. Артефакты модели + +```text +artifacts/models// +├── model.joblib # predictor и preprocessing +├── metadata.json # версии и совместимость +└── metrics.json # baseline, validation и ограничения +``` + +Joblib используется из стека scikit-learn. Загружаются только локально созданные доверенные артефакты; UI не принимает произвольные пользовательские файлы моделей. + +Metadata обязательно содержит: `model_id`, `schema_version`, `model_sha256`, `training_dataset_id`, `git_commit`, версии Python/sklearn, `target_signal`, `target_source`, `target_unit`, `horizon_minutes`, упорядоченные `feature_names`, хеш словаря, `feature_schema_hash`, параметры обработки/времени, границы train/validation/calibration, `seed`, capabilities, ограничения применимости и ссылки на отчёты. + +Режим применения проверяет версии сериализации, схему признаков, единицы, горизонт, словарь и параметры подготовки. `training_dataset_id` сохраняется для происхождения, но не обязан совпадать с набором применения: оценка на другом периоде допустима при совместимой схеме. Нельзя переобучать модель автоматически при несовместимости. Если baseline нужен без обученной модели, он выбирается явно в сценарии и подписывается как baseline. + +## 12. Интерфейс и команды запуска + ### Текущий desktop UI и целевой экран -Сейчас `source/ui.py` — локальное Tkinter-приложение. Оно запускает `model_demo`, -отображает checks и журнал, вызывает `prepare`/`build-state` как диагностические -операции и имеет отдельный экран trusted history forecast. History forecast загружает -совместимый `ModelBundle`, но не включает реальные actions. +Сейчас `source/ui.py` — локальное Tkinter-приложение. Оно запускает только +`model_demo`, отображает S/T95/CN, дозу присадки, checks и журнал, а также вызывает +`prepare` и `build-state` как диагностические операции. Оно не загружает `ModelBundle` +и не выполняет historical forecast. Целевой экран должен поддерживать: - -1. Выбор режима, сценария, времени истории и совместимой модели; кнопка «Рассчитать». -2. Состояние: измерения, единицы, источник и возраст; отдельные блоки АВТ, гидроочистки и смеси. -3. Качество и риск: факт, прогноз, граница, применимость и конкретные предупреждения. -4. Итог: статус, текущее → рекомендуемое значение, ожидаемый эффект, проверенные ограничения. -5. Альтернативы и полный след взаимодействия агентов; возможность скачать результат. - + +1. Выбор режима, сценария, времени истории и совместимой модели; кнопка «Рассчитать». +2. Состояние: измерения, единицы, источник и возраст; отдельные блоки АВТ, гидроочистки и смеси. +3. Качество и риск: факт, прогноз, граница, применимость и конкретные предупреждения. +4. Итог: статус, текущее → рекомендуемое значение, ожидаемый эффект, проверенные ограничения. +5. Альтернативы и полный след взаимодействия агентов; возможность скачать результат. + Расчёт запускается только явным действием пользователя; UI не меняет значения, выбранные оптимизатором, и не округляет числа перед проверками. При будущей загрузке моделей prepared data и artifacts кэшируются только по идентификаторам и хешам. - -Модельный режим и неполнота проверенной спецификации всегда видны рядом с рекомендацией. Экран ошибки выполнения отличается от технологического отказа. - + +Модельный режим и граница применимости синтетического паспорта всегда видны рядом с рекомендацией. Экран ошибки выполнения отличается от технологического отказа. + ### Реализованные и целевые команды - + Команды выполняются из корня репозитория после активации совместимого окружения. Реализованы `validate-stage0`, `run-model-demo`/`demo`, `prepare`, `build-state`, `train`, `evaluate`, `replay`, `acceptance`, `verify-model-freeze`, `export-journal` -и `python -m source.ui`. `run-history` сохранён как legacy-алиас forecast-only -пути `replay` и требует явного `--trusted-model`. +и `python -m source.ui`. ```bash -python -m pip install -r requirements.lock.txt +python -m pip install -r requirements.txt git lfs pull python -m source.main prepare --materials materials --config config/runtime.toml -python -m source.main train --dataset data/processed/ --target-source pak -python -m source.main evaluate --dataset data/processed/ --model artifacts/models/ --source pak --split test -python -m source.main replay --dataset data/processed/ --model artifacts/models/ --scenario history --at 2026-01-15T12:00:00+03:00 -python -m source.main run-history --dataset data/processed/ --model artifacts/models/ --trusted-model --as-of 2026-01-15T12:00:00+03:00 -python -m source.main acceptance --output reports/final-acceptance -python -m source.main run-model-demo blend_risk +python -m source.main train --dataset data/processed/ --config config/runtime.toml +python -m source.main evaluate --dataset data/processed/ --model artifacts/models/ --split test +python -m source.main replay --dataset data/processed/ --model artifacts/models/ --scenario config/scenarios/history.json --at 2026-01-15T12:00:00+03:00 +python -m source.main demo blend_risk python -m source.ui python -m pytest -python -m ruff check . -python -m ruff format --check . -``` - +python -m ruff check . +python -m ruff format --check . +``` + `prepare/train/evaluate` печатают путь созданного результата; `run-model-demo` и `run-history` — итог и путь журнала. `abstain` — успешный расчёт: exit code 0. Ошибки данных/конфигурации/кода — exit code 1. CLI строится на `argparse`; параметры и конфигурация разрешаются один раз в `main.py`. В UI используется тот же загрузчик конфигурации и те же функции. - -## 13. Проверки и критерии приёмки - -CI работает на малых синтетических fixtures без загрузки 100 МБ LFS и без полного обучения. Полная оценка истории запускается отдельно перед сдачей. Вместо `assert True` нужен сквозной `model_demo`. - -| Проверка | Что доказывает | Владелец | -| --- | --- | --- | -| Разные даты в соседних парах Excel | Отсутствие объединения по номеру строки | Backend | -| Дубликаты, `Pt Created`, неизвестная единица | Нет тихих численных подстановок | Backend | -| Проба взята до t, опубликована после t | Будущий лабораторный факт не попал в состояние/признаки | Backend + ML | -| Признаки для обучения и запуска на одном t | Совпадают имена, порядок и значения | ML | -| Граница train/test и вычисление статистик | Ни цели, ни preprocessing не используют будущие данные | ML | -| Сера 10, 10.0001 и верхняя граница выше 10 | Порог проверяется до округления; граница используется по настройке | Backend | -| Неверные доли, запас и неподтверждённая уставка | Недопустимый кандидат не получает итоговую рекомендацию | Backend | -| Пропуск оценки при активном критерии | `unknown` не превращается в допустимость или нулевой риск | Backend + ML | -| Очень выгодный, но недопустимый вариант | Экономика не отменяет жёсткое ограничение | Backend | -| Отсутствует action capability | Нельзя менять уставки через обычный прогноз | ML + Backend | -| Три фиксированных сценария раздела 9 | `abstain` с известными серными counterfactuals и reason codes | Оба | -| Исключение агента и ошибка записи | Техническая ошибка не замаскирована технологическим отказом | Backend | -| Повтор запуска | Совпадают решение и численные оценки, кроме служебных ID/времени | Оба | - -Перед финальной сдачей: запустить временную оценку на реальных данных, проверить минимум нормальный эпизод, риск качества и неполные/аномальные данные; дополнительно показать полный агентный цикл модельной оптимизации. Искусственно повреждённый эпизод явно подписать. В отчёте отдельно указать ошибки прогноза на истории и условный эффект в модельной среде. - -Целевой бюджет после загрузки данных: один цикл с максимум 126 кандидатами до 5 секунд на рабочем CPU-ноутбуке; это инженерная цель, не измеренный результат. В отчёте указать CPU, объём памяти, число кандидатов и фактическое время. - -## 14. Работа двух исполнителей - -### Владение и изоляция изменений - -Интеграционная ветка — `dev`. Рабочие ветки — `backend/` и `ml/` от актуальной `dev`, по одной небольшой поставке. У каждого исполнителя отдельный checkout или Git worktree. Два агента не переключают ветки, не выполняют reset и не меняют общий индекс Git в одном каталоге. - -В поставке — одна завершённая возможность с проверкой. Автор меняет свою область из раздела 3. Если нужен файл партнёра, передаёт описание изменения владельцу; владение можно временно передать явно, с указанием файлов и commit базы. Одновременное редактирование общего файла не считается способом интеграции. - -Backend собирает интеграционную версию и координирует слияние. ML проверяет семантику признаков, единиц, ограничений и численных результатов. Проверка партнёром обязательна для общих контрактов и сквозной логики; отдельный третий ревьюер не требуется. - -### Первая совместная поставка - -До независимой разработки оба исполнителя фиксируют: - -1. Pydantic-типы из раздела 5, сигнатуры раздела 6 и один сериализованный пример `ProcessState`/`Recommendation`. -2. В `global_tests/fixtures/` — три состояния для модельного смешения, ожидаемые статусы и численные значения из раздела 9. ML задаёт значения, backend оформляет контрактные fixtures. -3. Малую телеметрию и ЛИМС с задержанным анализом для проверки времени; подготовленный manifest и известный порядок признаков. -4. Команду проверки контрактов и модельного цикла. До реализации цикла допускаются отдельные тесты DTO; этап 1 закрывается только реальным сквозным запуском. - -Затем backend строит приложение с фиксированными результатами агентов **только в тестовых fixtures**. ML разрабатывает настоящие функции с теми же входами/выходами. В демонстрационном режиме используются реальные формулы сценария, а не заранее записанный успешный ответ. - -### Изменение контракта - -1. Инициатор описывает проблему, старую и новую форму входа/выхода, затронутые функции и единицы. Сообщение «нужен ещё один параметр» без примера недостаточно. -2. Второй исполнитель проверяет влияние на свою часть. Backend фиксирует согласованное изменение в DTO, этом дизайне и fixtures одной поставкой. -3. Потребитель обновляется в том же PR либо отдельным зависимым PR до слияния в `dev`. Ветка `dev` не остаётся в состоянии, где один модуль возвращает новый контракт, а другой читает старый. -4. Изменение схемы сопровождается версией и проверкой совместимости артефактов. Старые модели не пересохраняются под новым ID без повторной проверки признаков. - -Для гипотез ML не меняет общий контракт: эксперимент ведётся внутри своей ветки. В контракт попадает только принятая потребность, которую использует другая сторона. - -### Передача результата - -Каждая передача через PR или сообщение содержит: - -```text -Задача и владелец: -Базовый commit dev и commit результата: -Что изменилось для второго исполнителя: -Версия контракта; dataset_id / model_id при наличии: -Как воспроизвести: команда и пример входа: -Ожидаемый результат: статус, ключевые числа, путь артефакта: -Что проверено: команды и фактический исход: -Известные ограничения и блокеры: -Следующее действие второго исполнителя: -``` - -Модель передаётся не одним `model.joblib`, а полным каталогом из раздела 11, manifest данных/командой их подготовки и примером применения. Локальный абсолютный путь одного исполнителя не передаёт артефакт: второй должен иметь доступ к артефакту или воспроизводимой команде создания по закреплённому commit. Для первого этапа достаточно небольшой baseline-модели, которую оба могут обучить на CPU. - -Backend → ML: подготовленные таблицы, manifest, отчёт проблем и тестовую точку времени. ML → backend: модель/формулы, metadata, ограничения применимости и ожидаемый результат на этой точке. Получатель воспроизводит результат, а не подтверждает только получение файла. - -### Регулярная интеграция - -Минимум раз в рабочий день и после изменения контракта: - -1. Каждый сообщает готовую поставку, зависимость от партнёра и блокер с конкретным входом/ошибкой. -2. Backend собирает оба изменения от актуальной `dev`; конфликт семантики разрешается вместе с владельцем, не автоматическим выбором одной стороны. -3. Оба запускают три модельных сценария и проверяют `hold/recommend/abstain`. Для имеющейся модели проверяются загрузка и одна общая историческая точка. -4. Сравниваются выбранные источники, возраст анализов, список признаков, оценки, ограничения и итог; расхождения разбираются до нового улучшения. -5. Фиксируется один интеграционный commit с зелёными проверками. Следующая работа начинается от него. - -Общий критерий завершения задачи: код/конфигурация доступны в интеграционной версии, контракт совместим, целевой сценарий проходит, второй исполнитель воспроизвёл результат, существенные ограничения описаны. «Ноутбук работает у автора» или «UI показывает заглушку» не закрывает совместную поставку. - -### Что делать при блокировке - -- ML исследует данные или модель: backend продолжает на согласованных fixtures и модельных формулах, не придумывает новые численные правила. -- Backend меняет загрузчик: ML работает на закреплённом prepared dataset и не создаёт второй production-пайплайн чтения Excel. -- Не подтверждён технологический параметр: действие остаётся отключённым; интерфейс и сценарии отказа продолжают разрабатываться. -- Общий контракт ещё обсуждается: зависимая часть не сливается; независимые задачи выполняются без изменения интерфейса. - -## 15. Порядок фиксации и расширения - -| Этап плана | Какие части дизайна вводятся | -| --- | --- | -| 0 | Структура пакета, нормализация, словарь, временные правила и contracts | -| 1 | Сквозной цикл, baseline, модельные fixtures, журнал и минимальный UI | -| 2 | Общие признаки, обученная модель, metadata и временная оценка | -| 3 | Единый фильтр, кандидаты, ранжирование, проверка возможности оценки действий | -| 4 | Полный интерфейс модельного блендинга и hybrid-сценарий при подтверждённых связях | -| 5 | Статистические границы, область применимости, существенность и cooldown | -| 6 | Чистый запуск, отчёты, негативные сценарии и демонстрация | - -Незавершённая возможность обозначается через capabilities и понятный отказ, не скрывается условной константой. - -На этапе 0 исследуются, а не назначаются архитектурой: истинные единицы спорных тегов, управляющие параметры гидроочистки, технологические пределы, подтверждённые связи потоков, задержки, реальная товарная спецификация сверх серы. До подтверждения соответствующие действия и заявления о полноте проверки отключены. Владелец исследования — ML; backend обеспечивает исполнение запрета. - -Изменения полей DTO, единиц или временной семантики требуют обновления `schema_version` и явной проверки совместимости. Замена baseline на более точную модель с теми же входами меняет `model_id`, но не контракт. HTTP API, внешние интеграции и промышленное управление рассматриваются отдельным будущим дизайном после готовности этой версии. - -## 16. Подтверждения экспертов от 10.09.2026 - -Основание этого раздела — ответы составителей задания в рабочем чате, переданные команде 10.09.2026. Они заменяют противоречащие им начальные допущения выше. - -- Короткие имена колонок `avt_tags.csv` и `242000_tags.csv` напрямую соответствуют листу «КИП» в `Теги_хакатон.xlsx`; дополнительное масштабирование значений не требуется. Это подтверждает идентичность тегов, но не создаёт отсутствующие в материалах технологические пределы. -- Строка единиц в ЛИМС содержит ошибки. Каноническая единица определяется подтверждённым смыслом показателя: температуры кипения — `degC`, `D15` — `kg/m3`, `I250/I350` — `vol%`. Исходный ошибочный заголовок сохраняется в provenance. -- Время ЛИМС — момент отбора пробы; публикация занимает до 4 часов. Все источники используют один общий часовой пояс. До получения точного IANA-идентификатора сохраняется настроенный `Europe/Moscow`, явно записываемый в manifest. -- `24-2000:Mg.Sulfur` соответствует `Mg.Sulfur` точки 2 гидроочистки. Подтверждение корректности единиц принимается как массовый `ppm`, численно эквивалентный `mg/kg`; источники ПАК и ЛИМС всё равно остаются раздельными в обучении и отчёте. -- Исправленные формулы ВАК фиксируются отдельно от исходного Excel: `T90` использует `59.57*(F15/2000)`; коэффициент `T6` в `T50` равен `0.471`; `CloudPoint` начинается с `0.0002*F22`; первый член `CFPP` равен `0.22088*T23`; коэффициент `T6` в `T95` равен `0.50`. В формуле `AVT6:240-350:CFPP` последняя скобка лишняя, член читается как `F65/F32 + F30`. -- Подтверждённые доступные оператору переменные гидроочистки: `P8` — температура ГСС на входе Р-202, `T11` — массовый расход сырья, `F19` — давление на входе Р-202. Пределы скорости не предоставлены; задержку эффекта следует оценивать в диапазоне 0–3 часов. До определения единиц, диапазонов, совместной области и отдельной проверки модели действий реальные controls в сценариях остаются выключенными. -- Допустимый шаг рекомендаций — 15–60 минут, горизонт — 0–3 часа. Для Stage 2 сохраняется заранее выбранная точка 60 минут. -- Для модельного блендинга обязательны сера, `T95` и цетановое число. Разрешена явно модельная имитация резервуаров; присадка до 3% и её цена в 100 раз выше цены ДТ относятся к будущему сценарию и не подменяют отсутствующие реальные данные. -- Отложенная историческая оценка прогноза и собственная модель альтернативных действий показываются раздельно; история прогноза сама по себе не доказывает эффект невыполненных воздействий. + +## 13. Проверки и критерии приёмки + +CI работает на малых синтетических fixtures без загрузки 100 МБ LFS и без полного обучения. Полная оценка истории запускается отдельно перед сдачей. Вместо `assert True` нужен сквозной `model_demo`. + +| Проверка | Что доказывает | Владелец | +| --- | --- | --- | +| Разные даты в соседних парах Excel | Отсутствие объединения по номеру строки | Backend | +| Дубликаты, `Pt Created`, неизвестная единица | Нет тихих численных подстановок | Backend | +| Проба взята до t, опубликована после t | Будущий лабораторный факт не попал в состояние/признаки | Backend + ML | +| Признаки для обучения и запуска на одном t | Совпадают имена, порядок и значения | ML | +| Граница train/test и вычисление статистик | Ни цели, ни preprocessing не используют будущие данные | ML | +| Сера 10, 10.0001 и верхняя граница выше 10 | Порог проверяется до округления; граница используется по настройке | Backend | +| Неверные доли, запас и неподтверждённая уставка | Недопустимый кандидат не получает итоговую рекомендацию | Backend | +| Пропуск оценки при активном критерии | `unknown` не превращается в допустимость или нулевой риск | Backend + ML | +| Очень выгодный, но недопустимый вариант | Экономика не отменяет жёсткое ограничение | Backend | +| Отсутствует action capability | Нельзя менять уставки через обычный прогноз | ML + Backend | +| Пять фиксированных сценариев раздела 9 | S/T95/CN, присадка, `hold`, `recommend`, `abstain` с известными числами | Оба | +| Исключение агента и ошибка записи | Техническая ошибка не замаскирована технологическим отказом | Backend | +| Повтор запуска | Совпадают решение и численные оценки, кроме служебных ID/времени | Оба | + +Перед финальной сдачей: запустить временную оценку на реальных данных, проверить минимум нормальный эпизод, риск качества и неполные/аномальные данные; дополнительно показать полный агентный цикл модельной оптимизации. Искусственно повреждённый эпизод явно подписать. В отчёте отдельно указать ошибки прогноза на истории и условный эффект в модельной среде. + +Целевой бюджет после загрузки данных: один цикл с максимум 126 кандидатами до 5 секунд на рабочем CPU-ноутбуке; это инженерная цель, не измеренный результат. В отчёте указать CPU, объём памяти, число кандидатов и фактическое время. + +## 14. Работа двух исполнителей + +### Владение и изоляция изменений + +Интеграционная ветка — `dev`. Рабочие ветки — `backend/` и `ml/` от актуальной `dev`, по одной небольшой поставке. У каждого исполнителя отдельный checkout или Git worktree. Два агента не переключают ветки, не выполняют reset и не меняют общий индекс Git в одном каталоге. + +В поставке — одна завершённая возможность с проверкой. Автор меняет свою область из раздела 3. Если нужен файл партнёра, передаёт описание изменения владельцу; владение можно временно передать явно, с указанием файлов и commit базы. Одновременное редактирование общего файла не считается способом интеграции. + +Backend собирает интеграционную версию и координирует слияние. ML проверяет семантику признаков, единиц, ограничений и численных результатов. Проверка партнёром обязательна для общих контрактов и сквозной логики; отдельный третий ревьюер не требуется. + +### Первая совместная поставка + +До независимой разработки оба исполнителя фиксируют: + +1. Pydantic-типы из раздела 5, сигнатуры раздела 6 и один сериализованный пример `ProcessState`/`Recommendation`. +2. В `global_tests/fixtures/` — три состояния для модельного смешения, ожидаемые статусы и численные значения из раздела 9. ML задаёт значения, backend оформляет контрактные fixtures. +3. Малую телеметрию и ЛИМС с задержанным анализом для проверки времени; подготовленный manifest и известный порядок признаков. +4. Команду проверки контрактов и модельного цикла. До реализации цикла допускаются отдельные тесты DTO; этап 1 закрывается только реальным сквозным запуском. + +Затем backend строит приложение с фиксированными результатами агентов **только в тестовых fixtures**. ML разрабатывает настоящие функции с теми же входами/выходами. В демонстрационном режиме используются реальные формулы сценария, а не заранее записанный успешный ответ. + +### Изменение контракта + +1. Инициатор описывает проблему, старую и новую форму входа/выхода, затронутые функции и единицы. Сообщение «нужен ещё один параметр» без примера недостаточно. +2. Второй исполнитель проверяет влияние на свою часть. Backend фиксирует согласованное изменение в DTO, этом дизайне и fixtures одной поставкой. +3. Потребитель обновляется в том же PR либо отдельным зависимым PR до слияния в `dev`. Ветка `dev` не остаётся в состоянии, где один модуль возвращает новый контракт, а другой читает старый. +4. Изменение схемы сопровождается версией и проверкой совместимости артефактов. Старые модели не пересохраняются под новым ID без повторной проверки признаков. + +Для гипотез ML не меняет общий контракт: эксперимент ведётся внутри своей ветки. В контракт попадает только принятая потребность, которую использует другая сторона. + +### Передача результата + +Каждая передача через PR или сообщение содержит: + +```text +Задача и владелец: +Базовый commit dev и commit результата: +Что изменилось для второго исполнителя: +Версия контракта; dataset_id / model_id при наличии: +Как воспроизвести: команда и пример входа: +Ожидаемый результат: статус, ключевые числа, путь артефакта: +Что проверено: команды и фактический исход: +Известные ограничения и блокеры: +Следующее действие второго исполнителя: +``` + +Модель передаётся не одним `model.joblib`, а полным каталогом из раздела 11, manifest данных/командой их подготовки и примером применения. Локальный абсолютный путь одного исполнителя не передаёт артефакт: второй должен иметь доступ к артефакту или воспроизводимой команде создания по закреплённому commit. Для первого этапа достаточно небольшой baseline-модели, которую оба могут обучить на CPU. + +Backend → ML: подготовленные таблицы, manifest, отчёт проблем и тестовую точку времени. ML → backend: модель/формулы, metadata, ограничения применимости и ожидаемый результат на этой точке. Получатель воспроизводит результат, а не подтверждает только получение файла. + +### Регулярная интеграция + +Минимум раз в рабочий день и после изменения контракта: + +1. Каждый сообщает готовую поставку, зависимость от партнёра и блокер с конкретным входом/ошибкой. +2. Backend собирает оба изменения от актуальной `dev`; конфликт семантики разрешается вместе с владельцем, не автоматическим выбором одной стороны. +3. Оба запускают три модельных сценария и проверяют `hold/recommend/abstain`. Для имеющейся модели проверяются загрузка и одна общая историческая точка. +4. Сравниваются выбранные источники, возраст анализов, список признаков, оценки, ограничения и итог; расхождения разбираются до нового улучшения. +5. Фиксируется один интеграционный commit с зелёными проверками. Следующая работа начинается от него. + +Общий критерий завершения задачи: код/конфигурация доступны в интеграционной версии, контракт совместим, целевой сценарий проходит, второй исполнитель воспроизвёл результат, существенные ограничения описаны. «Ноутбук работает у автора» или «UI показывает заглушку» не закрывает совместную поставку. + +### Что делать при блокировке + +- ML исследует данные или модель: backend продолжает на согласованных fixtures и модельных формулах, не придумывает новые численные правила. +- Backend меняет загрузчик: ML работает на закреплённом prepared dataset и не создаёт второй production-пайплайн чтения Excel. +- Не подтверждён технологический параметр: действие остаётся отключённым; интерфейс и сценарии отказа продолжают разрабатываться. +- Общий контракт ещё обсуждается: зависимая часть не сливается; независимые задачи выполняются без изменения интерфейса. + +## 15. Порядок фиксации и расширения + +| Этап плана | Какие части дизайна вводятся | +| --- | --- | +| 0 | Структура пакета, нормализация, словарь, временные правила и contracts | +| 1 | Сквозной цикл, baseline, модельные fixtures, журнал и минимальный UI | +| 2 | Общие признаки, обученная модель, metadata и временная оценка | +| 3 | Единый фильтр, кандидаты, ранжирование, проверка возможности оценки действий | +| 4 | Полный интерфейс модельного блендинга и hybrid-сценарий при подтверждённых связях | +| 5 | Статистические границы, область применимости, существенность и cooldown | +| 6 | Чистый запуск, отчёты, негативные сценарии и демонстрация | + +Незавершённая возможность обозначается через capabilities и понятный отказ, не скрывается условной константой. + +На этапе 0 исследуются, а не назначаются архитектурой: истинные единицы спорных тегов, управляющие параметры гидроочистки, технологические пределы, подтверждённые связи потоков, задержки, реальная товарная спецификация сверх серы. До подтверждения соответствующие действия и заявления о полноте проверки отключены. Владелец исследования — ML; backend обеспечивает исполнение запрета. + +Изменения полей DTO, единиц или временной семантики требуют обновления `schema_version` и явной проверки совместимости. Замена baseline на более точную модель с теми же входами меняет `model_id`, но не контракт. HTTP API, внешние интеграции и промышленное управление рассматриваются отдельным будущим дизайном после готовности этой версии. + +## 16. Подтверждения экспертов от 10.09.2026 + +Основание этого раздела — ответы составителей задания в рабочем чате, переданные команде 10.09.2026. Они заменяют противоречащие им начальные допущения выше. + +- Короткие имена колонок `avt_tags.csv` и `242000_tags.csv` напрямую соответствуют листу «КИП» в `Теги_хакатон.xlsx`; дополнительное масштабирование значений не требуется. Это подтверждает идентичность тегов, но не создаёт отсутствующие в материалах технологические пределы. +- Строка единиц в ЛИМС содержит ошибки. Каноническая единица определяется подтверждённым смыслом показателя: температуры кипения — `degC`, `D15` — `kg/m3`, `I250/I350` — `vol%`. Исходный ошибочный заголовок сохраняется в provenance. +- Время ЛИМС — момент отбора пробы; публикация занимает до 4 часов. Все источники используют один общий часовой пояс. До получения точного IANA-идентификатора сохраняется настроенный `Europe/Moscow`, явно записываемый в manifest. +- `24-2000:Mg.Sulfur` соответствует `Mg.Sulfur` точки 2 гидроочистки. Подтверждение корректности единиц принимается как массовый `ppm`, численно эквивалентный `mg/kg`; источники ПАК и ЛИМС всё равно остаются раздельными в обучении и отчёте. +- Исправленные формулы ВАК фиксируются отдельно от исходного Excel: `T90` использует `59.57*(F15/2000)`; коэффициент `T6` в `T50` равен `0.471`; `CloudPoint` начинается с `0.0002*F22`; первый член `CFPP` равен `0.22088*T23`; коэффициент `T6` в `T95` равен `0.50`. В формуле `AVT6:240-350:CFPP` последняя скобка лишняя, член читается как `F65/F32 + F30`. +- Подтверждённые доступные оператору переменные гидроочистки: `P8` — температура ГСС на входе Р-202, `T11` — массовый расход сырья, `F19` — давление на входе Р-202. Пределы скорости не предоставлены; задержку эффекта следует оценивать в диапазоне 0–3 часов. До определения единиц, диапазонов, совместной области и отдельной проверки модели действий реальные controls в сценариях остаются выключенными. +- Допустимый шаг рекомендаций — 15–60 минут, горизонт — 0–3 часа. Для Stage 2 сохраняется заранее выбранная точка 60 минут. +- Для модельного блендинга обязательны сера, `T95` и цетановое число. Реализована явно модельная имитация резервуаров; присадка до 3% и её цена в 100 раз выше цены ДТ заданы в сценариях. Кривая эффективности остаётся синтетическим настраиваемым допущением и не подменяет отсутствующие реальные данные. +- Отложенная историческая оценка прогноза и собственная модель альтернативных действий показываются раздельно; история прогноза сама по себе не доказывает эффект невыполненных воздействий. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 80caa22..ffcdc6a 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,229 +1,232 @@ -# План реализации Нефтекода с разделением backend и ML - -## 1. Цель и исходное состояние - -За **1–2 недели силами двух исполнителей (людей или агентов) на CPU** собрать воспроизводимый прототип: система получает состояние производства, оценивает качество и риск, сравнивает допустимые изменения режима и выдаёт оператору рекомендацию либо объяснённый отказ. - -Основа — [ТЗ из репозитория](materials/ТЗ_нефтекод.docx), [технологические схемы](materials/АВТ_схемы.pdf) и [исходные данные](materials/README.md). Архитектура, контракты и порядок совместной работы зафиксированы в [техническом дизайне](DESIGN.md). Блендинг включаем как **явно обозначенный модельный сценарий**, поскольку отдельных исторических рецептур в пакете не найдено. - +# План реализации Нефтекода с разделением backend и ML + +## 1. Цель и исходное состояние + +За **1–2 недели силами двух исполнителей (людей или агентов) на CPU** собрать воспроизводимый прототип: система получает состояние производства, оценивает качество и риск, сравнивает допустимые изменения режима и выдаёт оператору рекомендацию либо объяснённый отказ. + +Основа — [ТЗ из репозитория](materials/ТЗ_нефтекод.docx), [технологические схемы](materials/АВТ_схемы.pdf) и [исходные данные](materials/README.md). Архитектура, контракты и порядок совместной работы зафиксированы в [техническом дизайне](DESIGN.md). Блендинг включаем как **явно обозначенный модельный сценарий**, поскольку отдельных исторических рецептур в пакете не найдено. + Этот раздел фиксирует исходную точку плана. Актуальный статус: реализованы Stages 0–6, включая preparation/state pipeline, safety guardrails, uncertainty, frozen evaluation, -приёмочные эпизоды и локальный Tkinter UI для model-demo. Интеграция исторического -артефакта в UI и action model остаются за границей версии 1. - -В данных: - -- Телеметрия АВТ и гидроочистки находится в `materials/data.rar`: по **189 217 строк**, с 01.01.2023 по 07.08.2026. -- ЛИМС содержит отдельные пары «время — значение» для каждого показателя. На выходе гидроочистки есть **1 462 измерения серы**, но только **42 измерения цетанового числа**. -- У показателей ПАК разные временные шкалы; плотность начинается только в марте 2025 года. -- Встречаются служебные значения, противоречивые единицы и неоднозначные соответствия тегов. Формулы ВАК также требуют проверки. -- Максимум лабораторной серы — **2 120 мг/кг**, ПАК — около **20 мг/кг**. Причину расхождения предстоит установить; автоматически удалять такие эпизоды нельзя. - -**Основной результат:** работающая система по всему циклу ТЗ. Более сложные модели добавляются только после готовности предыдущего этапа. Сроки — ориентиры для двух параллельных направлений; этап завершается по критерию готовности. - -## 2. Разделение ответственности и архитектура - -«Backend» и «ML» — два направления работы. Интерфейс — backend; подготовка признаков и оптимизация — ML. - -| Область | Backend | ML | -| --- | --- | --- | -| Данные | Чтение файлов, нормализация формата, временное объединение, кэш | Смысл тегов, единицы, правила качества данных, выбор показателей | -| Модели | Загрузка артефактов, проверка совместимости, вызов | Признаки, обучение, валидация, неопределённость | -| Ограничения | Исполнение единой проверки, запрет недопустимого результата | Обоснование границ, формулы риска, критерии допустимости | -| Оптимизация | Подключение к циклу решения, обработка ошибок | Генерация кандидатов, прогноз последствий, ранжирование | -| Мультиагентность | Оркестратор, последовательность вызовов, журнал | Агент качества, агент надёжности, агент оптимизации | +пять приёмочных эпизодов, полный синтетический паспорт S/T95/CN, модель присадки и +локальный Tkinter UI для model-demo. Интеграция исторического артефакта в UI и +промышленная action model остаются за границей версии 1. + +В данных: + +- Телеметрия АВТ и гидроочистки находится в `materials/data.rar`: по **189 217 строк**, с 01.01.2023 по 07.08.2026. +- ЛИМС содержит отдельные пары «время — значение» для каждого показателя. На выходе гидроочистки есть **1 462 измерения серы**, но только **42 измерения цетанового числа**. +- У показателей ПАК разные временные шкалы; плотность начинается только в марте 2025 года. +- Встречаются служебные значения, противоречивые единицы и неоднозначные соответствия тегов. Формулы ВАК также требуют проверки. +- Максимум лабораторной серы — **2 120 мг/кг**, ПАК — около **20 мг/кг**. Причину расхождения предстоит установить; автоматически удалять такие эпизоды нельзя. + +**Основной результат:** работающая система по всему циклу ТЗ. Более сложные модели добавляются только после готовности предыдущего этапа. Сроки — ориентиры для двух параллельных направлений; этап завершается по критерию готовности. + +## 2. Разделение ответственности и архитектура + +«Backend» и «ML» — два направления работы. Интерфейс — backend; подготовка признаков и оптимизация — ML. + +| Область | Backend | ML | +| --- | --- | --- | +| Данные | Чтение файлов, нормализация формата, временное объединение, кэш | Смысл тегов, единицы, правила качества данных, выбор показателей | +| Модели | Загрузка артефактов, проверка совместимости, вызов | Признаки, обучение, валидация, неопределённость | +| Ограничения | Исполнение единой проверки, запрет недопустимого результата | Обоснование границ, формулы риска, критерии допустимости | +| Оптимизация | Подключение к циклу решения, обработка ошибок | Генерация кандидатов, прогноз последствий, ранжирование | +| Мультиагентность | Оркестратор, последовательность вызовов, журнал | Агент качества, агент надёжности, агент оптимизации | | Демонстрация | Desktop UI на Tkinter, воспроизведение model-demo, отображение объяснений | Выбор эпизодов, метрики, проверка содержательных выводов | -| Приёмка | Интеграционные тесты и воспроизводимый запуск | Проверка утечек, качества прогнозов и корректности экспериментов | - -Каждый отвечает за тесты своей части. **ML задаёт формулы и ограничения; backend реализует их единое применение**, для одинаковой логики оптимизатора и интерфейса. - -### Совместная работа двух исполнителей - -- Backend владеет общими контрактами и интеграцией, ML — признаками, численными моделями и сценариями. Изменение общего интерфейса сначала фиксируется примером входа/выхода и тестом совместимости. -- Каждый работает в отдельной ветке и отдельном checkout/worktree. Файлы партнёра одновременно не редактируются; изменения согласуются через конкретный commit и описание ожидаемого поведения. -- Первая совместная поставка — DTO, тестовые состояния и фиксированный модельный сценарий. После неё оба направления работают параллельно: backend со структурированными fixtures, ML с тем же контрактом. -- Минимум раз в рабочий день и после изменения контракта — интеграция: три сквозных сценария, загрузка актуального артефакта при его наличии и проверка журнала. -- Передача задачи: commit, выполненные проверки, пример результата, ограничения и следующий необходимый шаг партнёра. Задача завершена после запуска результата вторым исполнителем. -- Каждый этап заканчивается совместно запускаемым приложением. Улучшения моделей не откладывают интеграцию до последнего дня. - -Архитектура — одно Python-приложение с обычными функциями и классами: - -```text -Источники → состояние процесса → оценки качества и риска → кандидаты - → проверка ограничений → выбор → объяснение и журнал -``` - +| Приёмка | Интеграционные тесты и воспроизводимый запуск | Проверка утечек, качества прогнозов и корректности экспериментов | + +Каждый отвечает за тесты своей части. **ML задаёт формулы и ограничения; backend реализует их единое применение**, для одинаковой логики оптимизатора и интерфейса. + +### Совместная работа двух исполнителей + +- Backend владеет общими контрактами и интеграцией, ML — признаками, численными моделями и сценариями. Изменение общего интерфейса сначала фиксируется примером входа/выхода и тестом совместимости. +- Каждый работает в отдельной ветке и отдельном checkout/worktree. Файлы партнёра одновременно не редактируются; изменения согласуются через конкретный commit и описание ожидаемого поведения. +- Первая совместная поставка — DTO, тестовые состояния и фиксированный модельный сценарий. После неё оба направления работают параллельно: backend со структурированными fixtures, ML с тем же контрактом. +- Минимум раз в рабочий день и после изменения контракта — интеграция: три сквозных сценария, загрузка актуального артефакта при его наличии и проверка журнала. +- Передача задачи: commit, выполненные проверки, пример результата, ограничения и следующий необходимый шаг партнёра. Задача завершена после запуска результата вторым исполнителем. +- Каждый этап заканчивается совместно запускаемым приложением. Улучшения моделей не откладывают интеграцию до последнего дня. + +Архитектура — одно Python-приложение с обычными функциями и классами: + +```text +Источники → состояние процесса → оценки качества и риска → кандидаты + → проверка ограничений → выбор → объяснение и журнал +``` + Используем имеющиеся pandas, NumPy, scikit-learn, Pydantic, Tkinter и pytest. Добавляем `openpyxl` для чтения Excel и `tzdata` для единообразной работы часовых поясов. HTTP API, отдельные сервисы и база данных для первой версии не нужны. - -Объяснение формируется шаблоном из результатов расчёта. LLM в обязательную часть не входит. - -### Общий контракт - -Контракт фиксируем в первый день: backend работает с тестовыми оценками, пока ML готовит модели. - -| Объект | Минимальное содержание | -| --- | --- | -| `ProcessState` | Время решения, значения и единицы, источники, время измерения и доступности, возраст, флаги качества | -| `CandidateAction` | Идентификатор, изменения подтверждённых управляющих параметров, горизонт прогноза, признак модельного сценария | -| `AgentAssessment` | Показатели качества, риск, неопределённость, применимость модели, причины ограничений | -| `Recommendation` | `recommend`, `hold` или `abstain`; действие, альтернативы, ожидаемые эффекты, результаты проверок и объяснение | - -ML предоставляет `predict_quality`, `assess_reliability` и `evaluate_candidates`. Backend вызывает их из `run_cycle` и сохраняет полный результат в JSONL. Обучение отделено от запуска приложения. - -## 3. Этапы реализации - -### Этап 0 — согласованные данные и правила, дни 1–2 - -**Backend:** - -- Подготовить воспроизводимое чтение архива, CSV и Excel без изменения исходников. -- Разбирать каждую пару «дата — значение» ЛИМС/ПАК отдельно. -- Ввести пространства имён тегов АВТ и гидроочистки, удалить только служебные индексные столбцы. -- Сохранять происхождение значения и причину признания его недостоверным. -- Привести конфигурацию инструментов к распознаваемому формату: существующий `config.toml` заменить на `pyproject.toml`, указать реальную папку тестов. - -**ML:** - -- Составить таблицу соответствий: тег → физический смысл → единица → источник подтверждения → возможность управления. -- Выделить признаки, цели прогнозирования и признаки, способные раскрывать целевое значение. -- Разобрать расхождения ЛИМС/ПАК, ошибки единиц и неоднозначные формулы. -- Зафиксировать первоначальный сценарий качества по сере и перечень остальных показателей, которые можно обоснованно проверять. -- Определить правила свежести и доступности анализов. По ответу эксперта от 10.09.2026 время ЛИМС означает отбор пробы, публикация занимает до 4 часов; использовать 4 часа консервативно и проверить чувствительность к меньшей задержке. - -**Готовность:** оба направления используют один формат данных и один словарь. Неподтверждённые теги не допускаются к управляющим воздействиям. - -### Этап 1 — первый сквозной прототип, дни 2–3 - -**Backend:** - -- Реализовать воспроизведение состояния на выбранный момент истории. -- Подключить четыре логические роли: качество, надёжность, оптимизация, оркестратор. + +Объяснение формируется шаблоном из результатов расчёта. LLM в обязательную часть не входит. + +### Общий контракт + +Контракт фиксируем в первый день: backend работает с тестовыми оценками, пока ML готовит модели. + +| Объект | Минимальное содержание | +| --- | --- | +| `ProcessState` | Время решения, значения и единицы, источники, время измерения и доступности, возраст, флаги качества | +| `CandidateAction` | Идентификатор, изменения подтверждённых управляющих параметров, горизонт прогноза, признак модельного сценария | +| `AgentAssessment` | Показатели качества, риск, неопределённость, применимость модели, причины ограничений | +| `Recommendation` | `recommend`, `hold` или `abstain`; действие, альтернативы, ожидаемые эффекты, результаты проверок и объяснение | + +ML предоставляет `predict_quality`, `assess_reliability` и `evaluate_candidates`. Backend вызывает их из `run_cycle` и сохраняет полный результат в JSONL. Обучение отделено от запуска приложения. + +## 3. Этапы реализации + +### Этап 0 — согласованные данные и правила, дни 1–2 + +**Backend:** + +- Подготовить воспроизводимое чтение архива, CSV и Excel без изменения исходников. +- Разбирать каждую пару «дата — значение» ЛИМС/ПАК отдельно. +- Ввести пространства имён тегов АВТ и гидроочистки, удалить только служебные индексные столбцы. +- Сохранять происхождение значения и причину признания его недостоверным. +- Привести конфигурацию инструментов к распознаваемому формату: существующий `config.toml` заменить на `pyproject.toml`, указать реальную папку тестов. + +**ML:** + +- Составить таблицу соответствий: тег → физический смысл → единица → источник подтверждения → возможность управления. +- Выделить признаки, цели прогнозирования и признаки, способные раскрывать целевое значение. +- Разобрать расхождения ЛИМС/ПАК, ошибки единиц и неоднозначные формулы. +- Зафиксировать первоначальный сценарий качества по сере и перечень остальных показателей, которые можно обоснованно проверять. +- Определить правила свежести и доступности анализов. По ответу эксперта от 10.09.2026 время ЛИМС означает отбор пробы, публикация занимает до 4 часов; использовать 4 часа консервативно и проверить чувствительность к меньшей задержке. + +**Готовность:** оба направления используют один формат данных и один словарь. Неподтверждённые теги не допускаются к управляющим воздействиям. + +### Этап 1 — первый сквозной прототип, дни 2–3 + +**Backend:** + +- Реализовать воспроизведение состояния на выбранный момент истории. +- Подключить четыре логические роли: качество, надёжность, оптимизация, оркестратор. - Добавить минимальный desktop UI: состояние, свежесть данных, результаты агентов, итог. -- Реализовать статусы рекомендации, сохранения режима и отказа. - -**ML:** - -- Дать базовую оценку качества по последним доступным пригодным измерениям. -- Реализовать прозрачный индекс тяжести режима по подтверждённым параметрам. -- Подготовить небольшой модельный пример смешения с известными свойствами компонентов. -- Добавить несколько вариантов, включая «ничего не менять». - -**Готовность:** показан полный цикл — от данных до объяснения. Модельные примеры явно подписаны; неподготовленная ML-модель не маскируется заглушкой. - -### Этап 2 — прогноз серы, дни 3–6 - -**Backend:** - -- Подключить сохранённую модель и общий с обучением код подготовки признаков. -- Добавить проверку версии модели, набора признаков и единиц. -- Показывать прогноз, горизонт и причины недоверия. - -**ML:** - -- Начать с прогноза серы гидроочищенного продукта. Горизонт первой версии — **60 минут**, как экспериментальное допущение. -- Сравнить сохранение последнего значения, Ridge и `HistGradientBoostingRegressor`. Последний уже входит в выбранный стек и поддерживает квантильную регрессию для последующего этапа неопределённости: [документация scikit-learn](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.HistGradientBoostingRegressor.html). -- Использовать только доступные на момент решения признаки: прошлые значения, изменения, агрегаты и возраст анализов. -- ПАК использовать как отдельный источник обучающих целей; качество относительно ЛИМС оценивать отдельно на реальных лабораторных измерениях. -- Взять начальное разделение: 2023–2024 — обучение, 2025 — валидация, 2026 до конца телеметрии — финальный тест. -- Подбор параметров проводить внутри обучающего периода последовательными временными блоками. На границах исключать примеры с пересекающимися горизонтами целей. Принцип временного разбиения соответствует [документации TimeSeriesSplit](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html). - -**Готовность:** отчёт с MAE, ошибкой около порога 10 мг/кг, пропущенными превышениями и ложными тревогами. Если сложная модель не улучшает базовую на валидации, сохраняется базовая. - -### Этап 3 — ограниченная оптимизация и надёжность, дни 6–8 - -**Backend:** - -- Реализовать общий фильтр жёстких ограничений и повторную проверку выбранного результата. -- Сохранять причины отбраковки каждого кандидата. -- Показывать текущее и рекомендуемое значения, альтернативы и происхождение границ. - -**ML:** - -- Выбрать **2–3 подтверждённых управляющих параметра**. Для каждого описать допустимый диапазон, максимальный шаг и основание. -- Использовать небольшой детерминированный перебор вблизи текущего режима. -- Проверять совместную правдоподобность комбинаций параметров, а не только отдельные min/max. -- Сначала исключать недопустимые варианты, затем ранжировать: меньший риск, больший выпуск, меньшие затраты, меньшее изменение режима. -- Энергию и стоимость при отсутствии прямых данных выражать прозрачными прокси. Индекс тяжести режима не называть вероятностью аварии. -- Отдельно проверить пригодность модели для сравнения действий. Простая подстановка новой уставки в прогнозную модель сама по себе этого не доказывает. - -**Готовность:** система находит допустимые варианты в демонстрационном сценарии и отказывается, когда все варианты нарушают ограничения. Без достаточных оснований влияния реальных уставок оптимизация остаётся в модельном сценарии, а на истории доступны прогноз и предупреждения. - -### Этап 4 — связанная цепочка и модельный блендинг, дни 8–10 - -**Backend:** - -- Разделить отображение исторических фактов и модельных предположений. -- Показать прохождение состояния через АВТ, гидроочистку и блендинг. -- Добавить карточку сценария с происхождением свойств компонентов. - -**ML:** - -- Учитывать качество входящего потока при оценке следующей стадии; транспортные задержки считать допущениями, пока они не подтверждены. -- Для модельного смешения применять массовые доли: `S_mix = Σ(w_i × S_i)`. -- Проверять неотрицательность долей, сумму 1 и ограничения доступности компонентов. -- Прогноз гидроочистки — характеристика соответствующего компонента. -- Остальные свойства смешения рассчитывать только по обоснованной зависимости. Линейное усреднение цетанового числа и низкотемпературных свойств по умолчанию не применять. - -**Готовность:** демонстрируется вся цепочка, включая влияние качества компонента на допустимую рецептуру. Соответствие по одной сере не выдаётся за соответствие всей товарной спецификации. - -### Этап 5 — устойчивость и неопределённость, дни 10–12 - -**Backend:** - -- Добавить обработку пропусков, устаревших анализов, конфликтов источников и неприменимости модели. -- Предотвращать лишние и постоянно меняющиеся рекомендации. -- Сохранять версии данных, модели, правил и сценария. - -**ML:** - -- Построить оценку верхней границы серы и проверить её покрытие на отложенных данных. -- Для допуска действия проверять верхнюю оценку относительно 10 мг/кг. -- Подобрать порог существенности улучшения и ограничения частоты изменений на валидации. -- Проверить устойчивость к задержкам лаборатории, ошибкам измерений и изменению режима. - -**Готовность:** нормальный период не вызывает лишних действий; нехватка данных снижает доверие или приводит к объяснённому отказу. - -### Этап 6 — приёмка и демонстрация, дни 12–14 - -**Backend:** - -- Подготовить запуск из чистого окружения, закрепить проверенные версии зависимостей. -- Описать получение LFS-данных, подготовку данных, обучение и запуск. -- Добавить воспроизводимый выбор демонстрационных эпизодов и экспорт журнала. - -**ML:** - -- Зафиксировать модели до финального тестирования. -- Сравнить базовый прогноз и итоговую модель; в модельной среде — сохранение режима и оптимизацию. -- Отдельно представить точность на истории и модельный эффект действий. -- Подготовить ограничения решения и объяснение основных ошибок. - -**Готовность:** другой участник запускает приложение по README и воспроизводит результаты. Дни 13–14 — резерв на ошибки и защиту, без новых крупных функций. - -## 4. Обязательные ограничения и проверки - -### Правила для всех этапов - -- Сера товарной смеси — не более **10 мг/кг**; остальные ограничения берутся из подтверждённой спецификации выбранного сценария. -- Экономическая выгода не компенсирует нарушение жёсткого ограничения. -- Приоритет источников — ЛИМС → ПАК → ВАК, с учётом пригодности, свежести и времени доступности. Лабораторный факт сохраняется даже при расхождении с ПАК. -- Исторические границы — только модельные ограничения, пока нет подтверждения промышленного диапазона. -- Запрещены использование будущих анализов, заполнение признаков из будущего и обучение на размноженных через forward-fill лабораторных целях. -- Состояние «нет данных» не превращается в «нарушений нет». -- `hold` означает обоснованное сохранение режима; `abstain` — невозможность надёжно рекомендовать действие. - -### Приёмочные сценарии - -| Сценарий | Ожидаемый результат | -| --- | --- | -| Устойчивый допустимый режим | Сохранение режима без лишних действий | -| Риск превышения серы | Допустимый кандидат либо объяснённый отказ | -| Все кандидаты нарушают ограничения | Отказ независимо от выгоды | -| Устаревшие или отсутствующие данные | Корректное снижение доверия либо отказ | -| Конфликт ЛИМС и ПАК | Видимое расхождение и применение зафиксированного правила | -| Отрицательная доля или сумма долей ≠ 1 | Отклонение рецептуры | -| Неподтверждённый тег, неверная единица | Запрет соответствующего расчёта или действия | -| Состояние вне области применимости | Отказ от неподтверждённого прогноза действий | -| Повтор одного запуска | Одинаковые расчёты и выбранный результат | - -Метрики системы: частота отказов, частота действий, число нарушений фильтра, время одного цикла. **Всеотказывающаяся система не считается готовой:** нужны и рабочие допустимые сценарии, и корректные отказы. +- Реализовать статусы рекомендации, сохранения режима и отказа. + +**ML:** + +- Дать базовую оценку качества по последним доступным пригодным измерениям. +- Реализовать прозрачный индекс тяжести режима по подтверждённым параметрам. +- Подготовить небольшой модельный пример смешения с известными свойствами компонентов. +- Добавить несколько вариантов, включая «ничего не менять». + +**Готовность:** показан полный цикл — от данных до объяснения. Модельные примеры явно подписаны; неподготовленная ML-модель не маскируется заглушкой. + +### Этап 2 — прогноз серы, дни 3–6 + +**Backend:** + +- Подключить сохранённую модель и общий с обучением код подготовки признаков. +- Добавить проверку версии модели, набора признаков и единиц. +- Показывать прогноз, горизонт и причины недоверия. + +**ML:** + +- Начать с прогноза серы гидроочищенного продукта. Горизонт первой версии — **60 минут**, как экспериментальное допущение. +- Сравнить сохранение последнего значения, Ridge и `HistGradientBoostingRegressor`. Последний уже входит в выбранный стек и поддерживает квантильную регрессию для последующего этапа неопределённости: [документация scikit-learn](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.HistGradientBoostingRegressor.html). +- Использовать только доступные на момент решения признаки: прошлые значения, изменения, агрегаты и возраст анализов. +- ПАК использовать как отдельный источник обучающих целей; качество относительно ЛИМС оценивать отдельно на реальных лабораторных измерениях. +- Взять начальное разделение: 2023–2024 — обучение, 2025 — валидация, 2026 до конца телеметрии — финальный тест. +- Подбор параметров проводить внутри обучающего периода последовательными временными блоками. На границах исключать примеры с пересекающимися горизонтами целей. Принцип временного разбиения соответствует [документации TimeSeriesSplit](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html). + +**Готовность:** отчёт с MAE, ошибкой около порога 10 мг/кг, пропущенными превышениями и ложными тревогами. Если сложная модель не улучшает базовую на валидации, сохраняется базовая. + +### Этап 3 — ограниченная оптимизация и надёжность, дни 6–8 + +**Backend:** + +- Реализовать общий фильтр жёстких ограничений и повторную проверку выбранного результата. +- Сохранять причины отбраковки каждого кандидата. +- Показывать текущее и рекомендуемое значения, альтернативы и происхождение границ. + +**ML:** + +- Выбрать **2–3 подтверждённых управляющих параметра**. Для каждого описать допустимый диапазон, максимальный шаг и основание. +- Использовать небольшой детерминированный перебор вблизи текущего режима. +- Проверять совместную правдоподобность комбинаций параметров, а не только отдельные min/max. +- Сначала исключать недопустимые варианты, затем ранжировать: меньший риск, больший выпуск, меньшие затраты, меньшее изменение режима. +- Энергию и стоимость при отсутствии прямых данных выражать прозрачными прокси. Индекс тяжести режима не называть вероятностью аварии. +- Отдельно проверить пригодность модели для сравнения действий. Простая подстановка новой уставки в прогнозную модель сама по себе этого не доказывает. + +**Готовность:** система находит допустимые варианты в демонстрационном сценарии и отказывается, когда все варианты нарушают ограничения. Без достаточных оснований влияния реальных уставок оптимизация остаётся в модельном сценарии, а на истории доступны прогноз и предупреждения. + +### Этап 4 — связанная цепочка и модельный блендинг, дни 8–10 + +**Backend:** + +- Разделить отображение исторических фактов и модельных предположений. +- Показать прохождение состояния через АВТ, гидроочистку и блендинг. +- Добавить карточку сценария с происхождением свойств компонентов. + +**ML:** + +- Учитывать качество входящего потока при оценке следующей стадии; транспортные задержки считать допущениями, пока они не подтверждены. +- Для модельного смешения применять массовые доли: `S_mix = Σ(w_i × S_i)`. +- Проверять неотрицательность долей, сумму 1 и ограничения доступности компонентов. +- Прогноз гидроочистки — характеристика соответствующего компонента. +- Остальные свойства смешения рассчитывать только по объявленной зависимости. В финальном `model_demo` линейные T95/CN и кривая присадки разрешены как явно синтетические, изменяемые допущения; на реальные данные автоматически не переносятся. + +**Готовность:** демонстрируется вся цепочка, включая влияние качества компонента на допустимую рецептуру. Соответствие по одной сере не выдаётся за соответствие всей товарной спецификации. + +### Этап 5 — устойчивость и неопределённость, дни 10–12 + +**Backend:** + +- Добавить обработку пропусков, устаревших анализов, конфликтов источников и неприменимости модели. +- Предотвращать лишние и постоянно меняющиеся рекомендации. +- Сохранять версии данных, модели, правил и сценария. + +**ML:** + +- Построить оценку верхней границы серы и проверить её покрытие на отложенных данных. +- Для допуска действия проверять верхнюю оценку относительно 10 мг/кг. +- Подобрать порог существенности улучшения и ограничения частоты изменений на валидации. +- Проверить устойчивость к задержкам лаборатории, ошибкам измерений и изменению режима. + +**Готовность:** нормальный период не вызывает лишних действий; нехватка данных снижает доверие или приводит к объяснённому отказу. + +### Этап 6 — приёмка и демонстрация, дни 12–14 + +**Backend:** + +- Подготовить запуск из чистого окружения, закрепить проверенные версии зависимостей. +- Описать получение LFS-данных, подготовку данных, обучение и запуск. +- Добавить воспроизводимый выбор демонстрационных эпизодов и экспорт журнала. + +**ML:** + +- Зафиксировать модели до финального тестирования. +- Сравнить базовый прогноз и итоговую модель; в модельной среде — сохранение режима и оптимизацию. +- Отдельно представить точность на истории и модельный эффект действий. +- Подготовить ограничения решения и объяснение основных ошибок. + +**Готовность:** другой участник запускает приложение по README и воспроизводит результаты. Дни 13–14 — резерв на ошибки и защиту, без новых крупных функций. + +## 4. Обязательные ограничения и проверки + +### Правила для всех этапов + +- Сера товарной смеси — не более **10 мг/кг**; остальные ограничения берутся из подтверждённой спецификации выбранного сценария. +- Экономическая выгода не компенсирует нарушение жёсткого ограничения. +- Приоритет источников — ЛИМС → ПАК → ВАК, с учётом пригодности, свежести и времени доступности. Лабораторный факт сохраняется даже при расхождении с ПАК. +- Исторические границы — только модельные ограничения, пока нет подтверждения промышленного диапазона. +- Запрещены использование будущих анализов, заполнение признаков из будущего и обучение на размноженных через forward-fill лабораторных целях. +- Состояние «нет данных» не превращается в «нарушений нет». +- `hold` означает обоснованное сохранение режима; `abstain` — невозможность надёжно рекомендовать действие. + +### Приёмочные сценарии + +| Сценарий | Ожидаемый результат | +| --- | --- | +| Устойчивый допустимый режим | Сохранение режима без лишних действий | +| Риск превышения серы | Допустимый кандидат либо объяснённый отказ | +| Риск превышения T95 | Рецептура с `T95_upper` в пределах модельной границы | +| Низкое цетановое число | Рецептура/доза с `CN_lower` в пределах и учётом дорогой присадки | +| Все кандидаты нарушают ограничения | Отказ независимо от выгоды | +| Устаревшие или отсутствующие данные | Корректное снижение доверия либо отказ | +| Конфликт ЛИМС и ПАК | Видимое расхождение и применение зафиксированного правила | +| Отрицательная доля или сумма долей ≠ 1 | Отклонение рецептуры | +| Неподтверждённый тег, неверная единица | Запрет соответствующего расчёта или действия | +| Состояние вне области применимости | Отказ от неподтверждённого прогноза действий | +| Повтор одного запуска | Одинаковые расчёты и выбранный результат | + +Метрики системы: частота отказов, частота действий, число нарушений фильтра, время одного цикла. **Всеотказывающаяся система не считается готовой:** нужны и рабочие допустимые сценарии, и корректные отказы. diff --git a/PROJECT_GUIDE.md b/PROJECT_GUIDE.md index a886049..da60f04 100644 --- a/PROJECT_GUIDE.md +++ b/PROJECT_GUIDE.md @@ -10,6 +10,10 @@ оператору безопасное действие. Если безопасного действия нет или данных не хватает, система должна честно отказаться от рекомендации. +Актуальное финальное расширение: `model_demo` проверяет S/T95/CN, перебирает присадку +0–3% и выдаёт `hold`/`recommend`; `abstain` остаётся для неполного паспорта. Все эти +эффекты относятся только к объявленной синтетической модели. + --- ## 1. Суть проекта в одну минуту @@ -583,8 +587,8 @@ blend Например: ```text -sulfur = 8.4 мг/кг -upper = 9.4 мг/кг +sulfur = 8.316 мг/кг +upper = 9.306 мг/кг basis = formula interval_kind = scenario_bound ``` @@ -1141,20 +1145,23 @@ upper <= 10 мг/кг Текущая рецептура: ```text -A = 0.9 -B = 0.1 +A = 0.891 +B = 0.099 +присадка = 0.01 ``` Верхняя оценка серы: ```text -9.4 мг/кг +S upper = 9.306 мг/кг +T95 upper = 354 °C +CN lower = 52.6 ``` Результат: ```text -abstain (T95 и цетановое число не оценены) +hold ``` ### `blend_risk` @@ -1162,23 +1169,23 @@ abstain (T95 и цетановое число не оценены) Текущая рецептура: ```text -A = 0.7 -B = 0.3 +A = 0.693 +B = 0.297 +присадка = 0.01 ``` Верхняя оценка серы: ```text -14.2 мг/кг +14.058 мг/кг ``` -Это выше 10, значит `hold` недопустим. Но операторская рекомендация всё равно -блокируется: T95 и цетановое число не оценены. +Это выше 10, значит `hold` недопустим. Оптимизатор проверяет также T95 и CN. Результат: ```text -abstain (диагностический кандидат A=0.9, B=0.1 не является рекомендацией) +recommend: A=0.891, B=0.099, присадка=0.01 ``` ### `blend_missing` @@ -1667,10 +1674,10 @@ Stage 4 связывает прогноз гидроочистки с модел - прогноз серы гидроочищенного компонента заменяет компонент `A`; - рецептуры пересчитываются по массовому балансу; - верхняя граница серы смешивается как консервативная сценарная граница; -- рецепты с нарушением серы, отсутствующей uncertainty или нехваткой запаса не +- T95/CN считаются по явной линейной модели, присадка — по сценарной кривой; +- рецепты с нарушением S/T95/CN, отсутствующей границей или нехваткой запаса не попадают в ranking; -- `T95`, цетановое число и полный паспорт товарного продукта остаются - `not_assessed`. +- `assessed` означает только полноту синтетического паспорта, не промышленную гарантию. Газовые теги `ht:F9`, `ht:F22`, `ht:Q21` теперь зафиксированы как gas context. Это наблюдаемые технологические сигналы, а не безопасные controls. Их нельзя diff --git a/README.md b/README.md index d3d58e7..d076c34 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,15 @@ - backend-срез stage 3: более строгий выбор `hold`/`recommend`/`abstain`, единые hard checks, причины отбраковки кандидатов, повторная проверка selected и trace в журнале; -- stage 4: модельная связка гидроочистка -> блендинг, массовый баланс рецептур и - gas context для `ht:F9`, `ht:F22`, `ht:Q21` без включения реального управления газом. +- stage 4: модельная связка гидроочистка -> блендинг, массовый баланс рецептур, + модельные контуры T95/цетанового числа, присадка до 3% и gas context для + `ht:F9`, `ht:F22`, `ht:Q21` без включения реального управления газом. - stage 5: empirical upper estimate для прогноза серы, applicability/OOD gate, robustness reporting и materiality/cooldown policy helpers без включения action model. - stage 6: точные версии зависимостей, команды `train`/`evaluate`/`replay`, каталог - демонстрационных эпизодов, проверка frozen models и экспорт полного журнала. -- stage 7: legacy CLI `run-history` для подготовленного historical dataset и явно - доверенного локального model artifact с проверкой metadata перед загрузкой `joblib`. + демонстрационных эпизодов, проверка frozen models и экспорт полного журнала. Пять + сценариев покрывают `hold`, рекомендации по сере/T95/цетановому числу и отказ при + неполном паспорте компонента. Полноценной промышленной ML-модели и управления реальными уставками пока нет. Доступен локальный desktop UI на Python: он запускает model-demo сценарии, показывает @@ -52,9 +53,10 @@ Stage 4 показывает связанную цепочку и модельный блендинг. Газовые теги видны как технологический контекст, но не становятся action controls: нет подтверждённых единиц, -диапазонов и модели эффекта. В model-demo `T95` и цетановое число являются сценарными -допущениями для демонстрации, а полный промышленный паспорт продукта остаётся -`not_assessed`. +диапазонов и модели эффекта. В `model_demo` проверяются сера, T95 и цетановое число: +T95 и базовое цетановое число линейно смешиваются как явное допущение, а эффект присадки +берётся из настраиваемой сценарной кривой. Это полный паспорт внутри синтетической модели, +но не подтверждение промышленного соответствия товарному стандарту. Stage 5 добавляет осторожность вокруг ML-прогноза: если artifact поддерживает uncertainty, quality-agent сначала проверяет область применимости признаков, потом использует point и @@ -95,7 +97,7 @@ python -m ruff format --check . python -m mypy source ``` -Ожидаемый результат `validate-stage0`: пять сценариев, три model-demo fixture и полный +Ожидаемый результат `validate-stage0`: семь сценариев, пять model-demo fixtures и полный словарь известных входных тегов. Неизвестные единицы и управляющие параметры помечены `ambiguous`, все реальные управляющие воздействия отключены. @@ -104,19 +106,17 @@ python -m mypy source ```bash python -m source.main run-model-demo blend_normal python -m source.main run-model-demo blend_risk +python -m source.main run-model-demo blend_t95_risk +python -m source.main run-model-demo blend_cetane_risk python -m source.main run-model-demo blend_missing ``` Ожидаемые статусы: -| Сценарий | Статус | Что показывает | -| --- | --- | --- | -| `blend_normal` | `abstain` | серная граница текущей смеси 9.4 мг/кг, но полный паспорт T95/CN не подтверждён | -| `blend_risk` | `abstain` | текущая серная граница 14.2 мг/кг, серный synthetic counterfactual A=90%/B=10% даёт 9.4 мг/кг, но не является советом оператору | -| `blend_missing` | `abstain` | при нехватке обязательной серы система отказывается от рискованной рекомендации | - -Серные counterfactuals в model-demo относятся только к синтетическому блендингу. -Реальные setpoint-рекомендации для history остаются отключены. +- `blend_normal` -> `hold`; +- `blend_risk`, `blend_t95_risk`, `blend_cetane_risk` -> `recommend` с одновременным + прохождением верхних границ серы/T95 и нижней границы цетанового числа; +- `blend_missing` -> `abstain`: неизвестное качество не превращается в `pass`. Stage 3 не меняет публичные demo-команды. Он делает внутренний выбор строже: infeasible-кандидаты не ранжируются, причины отказа сохраняются в журнале, selected @@ -136,12 +136,16 @@ python -m source.ui ```bash python -m source.ui --scenario blend_normal python -m source.ui --scenario blend_risk +python -m source.ui --scenario blend_t95_risk +python -m source.ui --scenario blend_cetane_risk python -m source.ui --scenario blend_missing ``` Интерфейс сохраняет текущие возможности системы: модельный расчёт рецептуры, доступные -ограничения, экспорт результата, журнал запусков, проверку конфигурации, подготовку данных, -сборку historical state и запуск trusted history forecast. Для проверки без дисплея: +ограничения, экспорт результата, журнал запусков, проверку конфигурации, подготовку данных +и сборку historical state. UI показывает рассчитанные верхние границы серы/T95, нижнюю +границу цетанового числа и долю присадки; модельный характер расчёта остаётся видимым. +Для проверки без дисплея: ```bash python -m source.ui --smoke --scenario blend_risk @@ -188,7 +192,7 @@ python -m source.main evaluate --dataset data/processed/ --model art ```bash python -m source.main replay --dataset data/processed/ --model artifacts/models/ --scenario history --at 2026-01-15T12:00:00+03:00 -python -m source.main acceptance --output reports/final-acceptance +python -m source.main acceptance --output reports/full-quality-acceptance python -m source.main verify-model-freeze ``` diff --git a/STAGE1.md b/STAGE1.md index d135c66..48f8dbd 100644 --- a/STAGE1.md +++ b/STAGE1.md @@ -1,8 +1,8 @@ # Stage 1: первый сквозной backend-цикл -> Актуальный статус: model-demo теперь содержит сценарные `T95` и цетановое число, -> поэтому демонстрирует три исхода: `hold`, `recommend` и `abstain`. Эти значения -> синтетические и не являются промышленным паспортом продукта. +> Актуальный статус: это историческое описание основы цикла. Финальная версия добавила +> модельные T95/CN и присадку: допустимые сценарии дают `hold`/`recommend`, а неполный +> паспорт по-прежнему даёт fail-closed `abstain`. Этот этап нужен, чтобы превратить подготовленные данные и DTO из stage 0 в проверяемый цикл принятия решения. ML-модель ещё не обязательна: backend @@ -16,7 +16,7 @@ - `source/agents/*` содержит логические роли качества, надёжности и оптимизации. Сейчас они считают прозрачные demo-метрики, не обученную модель. - `source/constraints.py` является единственным местом проверки жёстких - ограничений: сера, доступность верхней оценки и запас компонентов. + ограничений: S/T95/CN, необходимые границы, доля присадки и запасы. - `source/journal.py` пишет `metadata.json`, `input.json`, `features.json`, `trace.jsonl`, `candidates.jsonl` и `result.json`. - CLI получил команду `run-model-demo`. @@ -54,6 +54,8 @@ run_cycle -> generate_candidates -> evaluate_candidates -> check_constraints -> python -m source.main validate-stage0 python -m source.main run-model-demo blend_normal python -m source.main run-model-demo blend_risk +python -m source.main run-model-demo blend_t95_risk +python -m source.main run-model-demo blend_cetane_risk python -m source.main run-model-demo blend_missing ``` @@ -72,7 +74,7 @@ python -m pytest Ожидаемые статусы: - `blend_normal` -> `hold`; -- `blend_risk` -> `recommend`; +- `blend_risk`, `blend_t95_risk`, `blend_cetane_risk` -> `recommend`; - `blend_missing` -> `abstain`. ## Ограничения этапа diff --git a/STAGE3.md b/STAGE3.md index bf62863..45f690f 100644 --- a/STAGE3.md +++ b/STAGE3.md @@ -1,8 +1,7 @@ # Stage 3: Constraints, Controls, Rejection Reasons -> Актуальный статус: guardrails используются и после Stage 5. Model-demo снова -> демонстрирует `hold`/`recommend`/`abstain`, но `recommend` относится только к -> явно синтетическому блендингу; реальные history controls остаются выключены. +> Актуальный статус: guardrails используются в финальной версии. Model-demo теперь +> проверяет S/T95/CN и присадку; `abstain` сохраняется для неполных данных. Stage 3 состоит из двух связанных частей: @@ -105,6 +104,7 @@ Explanation показывает: - текущее значение серы; - выбранное значение серы; +- верхнюю T95 и нижнюю границу цетанового числа; - статус checks; - границу ограничения; - `reason_code`; @@ -177,6 +177,8 @@ Backend guardrails: ```bash python -m source.main run-model-demo blend_normal python -m source.main run-model-demo blend_risk +python -m source.main run-model-demo blend_t95_risk +python -m source.main run-model-demo blend_cetane_risk python -m source.main run-model-demo blend_missing python -m pytest global_tests/test_stage3_guardrails.py python -m pytest @@ -185,7 +187,7 @@ python -m pytest Ожидаемые demo-статусы: - `blend_normal` -> `hold`; -- `blend_risk` -> `recommend`; +- `blend_risk`, `blend_t95_risk`, `blend_cetane_risk` -> `recommend`; - `blend_missing` -> `abstain`. ML/control checks, если соответствующие файлы есть в ветке: diff --git a/STAGE4.md b/STAGE4.md index 2d471dc..dafb246 100644 --- a/STAGE4.md +++ b/STAGE4.md @@ -26,6 +26,10 @@ point/upper sulfur входного прогноза меняет множест входное качество и явный диапазон транспортного лага. - `apply_hydrotreater_forecast()` заменяет только соответствующий компонент смеси. - Сера смеси считается как `sum(w_i * S_i)`. +- T95 и базовое цетановое число считаются линейно по нормированным долям дизельных + компонентов как явное синтетическое допущение. +- Цетановая присадка перебирается от 0 до 3%; её прирост CN задаёт настраиваемая + кусочно-линейная кривая, а стоимость равна 100 стоимостным прокси на тонну. - Верхние оценки серы смешиваются тем же массовым балансом, но помечаются как консервативная сценарная граница без заявления о совместном статистическом покрытии. - Доли рецептуры неотрицательные, содержат все компоненты и суммируются в единицу. @@ -64,9 +68,9 @@ Stage 4 отслеживает следующие газовые сигналы модельным предположением: подтверждённого flow mapping нет. - Реальная история рецептур и запасов не предоставлена, поэтому blending остаётся модельным сценарием. -- Проверяются только сера и запас компонента. -- В model-demo `T95` и цетановое число являются сценарными synthetic values; - полная товарная спецификация остаётся `full_specification_status="not_assessed"`. +- В model-demo проверяются `S_upper`, `T95_upper`, `CN_lower`, запасы и присадка. +- `full_specification_status="assessed"` означает полноту только объявленного + синтетического паспорта, не промышленного товарного стандарта. - Отсутствующая верхняя граница серы даёт `UNKNOWN`, а не `PASS`. - Газовые сигналы не являются безопасными уставками. diff --git a/STAGE5.md b/STAGE5.md index eee7baa..f4b0380 100644 --- a/STAGE5.md +++ b/STAGE5.md @@ -107,8 +107,8 @@ hold candidate + best feasible candidate - Нет разрешенных промышленных setpoint-рекомендаций. - Нет гарантии safety coverage: `0.95` является эмпирической исторической оценкой, а не промышленной гарантией. - Applicability bounds являются marginal q0.001/q0.999 по train-признакам, а не полноценной многомерной OOD-моделью. -- `T95` и цетановое число в model-demo являются сценарными synthetic values; полный - промышленный паспорт товарного дизеля остаётся `not_assessed`. +- T95/CN доступны в отдельном синтетическом model-demo; исторически валидированных + моделей их последствий и промышленного паспорта по-прежнему нет. ## Как проверять @@ -126,11 +126,13 @@ Demo-команды должны сохранить прежние статус ```bash python -m source.main run-model-demo blend_normal python -m source.main run-model-demo blend_risk +python -m source.main run-model-demo blend_t95_risk +python -m source.main run-model-demo blend_cetane_risk python -m source.main run-model-demo blend_missing ``` Ожидаемо: - `blend_normal` -> `hold`; -- `blend_risk` -> `recommend`; +- `blend_risk`, `blend_t95_risk`, `blend_cetane_risk` -> `recommend`; - `blend_missing` -> `abstain`. diff --git a/STAGE6.md b/STAGE6.md index 4e982e7..4e1b6ba 100644 --- a/STAGE6.md +++ b/STAGE6.md @@ -2,13 +2,14 @@ ## Что зафиксировано -Этап 6 не добавляет новую технологическую модель. Он фиксирует и проверяет уже -реализованный прототип: +Финальная версия фиксирует и проверяет весь прототип, включая модельный товарный паспорт: - точные версии прямых зависимостей в `requirements.lock.txt`; - явные CLI-команды подготовки, обучения, оценки и historical replay; - модельные артефакты с `supports_actions=false` и проверяемыми SHA-256; -- три неизменяемых демонстрационных эпизода в `config/demo_episodes.json`; +- пять неизменяемых демонстрационных эпизодов в `config/demo_episodes.json`; +- верхние сценарные границы серы/T95, нижнюю границу цетанового числа и + конфигурируемую кривую цетаноповышающей присадки до 3%; - атомарный приёмочный отчёт и полный ZIP журналов; - раздельное представление исторической точности и условного эффекта блендинга. @@ -76,21 +77,25 @@ Baseline и модель сравниваются только на общих t ## Воспроизводимые демонстрационные эпизоды ```bash -python -m source.main acceptance --output reports/final-acceptance +python -m source.main acceptance --output reports/full-quality-acceptance ``` Каталог содержит: -1. `stable_blend` — серная граница текущей смеси 9.4 мг/кг; -2. `sulfur_risk` — текущая граница 14.2 мг/кг, серный контрфактуал A=90%, B=10% - даёт 9.4 мг/кг; -3. `missing_component_quality` — отсутствующая оценка компонента блокирует расчёт. - -Во всех трёх эпизодах итоговый статус — `abstain`, поскольку обязательные T95 и -цетановое число не оценены. Серный контрфактуал сохраняется как результат синтетической -модели, но явно имеет `operator_recommendation=false`. Это не всеотказывающаяся ошибка: -система вычисляет доступную часть, показывает допустимые по сере варианты и отказывает -только в полном операторском решении, для которого не хватает обязательного паспорта. +1. `stable_blend` — `hold`: S upper 9.306 мг/кг, T95 upper 354 °C, + cetane lower 52.6; +2. `sulfur_risk` — `recommend`: текущая S upper 14.058 мг/кг; выбран вариант + A=89.1%, B=9.9%, присадка=1%, S upper 9.306 мг/кг; +3. `t95_risk` — `recommend`: T95 upper снижается с 367 до 359.5 °C; +4. `cetane_risk` — `recommend`: cetane lower повышается с 50.6 до 51.1, дорогая + присадка исключается из выбранного варианта; +5. `missing_component_quality` — `abstain`: отсутствующая оценка серы компонента + блокирует расчёт независимо от экономики. + +Каждый `hold`/`recommend` одновременно проходит все три обязательные проверки. Верхние +оценки используются для серы и T95, нижняя — для цетанового числа. Модель T95/CN и кривая +присадки являются изменяемыми параметрами сценария, а не результатом промышленной +калибровки. `summary.json` хранит численные результаты, причины, время цикла и fingerprint решения без случайного `run_id`. `journals.zip` содержит все шесть файлов каждого запуска и @@ -137,7 +142,7 @@ Historical replay на `2026-01-15T12:00:00+03:00` даёт point 6.223 мг/к отключена, а подтверждённые факторы индекса надёжности отсутствуют. Приёмочный model-demo прогон на Intel64 Family 6 Model 186, 20 логических процессорах и -15.7 GiB RAM занял менее 0.02 с на один цикл; проверялся максимум 21 кандидат. Это время +15.7 GiB RAM занял около 0.02 с на один цикл; проверялись 84 кандидата. Это время короткого синтетического цикла, а не подготовки данных или обучения. Модели и полные residuals остаются локальными производными файлами согласно `.gitignore`. @@ -153,9 +158,12 @@ Historical replay на `2026-01-15T12:00:00+03:00` даёт point 6.223 мг/к - Верхняя граница 0.95 — эмпирическое покрытие на истории, не промышленная гарантия. - Applicability использует одномерные train-квантили признаков и может отклонять много test-точек при сдвиге режима. -- T95 и цетановое число не имеют валидированной модели, поэтому соответствие полного - товарного паспорта не заявляется. +- T95 и цетановое число проверяются только внутри явно объявленной линейной модели + смешения; промышленная валидность этой зависимости не заявляется. +- Кривая `доза присадки -> прирост цетанового числа` синтетическая и настраиваемая. + Ограничение 3% и ценовой коэффициент 100x взяты из уточнения эксперта, но сама кривая + должна быть перекалибрована на конкретном топливе перед производственным применением. - `P8`, `T11`, `F19` известны как управляющие переменные, но модель причинного эффекта действий не подтверждена. Реальные setpoint-рекомендации запрещены. -- Условный эффект блендинга доказывается только внутри синтетической массовой модели; +- Условный эффект блендинга доказывается только внутри синтетической модели паспорта; исторические данные не подтверждают невыполненные воздействия. diff --git a/config/demo_episodes.json b/config/demo_episodes.json index a7f6e8e..bd8603d 100644 --- a/config/demo_episodes.json +++ b/config/demo_episodes.json @@ -4,29 +4,52 @@ { "id": "stable_blend", "scenario": "blend_normal", - "expected_status": "abstain", - "expected_reason_codes": ["UNASSESSED_REQUIRED_PROPERTY"], - "expected_baseline_upper": 9.4, - "expected_sulfur_only_upper": 9.4, - "expected_sulfur_only_blend": null + "expected_status": "hold", + "expected_reason_codes": ["NO_MATERIAL_IMPROVEMENT"], + "expected_baseline_quality": {"sulfur_upper": 9.306, "t95_upper": 354.0, "cetane_lower": 52.6}, + "expected_selected_quality": {"sulfur_upper": 9.306, "t95_upper": 354.0, "cetane_lower": 52.6}, + "expected_recipe": {"A": 0.891, "B": 0.099}, + "expected_additive_fraction": 0.01 }, { "id": "sulfur_risk", "scenario": "blend_risk", - "expected_status": "abstain", - "expected_reason_codes": ["UNASSESSED_REQUIRED_PROPERTY", "QUALITY_LIMIT"], - "expected_baseline_upper": 14.2, - "expected_sulfur_only_upper": 9.4, - "expected_sulfur_only_blend": {"A": 0.9, "B": 0.1} + "expected_status": "recommend", + "expected_reason_codes": ["QUALITY_LIMIT"], + "expected_baseline_quality": {"sulfur_upper": 14.058, "t95_upper": 358.0, "cetane_lower": 51.8}, + "expected_selected_quality": {"sulfur_upper": 9.306, "t95_upper": 354.0, "cetane_lower": 52.6}, + "expected_recipe": {"A": 0.891, "B": 0.099}, + "expected_additive_fraction": 0.01 + }, + { + "id": "t95_risk", + "scenario": "blend_t95_risk", + "expected_status": "recommend", + "expected_reason_codes": ["QUALITY_LIMIT"], + "expected_baseline_quality": {"sulfur_upper": 7.425, "t95_upper": 367.0, "cetane_lower": 51.0}, + "expected_selected_quality": {"sulfur_upper": 6.9795, "t95_upper": 359.5, "cetane_lower": 51.6}, + "expected_recipe": {"A": 0.6435, "B": 0.3465}, + "expected_additive_fraction": 0.01 + }, + { + "id": "cetane_risk", + "scenario": "blend_cetane_risk", + "expected_status": "recommend", + "expected_reason_codes": ["QUALITY_LIMIT"], + "expected_baseline_quality": {"sulfur_upper": 8.019, "t95_upper": 349.0, "cetane_lower": 50.6}, + "expected_selected_quality": {"sulfur_upper": 6.6, "t95_upper": 344.0, "cetane_lower": 51.1}, + "expected_recipe": {"A": 0.8, "B": 0.2}, + "expected_additive_fraction": 0.0 }, { "id": "missing_component_quality", "scenario": "blend_missing", "expected_status": "abstain", "expected_reason_codes": ["MISSING_REQUIRED_SIGNAL"], - "expected_baseline_upper": null, - "expected_sulfur_only_upper": null, - "expected_sulfur_only_blend": null + "expected_baseline_quality": {"sulfur_upper": null, "t95_upper": 358.0, "cetane_lower": 51.8}, + "expected_selected_quality": null, + "expected_recipe": null, + "expected_additive_fraction": null } ] } diff --git a/config/scenarios/blend_cetane_risk.json b/config/scenarios/blend_cetane_risk.json new file mode 100644 index 0000000..340e182 --- /dev/null +++ b/config/scenarios/blend_cetane_risk.json @@ -0,0 +1,47 @@ +{ + "id": "blend_cetane_risk", + "mode": "model_demo", + "required_signals": [], + "controls": [], + "constraints": [ + {"id": "blend_sulfur", "metric": "sulfur", "stage": "blend", "lower": null, "upper": 10.0, "unit": "mg/kg", "use_upper_estimate": true, "use_lower_estimate": false, "required": true, "basis": "tz", "evidence_ref": "materials/ТЗ_нефтекод.docx"}, + {"id": "blend_t95", "metric": "t95", "stage": "blend", "lower": null, "upper": 360.0, "unit": "degC", "use_upper_estimate": true, "use_lower_estimate": false, "required": true, "basis": "model_assumption", "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario"}, + {"id": "blend_cetane", "metric": "cetane_number", "stage": "blend", "lower": 51.0, "upper": null, "unit": "cetane_number", "use_upper_estimate": false, "use_lower_estimate": true, "required": true, "basis": "model_assumption", "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario"} + ], + "blend_components": [ + { + "id": "A", + "sulfur": {"value": 5.0, "lower": null, "upper": 6.0, "unit": "mg/kg", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "t95": {"value": 340.0, "lower": null, "upper": 342.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 53.0, "lower": 52.5, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "available_mass_t": 100.0, + "cost_proxy_per_t": 1.0, + "risk_index": 0.0, + "source_state_id": null + }, + { + "id": "B", + "sulfur": {"value": 8.0, "lower": null, "upper": 9.0, "unit": "mg/kg", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "t95": {"value": 350.0, "lower": null, "upper": 352.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 46.0, "lower": 45.5, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "available_mass_t": 80.0, + "cost_proxy_per_t": 0.8, + "risk_index": 0.0, + "source_state_id": null + } + ], + "cetane_additive": { + "id": "cetane_improver", "max_mass_fraction": 0.03, "fraction_step": 0.01, "available_mass_t": 3.0, "cost_proxy_per_t": 100.0, + "response_curve": [{"mass_fraction": 0.0, "cetane_gain": 0.0}, {"mass_fraction": 0.01, "cetane_gain": 3.0}, {"mass_fraction": 0.02, "cetane_gain": 5.0}, {"mass_fraction": 0.03, "cetane_gain": 6.5}], + "evidence_ref": "expert clarification 10.09.2026: dose <=3%, price ratio 100x", + "assumptions": ["The dose-to-cetane curve is synthetic and configurable; it is not plant-calibrated."] + }, + "total_mass_t": 100.0, + "current_blend_mass_fractions": {"A": 0.297, "B": 0.693}, + "current_additive_mass_fraction": 0.01, + "active_criteria": ["risk_index", "throughput", "cost_proxy"], + "materiality_thresholds": {"risk_index": 0.02, "throughput": 0.02, "cost_proxy": 0.02}, + "require_upper_bound": true, + "action_cooldown_minutes": 60, + "assumptions": ["All component values and T95/cetane blend relations are synthetic and are not plant guarantees."] +} diff --git a/config/scenarios/blend_missing.json b/config/scenarios/blend_missing.json index e8bc30a..377d978 100644 --- a/config/scenarios/blend_missing.json +++ b/config/scenarios/blend_missing.json @@ -1,49 +1,92 @@ -{ - "id": "blend_missing", - "mode": "model_demo", - "required_signals": [], - "controls": [], - "constraints": [ - { - "id": "blend_sulfur", - "metric": "sulfur", - "stage": "blend", - "lower": null, - "upper": 10.0, - "unit": "mg/kg", - "use_upper_estimate": true, - "required": true, - "basis": "tz", - "evidence_ref": "materials/ТЗ_нефтекод.docx" - } - ], - "blend_components": [ +{ + "id": "blend_missing", + "mode": "model_demo", + "required_signals": [], + "controls": [], + "constraints": [ + { + "id": "blend_sulfur", + "metric": "sulfur", + "stage": "blend", + "lower": null, + "upper": 10.0, + "unit": "mg/kg", + "use_upper_estimate": true, + "use_lower_estimate": false, + "required": true, + "basis": "tz", + "evidence_ref": "materials/ТЗ_нефтекод.docx" + }, + { + "id": "blend_t95", + "metric": "t95", + "stage": "blend", + "lower": null, + "upper": 360.0, + "unit": "degC", + "use_upper_estimate": true, + "use_lower_estimate": false, + "required": true, + "basis": "model_assumption", + "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario" + }, + { + "id": "blend_cetane", + "metric": "cetane_number", + "stage": "blend", + "lower": 51.0, + "upper": null, + "unit": "cetane_number", + "use_upper_estimate": false, + "use_lower_estimate": true, + "required": true, + "basis": "model_assumption", + "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario" + } + ], + "blend_components": [ { "id": "A", "sulfur": {"value": null, "lower": null, "upper": null, "unit": "mg/kg", "basis": "formula", "interval_kind": "none", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic missing-data case"]}, - "t95": {"value": 350.0, "lower": null, "upper": 355.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo T95 value"]}, - "cetane_number": {"value": 54.0, "lower": null, "upper": 54.0, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo cetane value"]}, + "t95": {"value": 350.0, "lower": null, "upper": 352.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 50.5, "lower": 50.0, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, "available_mass_t": 100.0, "cost_proxy_per_t": 1.0, "risk_index": 0.0, - "source_state_id": null - }, + "source_state_id": null + }, { "id": "B", "sulfur": {"value": 30.0, "lower": null, "upper": 31.0, "unit": "mg/kg", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo value"]}, - "t95": {"value": 360.0, "lower": null, "upper": 365.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo T95 value"]}, - "cetane_number": {"value": 48.0, "lower": null, "upper": 48.0, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo cetane value"]}, + "t95": {"value": 370.0, "lower": null, "upper": 372.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 46.5, "lower": 46.0, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, "available_mass_t": 30.0, "cost_proxy_per_t": 0.8, "risk_index": 0.0, - "source_state_id": null - } - ], - "total_mass_t": 100.0, - "current_blend_mass_fractions": {"A": 0.7, "B": 0.3}, - "active_criteria": ["risk_index", "throughput", "cost_proxy"], - "materiality_thresholds": {"risk_index": 0.02, "throughput": 0.02, "cost_proxy": 0.02}, - "require_upper_bound": true, - "action_cooldown_minutes": 60, - "assumptions": ["All component values, including T95 and cetane number, are synthetic and are not plant guarantees."] + "source_state_id": null + } + ], + "cetane_additive": { + "id": "cetane_improver", + "max_mass_fraction": 0.03, + "fraction_step": 0.01, + "available_mass_t": 3.0, + "cost_proxy_per_t": 100.0, + "response_curve": [ + {"mass_fraction": 0.0, "cetane_gain": 0.0}, + {"mass_fraction": 0.01, "cetane_gain": 3.0}, + {"mass_fraction": 0.02, "cetane_gain": 5.0}, + {"mass_fraction": 0.03, "cetane_gain": 6.5} + ], + "evidence_ref": "expert clarification 10.09.2026: dose <=3%, price ratio 100x", + "assumptions": ["The dose-to-cetane curve is synthetic and configurable; it is not plant-calibrated."] + }, + "total_mass_t": 100.0, + "current_blend_mass_fractions": {"A": 0.693, "B": 0.297}, + "current_additive_mass_fraction": 0.01, + "active_criteria": ["risk_index", "throughput", "cost_proxy"], + "materiality_thresholds": {"risk_index": 0.02, "throughput": 0.02, "cost_proxy": 0.02}, + "require_upper_bound": true, + "action_cooldown_minutes": 60, + "assumptions": ["All component values and T95/cetane blend relations are synthetic and are not plant guarantees."] } diff --git a/config/scenarios/blend_normal.json b/config/scenarios/blend_normal.json index 9882092..2c2c33d 100644 --- a/config/scenarios/blend_normal.json +++ b/config/scenarios/blend_normal.json @@ -1,49 +1,92 @@ -{ - "id": "blend_normal", - "mode": "model_demo", - "required_signals": [], - "controls": [], - "constraints": [ - { - "id": "blend_sulfur", - "metric": "sulfur", - "stage": "blend", - "lower": null, - "upper": 10.0, - "unit": "mg/kg", - "use_upper_estimate": true, - "required": true, - "basis": "tz", - "evidence_ref": "materials/ТЗ_нефтекод.docx" - } - ], - "blend_components": [ +{ + "id": "blend_normal", + "mode": "model_demo", + "required_signals": [], + "controls": [], + "constraints": [ + { + "id": "blend_sulfur", + "metric": "sulfur", + "stage": "blend", + "lower": null, + "upper": 10.0, + "unit": "mg/kg", + "use_upper_estimate": true, + "use_lower_estimate": false, + "required": true, + "basis": "tz", + "evidence_ref": "materials/ТЗ_нефтекод.docx" + }, + { + "id": "blend_t95", + "metric": "t95", + "stage": "blend", + "lower": null, + "upper": 360.0, + "unit": "degC", + "use_upper_estimate": true, + "use_lower_estimate": false, + "required": true, + "basis": "model_assumption", + "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario" + }, + { + "id": "blend_cetane", + "metric": "cetane_number", + "stage": "blend", + "lower": 51.0, + "upper": null, + "unit": "cetane_number", + "use_upper_estimate": false, + "use_lower_estimate": true, + "required": true, + "basis": "model_assumption", + "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario" + } + ], + "blend_components": [ { "id": "A", "sulfur": {"value": 6.0, "lower": null, "upper": 7.0, "unit": "mg/kg", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo value"]}, - "t95": {"value": 350.0, "lower": null, "upper": 355.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo T95 value"]}, - "cetane_number": {"value": 54.0, "lower": null, "upper": 54.0, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo cetane value"]}, + "t95": {"value": 350.0, "lower": null, "upper": 352.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 50.5, "lower": 50.0, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, "available_mass_t": 100.0, "cost_proxy_per_t": 1.0, "risk_index": 0.0, - "source_state_id": null - }, + "source_state_id": null + }, { "id": "B", "sulfur": {"value": 30.0, "lower": null, "upper": 31.0, "unit": "mg/kg", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo value"]}, - "t95": {"value": 360.0, "lower": null, "upper": 365.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo T95 value"]}, - "cetane_number": {"value": 48.0, "lower": null, "upper": 48.0, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo cetane value"]}, + "t95": {"value": 370.0, "lower": null, "upper": 372.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 46.5, "lower": 46.0, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, "available_mass_t": 30.0, "cost_proxy_per_t": 0.8, "risk_index": 0.0, - "source_state_id": null - } - ], - "total_mass_t": 100.0, - "current_blend_mass_fractions": {"A": 0.9, "B": 0.1}, - "active_criteria": ["risk_index", "throughput", "cost_proxy"], - "materiality_thresholds": {"risk_index": 0.02, "throughput": 0.02, "cost_proxy": 0.02}, - "require_upper_bound": true, - "action_cooldown_minutes": 60, - "assumptions": ["All component values, including T95 and cetane number, are synthetic and are not plant guarantees."] + "source_state_id": null + } + ], + "cetane_additive": { + "id": "cetane_improver", + "max_mass_fraction": 0.03, + "fraction_step": 0.01, + "available_mass_t": 3.0, + "cost_proxy_per_t": 100.0, + "response_curve": [ + {"mass_fraction": 0.0, "cetane_gain": 0.0}, + {"mass_fraction": 0.01, "cetane_gain": 3.0}, + {"mass_fraction": 0.02, "cetane_gain": 5.0}, + {"mass_fraction": 0.03, "cetane_gain": 6.5} + ], + "evidence_ref": "expert clarification 10.09.2026: dose <=3%, price ratio 100x", + "assumptions": ["The dose-to-cetane curve is synthetic and configurable; it is not plant-calibrated."] + }, + "total_mass_t": 100.0, + "current_blend_mass_fractions": {"A": 0.891, "B": 0.099}, + "current_additive_mass_fraction": 0.01, + "active_criteria": ["risk_index", "throughput", "cost_proxy"], + "materiality_thresholds": {"risk_index": 0.02, "throughput": 0.02, "cost_proxy": 0.02}, + "require_upper_bound": true, + "action_cooldown_minutes": 60, + "assumptions": ["All component values and T95/cetane blend relations are synthetic and are not plant guarantees."] } diff --git a/config/scenarios/blend_risk.json b/config/scenarios/blend_risk.json index cdd9e5d..a40c7df 100644 --- a/config/scenarios/blend_risk.json +++ b/config/scenarios/blend_risk.json @@ -1,49 +1,92 @@ -{ - "id": "blend_risk", - "mode": "model_demo", - "required_signals": [], - "controls": [], - "constraints": [ - { - "id": "blend_sulfur", - "metric": "sulfur", - "stage": "blend", - "lower": null, - "upper": 10.0, - "unit": "mg/kg", - "use_upper_estimate": true, - "required": true, - "basis": "tz", - "evidence_ref": "materials/ТЗ_нефтекод.docx" - } - ], - "blend_components": [ +{ + "id": "blend_risk", + "mode": "model_demo", + "required_signals": [], + "controls": [], + "constraints": [ + { + "id": "blend_sulfur", + "metric": "sulfur", + "stage": "blend", + "lower": null, + "upper": 10.0, + "unit": "mg/kg", + "use_upper_estimate": true, + "use_lower_estimate": false, + "required": true, + "basis": "tz", + "evidence_ref": "materials/ТЗ_нефтекод.docx" + }, + { + "id": "blend_t95", + "metric": "t95", + "stage": "blend", + "lower": null, + "upper": 360.0, + "unit": "degC", + "use_upper_estimate": true, + "use_lower_estimate": false, + "required": true, + "basis": "model_assumption", + "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario" + }, + { + "id": "blend_cetane", + "metric": "cetane_number", + "stage": "blend", + "lower": 51.0, + "upper": null, + "unit": "cetane_number", + "use_upper_estimate": false, + "use_lower_estimate": true, + "required": true, + "basis": "model_assumption", + "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario" + } + ], + "blend_components": [ { "id": "A", "sulfur": {"value": 6.0, "lower": null, "upper": 7.0, "unit": "mg/kg", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo value"]}, - "t95": {"value": 350.0, "lower": null, "upper": 355.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo T95 value"]}, - "cetane_number": {"value": 54.0, "lower": null, "upper": 54.0, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo cetane value"]}, + "t95": {"value": 350.0, "lower": null, "upper": 352.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 50.5, "lower": 50.0, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, "available_mass_t": 100.0, "cost_proxy_per_t": 1.0, "risk_index": 0.0, - "source_state_id": null - }, + "source_state_id": null + }, { "id": "B", "sulfur": {"value": 30.0, "lower": null, "upper": 31.0, "unit": "mg/kg", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo value"]}, - "t95": {"value": 360.0, "lower": null, "upper": 365.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo T95 value"]}, - "cetane_number": {"value": 48.0, "lower": null, "upper": 48.0, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo cetane value"]}, + "t95": {"value": 370.0, "lower": null, "upper": 372.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 46.5, "lower": 46.0, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, "available_mass_t": 30.0, "cost_proxy_per_t": 0.8, "risk_index": 0.0, - "source_state_id": null - } - ], - "total_mass_t": 100.0, - "current_blend_mass_fractions": {"A": 0.7, "B": 0.3}, - "active_criteria": ["risk_index", "throughput", "cost_proxy"], - "materiality_thresholds": {"risk_index": 0.02, "throughput": 0.02, "cost_proxy": 0.02}, - "require_upper_bound": true, - "action_cooldown_minutes": 60, - "assumptions": ["All component values, including T95 and cetane number, are synthetic and are not plant guarantees."] + "source_state_id": null + } + ], + "cetane_additive": { + "id": "cetane_improver", + "max_mass_fraction": 0.03, + "fraction_step": 0.01, + "available_mass_t": 3.0, + "cost_proxy_per_t": 100.0, + "response_curve": [ + {"mass_fraction": 0.0, "cetane_gain": 0.0}, + {"mass_fraction": 0.01, "cetane_gain": 3.0}, + {"mass_fraction": 0.02, "cetane_gain": 5.0}, + {"mass_fraction": 0.03, "cetane_gain": 6.5} + ], + "evidence_ref": "expert clarification 10.09.2026: dose <=3%, price ratio 100x", + "assumptions": ["The dose-to-cetane curve is synthetic and configurable; it is not plant-calibrated."] + }, + "total_mass_t": 100.0, + "current_blend_mass_fractions": {"A": 0.693, "B": 0.297}, + "current_additive_mass_fraction": 0.01, + "active_criteria": ["risk_index", "throughput", "cost_proxy"], + "materiality_thresholds": {"risk_index": 0.02, "throughput": 0.02, "cost_proxy": 0.02}, + "require_upper_bound": true, + "action_cooldown_minutes": 60, + "assumptions": ["All component values and T95/cetane blend relations are synthetic and are not plant guarantees."] } diff --git a/config/scenarios/blend_t95_risk.json b/config/scenarios/blend_t95_risk.json new file mode 100644 index 0000000..5ad1a9a --- /dev/null +++ b/config/scenarios/blend_t95_risk.json @@ -0,0 +1,47 @@ +{ + "id": "blend_t95_risk", + "mode": "model_demo", + "required_signals": [], + "controls": [], + "constraints": [ + {"id": "blend_sulfur", "metric": "sulfur", "stage": "blend", "lower": null, "upper": 10.0, "unit": "mg/kg", "use_upper_estimate": true, "use_lower_estimate": false, "required": true, "basis": "tz", "evidence_ref": "materials/ТЗ_нефтекод.docx"}, + {"id": "blend_t95", "metric": "t95", "stage": "blend", "lower": null, "upper": 360.0, "unit": "degC", "use_upper_estimate": true, "use_lower_estimate": false, "required": true, "basis": "model_assumption", "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario"}, + {"id": "blend_cetane", "metric": "cetane_number", "stage": "blend", "lower": 51.0, "upper": null, "unit": "cetane_number", "use_upper_estimate": false, "use_lower_estimate": true, "required": true, "basis": "model_assumption", "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario"} + ], + "blend_components": [ + { + "id": "A", + "sulfur": {"value": 5.0, "lower": null, "upper": 6.0, "unit": "mg/kg", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "t95": {"value": 340.0, "lower": null, "upper": 342.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 50.5, "lower": 50.0, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "available_mass_t": 100.0, + "cost_proxy_per_t": 1.0, + "risk_index": 0.0, + "source_state_id": null + }, + { + "id": "B", + "sulfur": {"value": 8.0, "lower": null, "upper": 9.0, "unit": "mg/kg", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "t95": {"value": 390.0, "lower": null, "upper": 392.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 46.5, "lower": 46.0, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "available_mass_t": 60.0, + "cost_proxy_per_t": 0.8, + "risk_index": 0.0, + "source_state_id": null + } + ], + "cetane_additive": { + "id": "cetane_improver", "max_mass_fraction": 0.03, "fraction_step": 0.01, "available_mass_t": 3.0, "cost_proxy_per_t": 100.0, + "response_curve": [{"mass_fraction": 0.0, "cetane_gain": 0.0}, {"mass_fraction": 0.01, "cetane_gain": 3.0}, {"mass_fraction": 0.02, "cetane_gain": 5.0}, {"mass_fraction": 0.03, "cetane_gain": 6.5}], + "evidence_ref": "expert clarification 10.09.2026: dose <=3%, price ratio 100x", + "assumptions": ["The dose-to-cetane curve is synthetic and configurable; it is not plant-calibrated."] + }, + "total_mass_t": 100.0, + "current_blend_mass_fractions": {"A": 0.495, "B": 0.495}, + "current_additive_mass_fraction": 0.01, + "active_criteria": ["risk_index", "throughput", "cost_proxy"], + "materiality_thresholds": {"risk_index": 0.02, "throughput": 0.02, "cost_proxy": 0.02}, + "require_upper_bound": true, + "action_cooldown_minutes": 60, + "assumptions": ["All component values and T95/cetane blend relations are synthetic and are not plant guarantees."] +} diff --git a/global_tests/fixtures/contracts/recommendation.json b/global_tests/fixtures/contracts/recommendation.json index 8a7af6d..dd1470c 100644 --- a/global_tests/fixtures/contracts/recommendation.json +++ b/global_tests/fixtures/contracts/recommendation.json @@ -1,42 +1,43 @@ -{ - "schema_version": "1.0", - "run_id": "fixture-run-hold", - "state_id": "fixture-state-normal", - "as_of": "2026-01-15T09:00:00Z", - "scenario_id": "blend_normal", - "mode": "model_demo", - "status": "hold", - "baseline": null, - "selected": { - "candidate": { - "id": "hold", - "kind": "hold", - "setpoints": {}, - "blend_mass_fractions": {}, - "horizon_minutes": 60, - "is_model_scenario": true - }, - "assessments": [], - "checks": [ - { - "constraint_id": "blend_sulfur", - "candidate_id": "hold", - "status": "pass", - "actual": 9.4, - "lower": null, - "upper": 10.0, - "unit": "mg/kg", - "basis": "tz", - "evidence_ref": "materials/ТЗ_нефтекод.docx", - "reason_code": "QUALITY_OK" - } - ], - "feasible": true, - "rank_key": [0.0, -100.0, 0.98, 0.0, "hold"] - }, - "alternatives": [], - "reason_codes": ["NO_MATERIAL_IMPROVEMENT"], - "explanation": "Текущая модельная рецептура проходит ограничение по сере.", - "assumptions": ["Все значения сценария синтетические."], - "model_id": null -} +{ + "schema_version": "1.1", + "run_id": "fixture-run-hold", + "state_id": "fixture-state-normal", + "as_of": "2026-01-15T09:00:00Z", + "scenario_id": "blend_normal", + "mode": "model_demo", + "status": "hold", + "baseline": null, + "selected": { + "candidate": { + "id": "hold", + "kind": "hold", + "setpoints": {}, + "blend_mass_fractions": {}, + "additive_mass_fraction": 0.0, + "horizon_minutes": 60, + "is_model_scenario": true + }, + "assessments": [], + "checks": [ + { + "constraint_id": "blend_sulfur", + "candidate_id": "hold", + "status": "pass", + "actual": 9.4, + "lower": null, + "upper": 10.0, + "unit": "mg/kg", + "basis": "tz", + "evidence_ref": "materials/ТЗ_нефтекод.docx", + "reason_code": "QUALITY_OK" + } + ], + "feasible": true, + "rank_key": [0.0, -100.0, 0.98, 0.0, "hold"] + }, + "alternatives": [], + "reason_codes": ["NO_MATERIAL_IMPROVEMENT"], + "explanation": "Текущая модельная рецептура проходит ограничение по сере.", + "assumptions": ["Все значения сценария синтетические."], + "model_id": null +} diff --git a/global_tests/fixtures/model_demo/blend_cetane_risk.json b/global_tests/fixtures/model_demo/blend_cetane_risk.json new file mode 100644 index 0000000..483b7f0 --- /dev/null +++ b/global_tests/fixtures/model_demo/blend_cetane_risk.json @@ -0,0 +1,4 @@ +{ + "state": {"schema_version": "1.0", "state_id": "fixture-state-cetane-risk", "as_of": "2026-01-15T09:00:00Z", "dataset_id": "modeldemo001", "mode": "model_demo", "signals": {}, "issues": []}, + "expected": {"status": "recommend", "selected_blend_mass_fractions": {"A": 0.8, "B": 0.2}, "selected_additive_mass_fraction": 0.0} +} diff --git a/global_tests/fixtures/model_demo/blend_normal.json b/global_tests/fixtures/model_demo/blend_normal.json index cc197a8..f5da624 100644 --- a/global_tests/fixtures/model_demo/blend_normal.json +++ b/global_tests/fixtures/model_demo/blend_normal.json @@ -1,17 +1,20 @@ -{ - "state": { - "schema_version": "1.0", - "state_id": "fixture-state-normal", - "as_of": "2026-01-15T09:00:00Z", - "dataset_id": "modeldemo001", - "mode": "model_demo", - "signals": {}, - "issues": [] - }, - "expected": { - "status": "hold", - "current_sulfur_mg_kg": 8.4, - "current_upper_sulfur_mg_kg": 9.4, - "selected_blend_mass_fractions": {"A": 0.9, "B": 0.1} - } -} +{ + "state": { + "schema_version": "1.0", + "state_id": "fixture-state-normal", + "as_of": "2026-01-15T09:00:00Z", + "dataset_id": "modeldemo001", + "mode": "model_demo", + "signals": {}, + "issues": [] + }, + "expected": { + "status": "hold", + "current_sulfur_mg_kg": 8.316, + "current_upper_sulfur_mg_kg": 9.306, + "current_upper_t95_degC": 354.0, + "current_lower_cetane_number": 52.6, + "selected_blend_mass_fractions": {"A": 0.891, "B": 0.099}, + "selected_additive_mass_fraction": 0.01 + } +} diff --git a/global_tests/fixtures/model_demo/blend_risk.json b/global_tests/fixtures/model_demo/blend_risk.json index 363c10b..4dc8e0e 100644 --- a/global_tests/fixtures/model_demo/blend_risk.json +++ b/global_tests/fixtures/model_demo/blend_risk.json @@ -1,19 +1,24 @@ -{ - "state": { - "schema_version": "1.0", - "state_id": "fixture-state-risk", - "as_of": "2026-01-15T09:00:00Z", - "dataset_id": "modeldemo001", - "mode": "model_demo", - "signals": {}, - "issues": [] - }, - "expected": { - "status": "recommend", - "current_sulfur_mg_kg": 13.2, - "current_upper_sulfur_mg_kg": 14.2, - "selected_sulfur_mg_kg": 8.4, - "selected_upper_sulfur_mg_kg": 9.4, - "selected_blend_mass_fractions": {"A": 0.9, "B": 0.1} - } -} +{ + "state": { + "schema_version": "1.0", + "state_id": "fixture-state-risk", + "as_of": "2026-01-15T09:00:00Z", + "dataset_id": "modeldemo001", + "mode": "model_demo", + "signals": {}, + "issues": [] + }, + "expected": { + "status": "recommend", + "current_sulfur_mg_kg": 13.068, + "current_upper_sulfur_mg_kg": 14.058, + "current_upper_t95_degC": 358.0, + "current_lower_cetane_number": 51.8, + "selected_sulfur_mg_kg": 8.316, + "selected_upper_sulfur_mg_kg": 9.306, + "selected_upper_t95_degC": 354.0, + "selected_lower_cetane_number": 52.6, + "selected_blend_mass_fractions": {"A": 0.891, "B": 0.099}, + "selected_additive_mass_fraction": 0.01 + } +} diff --git a/global_tests/fixtures/model_demo/blend_t95_risk.json b/global_tests/fixtures/model_demo/blend_t95_risk.json new file mode 100644 index 0000000..5e9b8d1 --- /dev/null +++ b/global_tests/fixtures/model_demo/blend_t95_risk.json @@ -0,0 +1,4 @@ +{ + "state": {"schema_version": "1.0", "state_id": "fixture-state-t95-risk", "as_of": "2026-01-15T09:00:00Z", "dataset_id": "modeldemo001", "mode": "model_demo", "signals": {}, "issues": []}, + "expected": {"status": "recommend", "selected_blend_mass_fractions": {"A": 0.6435, "B": 0.3465}, "selected_additive_mass_fraction": 0.01} +} diff --git a/global_tests/test_stage0.py b/global_tests/test_stage0.py index 541923a..226fcba 100644 --- a/global_tests/test_stage0.py +++ b/global_tests/test_stage0.py @@ -1,341 +1,356 @@ -"""Acceptance checks for the complete stage-0 contract boundary.""" - -from __future__ import annotations - -import json -from datetime import UTC, datetime -from pathlib import Path - -import pandas as pd -import pytest -from pydantic import ValidationError - -from source.config import load_runtime_config, load_scenario, load_tag_dictionary -from source.contracts import ( - CandidateAction, - CandidateKind, - DatasetManifest, - MappingStatus, - Observation, - ProcessState, - Recommendation, - Stage, - TagMeta, - Unit, - Validity, -) -from source.data.ingest import read_lims, read_pak, read_telemetry_csv -from source.data.prepare import PreparedData, known_feature_order -from source.data.state import build_state - -FIXTURES = Path(__file__).parent / "fixtures" - - -def test_all_versioned_configs_load() -> None: - """Verify runtime settings and every checked-in scenario load successfully.""" - runtime = load_runtime_config("config/runtime.toml") - assert runtime.source_timezone == "Europe/Moscow" - assert runtime.lims_delay_hours == 4 - assert runtime.horizon_minutes == 60 - assert runtime.max_candidates == 125 - assert {load_scenario(path).id for path in Path("config/scenarios").glob("*.json")} == { - "history", - "blend_normal", - "blend_risk", - "blend_missing", - "hybrid_blend", - } - - -def test_tag_dictionary_uses_confirmed_expert_clarifications() -> None: - """Verify expert-confirmed mappings, conversion and controllable signals.""" - tags = load_tag_dictionary("config/tags.csv") - assert len(tags) == 170 - assert {tag.signal_id for tag in tags.values() if tag.controllable} == { - "ht:F19", - "ht:P8", - "ht:T11", - } - pak_sulfur = tags["24-2000:Mg.Sulfur"] - assert pak_sulfur.mapping_status is MappingStatus.CONFIRMED - assert pak_sulfur.signal_id == "ht:2:Mg.Sulfur" - assert pak_sulfur.canonical_unit == Unit.MG_KG.value - assert pak_sulfur.conversion.value == "ppm_mass_to_mg_kg" - assert tags["ЛИМС:Гидроочистка.2:Mg.Sulfur"].mapping_status is MappingStatus.CONFIRMED - - -def test_ml_feature_order_excludes_unconfirmed_mappings() -> None: - """Unknown tags stay inspectable but cannot silently become model inputs.""" - tags = load_tag_dictionary("config/tags.csv") - - feature_order = known_feature_order(tags) - - assert "ht:2:Mg.Sulfur" in feature_order - assert "ht:sulfur" not in feature_order - - -def test_serialized_contract_examples_validate() -> None: - """Verify the representative serialized state and recommendation contracts.""" - ProcessState.model_validate_json( - (FIXTURES / "contracts/process_state.json").read_text(encoding="utf-8") - ) - Recommendation.model_validate_json( - (FIXTURES / "contracts/recommendation.json").read_text(encoding="utf-8") - ) - - -@pytest.mark.parametrize("name", ["blend_normal", "blend_risk", "blend_missing"]) -def test_model_demo_state_fixtures_validate(name: str) -> None: - """Verify each model-demo fixture contains a valid process state and status.""" - fixture = json.loads((FIXTURES / f"model_demo/{name}.json").read_text(encoding="utf-8")) - ProcessState.model_validate(fixture["state"]) - assert fixture["expected"]["status"] in {"hold", "recommend", "abstain"} - - -def test_model_demo_numbers_match_design() -> None: - """Verify fixture sulfur values match the numerical examples in DESIGN.md.""" - normal = load_scenario("config/scenarios/blend_normal.json") - risk = load_scenario("config/scenarios/blend_risk.json") - - def sulfur(scenario, field: str) -> float: - """Calculate weighted sulfur for the scenario's current blend.""" - return sum( - scenario.current_blend_mass_fractions[item.id] * getattr(item.sulfur, field) - for item in scenario.blend_components - ) - - assert sulfur(normal, "value") == pytest.approx(8.4) - assert sulfur(normal, "upper") == pytest.approx(9.4) - assert sulfur(risk, "value") == pytest.approx(13.2) - assert sulfur(risk, "upper") == pytest.approx(14.2) - missing = load_scenario("config/scenarios/blend_missing.json") - assert missing.blend_components[0].sulfur.value is None - assert missing.blend_components[0].sulfur.upper is None - - -def test_contracts_reject_extra_fields_naive_time_and_nan() -> None: - """Verify contracts reject unknown fields, naive timestamps and NaN values.""" - payload = json.loads((FIXTURES / "contracts/process_state.json").read_text(encoding="utf-8")) - payload["unexpected"] = True - with pytest.raises(ValidationError): - ProcessState.model_validate(payload) - - observation = { - "id": "invalid", - "signal_id": "ht:sulfur", - "stage": "ht", - "source": "pak", - "measured_at": "2026-01-01T00:00:00", - "available_at": "2026-01-01T00:00:00Z", - "value": 1.0, - "unit": "mg/kg", - "validity": "valid", - "source_ref": "fixture", - } - with pytest.raises(ValidationError): - Observation.model_validate(observation) - observation["measured_at"] = "2026-01-01T00:00:00Z" - observation["value"] = float("nan") - with pytest.raises(ValidationError): - Observation.model_validate(observation) - - -def test_candidate_contract_enforces_kind_and_recipe() -> None: - """Verify candidate payloads cannot contradict their declared action kind.""" - with pytest.raises(ValidationError): - CandidateAction( - id="bad-hold", - kind=CandidateKind.HOLD, - setpoints={"ht:T6": 300.0}, - horizon_minutes=60, - ) - with pytest.raises(ValidationError): - CandidateAction( - id="bad-blend", - kind=CandidateKind.BLEND, - blend_mass_fractions={"A": 0.8, "B": 0.3}, - horizon_minutes=60, - ) - - -def test_telemetry_is_utc_namespaced_and_reports_conflicts(tmp_path: Path) -> None: - """Verify telemetry normalization, namespacing and duplicate conflict reporting.""" - path = tmp_path / "telemetry.csv" - pd.DataFrame( - { - "Unnamed: 0": [0, 1, 2], - "date": ["2026-01-15 12:00:00"] * 2 + ["broken"], - "T1": [130.0, 131.0, 132.0], - } - ).to_csv(path, index=False) - tags = { - "avt:T1": TagMeta( - signal_id="avt:T1", - raw_name="avt:T1", - stage=Stage.AVT, - meaning="temperature fixture", - raw_unit=None, - canonical_unit="unknown", - conversion="none", - mapping_status="ambiguous", - controllable=False, - evidence_ref="fixture", - ) - } - result = read_telemetry_csv(path, "avt", tags) - assert list(result.frame.columns) == ["avt:T1", "timestamp"] or list(result.frame.columns) == [ - "timestamp", - "avt:T1", - ] - assert len(result.frame) == 1 - assert result.frame.loc[0, "timestamp"].tzinfo is not None - assert pd.isna(result.frame.loc[0, "avt:T1"]) - assert {issue.code for issue in result.issues} >= { - "INVALID_TIMESTAMP", - "SOURCE_CONFLICT", - "TAG_UNCONFIRMED", - } - - -def test_pak_and_lims_keep_time_semantics_and_invalid_values(tmp_path: Path) -> None: - """Verify PAK and LIMS preserve timestamps, invalid values and delay semantics.""" - pak_path = tmp_path / "pak.xlsx" - pd.DataFrame( - [ - ["24-2000:D15", None], - ["кг/м3", None], - [datetime(2026, 1, 15, 12), 830.0], - [datetime(2026, 1, 15, 12, 10), "Pt Created"], - ] - ).to_excel(pak_path, header=False, index=False) - lims_path = tmp_path / "lims.xlsx" - section = "Установка 'Гидроочистка'. Точка отбора '2'. Продукт 'ДТ'" - pd.DataFrame( - [ - [section, None], - ["Mg.Sulfur", None], - ["мг/кг", None], - ["Количество значений:", 1], - [datetime(2026, 1, 15, 12), 8.4], - ] - ).to_excel(lims_path, header=False, index=False) - tags = { - "24-2000:D15": TagMeta( - signal_id="ht:density_15c", - raw_name="24-2000:D15", - stage="ht", - meaning="density", - raw_unit="кг/м3", - canonical_unit="kg/m3", - conversion="none", - mapping_status="confirmed", - controllable=False, - evidence_ref="fixture", - ), - "ЛИМС:Гидроочистка.2:Mg.Sulfur": TagMeta( - signal_id="ht:2:Mg.Sulfur", - raw_name="ЛИМС:Гидроочистка.2:Mg.Sulfur", - stage="ht", - meaning="sulfur", - raw_unit="мг/кг", - canonical_unit="mg/kg", - conversion="none", - mapping_status="confirmed", - controllable=False, - evidence_ref="fixture", - ), - } - pak = read_pak(pak_path, tags) - lims = read_lims(lims_path, tags, lims_delay_hours=6) - assert pak.observations[0].measured_at == datetime(2026, 1, 15, 9, tzinfo=UTC) - assert pak.observations[0].available_at == pak.observations[0].measured_at - assert pak.observations[1].validity is Validity.INVALID - assert pak.observations[1].value is None - assert "INVALID_VALUE" in {issue.code for issue in pak.issues} - assert lims.observations[0].available_at - lims.observations[0].measured_at == pd.Timedelta( - hours=6 - ) - - -def test_expert_confirmed_pak_conversion_and_lims_unit_override(tmp_path: Path) -> None: - """Apply only the PAK conversion and bad-header override confirmed by experts.""" - pak_path = tmp_path / "pak_sulfur.xlsx" - pd.DataFrame( - [ - ["24-2000:Mg.Sulfur", None], - ["ppm", None], - [datetime(2026, 1, 15, 12), 7.5], - ] - ).to_excel(pak_path, header=False, index=False) - lims_path = tmp_path / "lims_bad_unit.xlsx" - section = "Установка 'АВТ'. Точка отбора '1'. Продукт 'ДТ'" - pd.DataFrame( - [ - [section, None], - ["50%.T", None], - ["кг/м3", None], - ["Количество значений:", 1], - [datetime(2026, 1, 15, 12), 250.0], - ] - ).to_excel(lims_path, header=False, index=False) - tags = { - "24-2000:Mg.Sulfur": TagMeta( - signal_id="ht:2:Mg.Sulfur", - raw_name="24-2000:Mg.Sulfur", - stage="ht", - meaning="sulfur", - raw_unit="ppm", - canonical_unit="mg/kg", - conversion="ppm_mass_to_mg_kg", - mapping_status="confirmed", - controllable=False, - evidence_ref="DESIGN §16", - ), - "ЛИМС:АВТ.1:50%.T": TagMeta( - signal_id="avt:1:50%.T", - raw_name="ЛИМС:АВТ.1:50%.T", - stage="avt", - meaning="50 percent boiling temperature", - raw_unit="кг/м3", - canonical_unit="degC", - conversion="none", - mapping_status="confirmed", - controllable=False, - evidence_ref="DESIGN §16", - ), - } - - pak = read_pak(pak_path, tags) - lims = read_lims(lims_path, tags, lims_delay_hours=4) - - assert pak.observations[0].signal_id == "ht:2:Mg.Sulfur" - assert pak.observations[0].unit == "mg/kg" - assert pak.observations[0].value == pytest.approx(7.5) - assert pak.observations[0].validity is Validity.VALID - assert lims.observations[0].unit == "degC" - assert lims.observations[0].validity is Validity.VALID - assert {issue.code for issue in lims.issues} == {"UNIT_HEADER_OVERRIDDEN"} - - -def test_build_state_cannot_see_delayed_lims() -> None: - """Verify state selection respects measured and publication availability times.""" - quality = pd.read_csv(FIXTURES / "data/quality.csv") - manifest = DatasetManifest.model_validate_json( - (FIXTURES / "data/manifest.json").read_text(encoding="utf-8") - ) - data = PreparedData( - telemetry=pd.read_csv(FIXTURES / "data/telemetry.csv"), - quality=quality, - issues=pd.read_csv(FIXTURES / "data/issues.csv"), - manifest=manifest, - feature_order=tuple( - json.loads((FIXTURES / "data/feature_order.json").read_text(encoding="utf-8")) - ), - ) - scenario = load_scenario("config/scenarios/history.json") - config = load_runtime_config("config/runtime.toml") - - early = build_state(data, datetime(2026, 1, 15, 8, 10, tzinfo=UTC), scenario, config) - assert early.signals["ht:2:Mg.Sulfur"].selected.source.value == "pak" - later = build_state(data, datetime(2026, 1, 15, 14, 10, tzinfo=UTC), scenario, config) - assert later.signals["ht:2:Mg.Sulfur"].selected.source.value == "lims" +"""Acceptance checks for the complete stage-0 contract boundary.""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path + +import pandas as pd +import pytest +from pydantic import ValidationError + +from source.config import load_runtime_config, load_scenario, load_tag_dictionary +from source.contracts import ( + CandidateAction, + CandidateKind, + DatasetManifest, + MappingStatus, + Observation, + ProcessState, + Recommendation, + Stage, + TagMeta, + Unit, + Validity, +) +from source.data.ingest import read_lims, read_pak, read_telemetry_csv +from source.data.prepare import PreparedData, known_feature_order +from source.data.state import build_state + +FIXTURES = Path(__file__).parent / "fixtures" + + +def test_all_versioned_configs_load() -> None: + """Verify runtime settings and every checked-in scenario load successfully.""" + runtime = load_runtime_config("config/runtime.toml") + assert runtime.source_timezone == "Europe/Moscow" + assert runtime.lims_delay_hours == 4 + assert runtime.horizon_minutes == 60 + assert runtime.max_candidates == 125 + assert {load_scenario(path).id for path in Path("config/scenarios").glob("*.json")} == { + "history", + "blend_normal", + "blend_risk", + "blend_t95_risk", + "blend_cetane_risk", + "blend_missing", + "hybrid_blend", + } + + +def test_tag_dictionary_uses_confirmed_expert_clarifications() -> None: + """Verify expert-confirmed mappings, conversion and controllable signals.""" + tags = load_tag_dictionary("config/tags.csv") + assert len(tags) == 170 + assert {tag.signal_id for tag in tags.values() if tag.controllable} == { + "ht:F19", + "ht:P8", + "ht:T11", + } + pak_sulfur = tags["24-2000:Mg.Sulfur"] + assert pak_sulfur.mapping_status is MappingStatus.CONFIRMED + assert pak_sulfur.signal_id == "ht:2:Mg.Sulfur" + assert pak_sulfur.canonical_unit == Unit.MG_KG.value + assert pak_sulfur.conversion.value == "ppm_mass_to_mg_kg" + assert tags["ЛИМС:Гидроочистка.2:Mg.Sulfur"].mapping_status is MappingStatus.CONFIRMED + + +def test_ml_feature_order_excludes_unconfirmed_mappings() -> None: + """Unknown tags stay inspectable but cannot silently become model inputs.""" + tags = load_tag_dictionary("config/tags.csv") + + feature_order = known_feature_order(tags) + + assert "ht:2:Mg.Sulfur" in feature_order + assert "ht:sulfur" not in feature_order + + +def test_serialized_contract_examples_validate() -> None: + """Verify the representative serialized state and recommendation contracts.""" + ProcessState.model_validate_json( + (FIXTURES / "contracts/process_state.json").read_text(encoding="utf-8") + ) + Recommendation.model_validate_json( + (FIXTURES / "contracts/recommendation.json").read_text(encoding="utf-8") + ) + legacy = json.loads((FIXTURES / "contracts/recommendation.json").read_text(encoding="utf-8")) + legacy["schema_version"] = "1.0" + legacy["selected"]["candidate"].pop("additive_mass_fraction") + Recommendation.model_validate(legacy) + + +@pytest.mark.parametrize( + "name", + [ + "blend_normal", + "blend_risk", + "blend_t95_risk", + "blend_cetane_risk", + "blend_missing", + ], +) +def test_model_demo_state_fixtures_validate(name: str) -> None: + """Verify each model-demo fixture contains a valid process state and status.""" + fixture = json.loads((FIXTURES / f"model_demo/{name}.json").read_text(encoding="utf-8")) + ProcessState.model_validate(fixture["state"]) + assert fixture["expected"]["status"] in {"hold", "recommend", "abstain"} + + +def test_model_demo_numbers_match_design() -> None: + """Verify fixture sulfur values match the numerical examples in DESIGN.md.""" + normal = load_scenario("config/scenarios/blend_normal.json") + risk = load_scenario("config/scenarios/blend_risk.json") + + def sulfur(scenario, field: str) -> float: + """Calculate weighted sulfur for the scenario's current blend.""" + return sum( + scenario.current_blend_mass_fractions[item.id] * getattr(item.sulfur, field) + for item in scenario.blend_components + ) + + assert sulfur(normal, "value") == pytest.approx(8.316) + assert sulfur(normal, "upper") == pytest.approx(9.306) + assert sulfur(risk, "value") == pytest.approx(13.068) + assert sulfur(risk, "upper") == pytest.approx(14.058) + missing = load_scenario("config/scenarios/blend_missing.json") + assert missing.blend_components[0].sulfur.value is None + assert missing.blend_components[0].sulfur.upper is None + + +def test_contracts_reject_extra_fields_naive_time_and_nan() -> None: + """Verify contracts reject unknown fields, naive timestamps and NaN values.""" + payload = json.loads((FIXTURES / "contracts/process_state.json").read_text(encoding="utf-8")) + payload["unexpected"] = True + with pytest.raises(ValidationError): + ProcessState.model_validate(payload) + + observation = { + "id": "invalid", + "signal_id": "ht:sulfur", + "stage": "ht", + "source": "pak", + "measured_at": "2026-01-01T00:00:00", + "available_at": "2026-01-01T00:00:00Z", + "value": 1.0, + "unit": "mg/kg", + "validity": "valid", + "source_ref": "fixture", + } + with pytest.raises(ValidationError): + Observation.model_validate(observation) + observation["measured_at"] = "2026-01-01T00:00:00Z" + observation["value"] = float("nan") + with pytest.raises(ValidationError): + Observation.model_validate(observation) + + +def test_candidate_contract_enforces_kind_and_recipe() -> None: + """Verify candidate payloads cannot contradict their declared action kind.""" + with pytest.raises(ValidationError): + CandidateAction( + id="bad-hold", + kind=CandidateKind.HOLD, + setpoints={"ht:T6": 300.0}, + horizon_minutes=60, + ) + with pytest.raises(ValidationError): + CandidateAction( + id="bad-blend", + kind=CandidateKind.BLEND, + blend_mass_fractions={"A": 0.8, "B": 0.3}, + horizon_minutes=60, + ) + + +def test_telemetry_is_utc_namespaced_and_reports_conflicts(tmp_path: Path) -> None: + """Verify telemetry normalization, namespacing and duplicate conflict reporting.""" + path = tmp_path / "telemetry.csv" + pd.DataFrame( + { + "Unnamed: 0": [0, 1, 2], + "date": ["2026-01-15 12:00:00"] * 2 + ["broken"], + "T1": [130.0, 131.0, 132.0], + } + ).to_csv(path, index=False) + tags = { + "avt:T1": TagMeta( + signal_id="avt:T1", + raw_name="avt:T1", + stage=Stage.AVT, + meaning="temperature fixture", + raw_unit=None, + canonical_unit="unknown", + conversion="none", + mapping_status="ambiguous", + controllable=False, + evidence_ref="fixture", + ) + } + result = read_telemetry_csv(path, "avt", tags) + assert list(result.frame.columns) == ["avt:T1", "timestamp"] or list(result.frame.columns) == [ + "timestamp", + "avt:T1", + ] + assert len(result.frame) == 1 + assert result.frame.loc[0, "timestamp"].tzinfo is not None + assert pd.isna(result.frame.loc[0, "avt:T1"]) + assert {issue.code for issue in result.issues} >= { + "INVALID_TIMESTAMP", + "SOURCE_CONFLICT", + "TAG_UNCONFIRMED", + } + + +def test_pak_and_lims_keep_time_semantics_and_invalid_values(tmp_path: Path) -> None: + """Verify PAK and LIMS preserve timestamps, invalid values and delay semantics.""" + pak_path = tmp_path / "pak.xlsx" + pd.DataFrame( + [ + ["24-2000:D15", None], + ["кг/м3", None], + [datetime(2026, 1, 15, 12), 830.0], + [datetime(2026, 1, 15, 12, 10), "Pt Created"], + ] + ).to_excel(pak_path, header=False, index=False) + lims_path = tmp_path / "lims.xlsx" + section = "Установка 'Гидроочистка'. Точка отбора '2'. Продукт 'ДТ'" + pd.DataFrame( + [ + [section, None], + ["Mg.Sulfur", None], + ["мг/кг", None], + ["Количество значений:", 1], + [datetime(2026, 1, 15, 12), 8.4], + ] + ).to_excel(lims_path, header=False, index=False) + tags = { + "24-2000:D15": TagMeta( + signal_id="ht:density_15c", + raw_name="24-2000:D15", + stage="ht", + meaning="density", + raw_unit="кг/м3", + canonical_unit="kg/m3", + conversion="none", + mapping_status="confirmed", + controllable=False, + evidence_ref="fixture", + ), + "ЛИМС:Гидроочистка.2:Mg.Sulfur": TagMeta( + signal_id="ht:2:Mg.Sulfur", + raw_name="ЛИМС:Гидроочистка.2:Mg.Sulfur", + stage="ht", + meaning="sulfur", + raw_unit="мг/кг", + canonical_unit="mg/kg", + conversion="none", + mapping_status="confirmed", + controllable=False, + evidence_ref="fixture", + ), + } + pak = read_pak(pak_path, tags) + lims = read_lims(lims_path, tags, lims_delay_hours=6) + assert pak.observations[0].measured_at == datetime(2026, 1, 15, 9, tzinfo=UTC) + assert pak.observations[0].available_at == pak.observations[0].measured_at + assert pak.observations[1].validity is Validity.INVALID + assert pak.observations[1].value is None + assert "INVALID_VALUE" in {issue.code for issue in pak.issues} + assert lims.observations[0].available_at - lims.observations[0].measured_at == pd.Timedelta( + hours=6 + ) + + +def test_expert_confirmed_pak_conversion_and_lims_unit_override(tmp_path: Path) -> None: + """Apply only the PAK conversion and bad-header override confirmed by experts.""" + pak_path = tmp_path / "pak_sulfur.xlsx" + pd.DataFrame( + [ + ["24-2000:Mg.Sulfur", None], + ["ppm", None], + [datetime(2026, 1, 15, 12), 7.5], + ] + ).to_excel(pak_path, header=False, index=False) + lims_path = tmp_path / "lims_bad_unit.xlsx" + section = "Установка 'АВТ'. Точка отбора '1'. Продукт 'ДТ'" + pd.DataFrame( + [ + [section, None], + ["50%.T", None], + ["кг/м3", None], + ["Количество значений:", 1], + [datetime(2026, 1, 15, 12), 250.0], + ] + ).to_excel(lims_path, header=False, index=False) + tags = { + "24-2000:Mg.Sulfur": TagMeta( + signal_id="ht:2:Mg.Sulfur", + raw_name="24-2000:Mg.Sulfur", + stage="ht", + meaning="sulfur", + raw_unit="ppm", + canonical_unit="mg/kg", + conversion="ppm_mass_to_mg_kg", + mapping_status="confirmed", + controllable=False, + evidence_ref="DESIGN §16", + ), + "ЛИМС:АВТ.1:50%.T": TagMeta( + signal_id="avt:1:50%.T", + raw_name="ЛИМС:АВТ.1:50%.T", + stage="avt", + meaning="50 percent boiling temperature", + raw_unit="кг/м3", + canonical_unit="degC", + conversion="none", + mapping_status="confirmed", + controllable=False, + evidence_ref="DESIGN §16", + ), + } + + pak = read_pak(pak_path, tags) + lims = read_lims(lims_path, tags, lims_delay_hours=4) + + assert pak.observations[0].signal_id == "ht:2:Mg.Sulfur" + assert pak.observations[0].unit == "mg/kg" + assert pak.observations[0].value == pytest.approx(7.5) + assert pak.observations[0].validity is Validity.VALID + assert lims.observations[0].unit == "degC" + assert lims.observations[0].validity is Validity.VALID + assert {issue.code for issue in lims.issues} == {"UNIT_HEADER_OVERRIDDEN"} + + +def test_build_state_cannot_see_delayed_lims() -> None: + """Verify state selection respects measured and publication availability times.""" + quality = pd.read_csv(FIXTURES / "data/quality.csv") + manifest = DatasetManifest.model_validate_json( + (FIXTURES / "data/manifest.json").read_text(encoding="utf-8") + ) + data = PreparedData( + telemetry=pd.read_csv(FIXTURES / "data/telemetry.csv"), + quality=quality, + issues=pd.read_csv(FIXTURES / "data/issues.csv"), + manifest=manifest, + feature_order=tuple( + json.loads((FIXTURES / "data/feature_order.json").read_text(encoding="utf-8")) + ), + ) + scenario = load_scenario("config/scenarios/history.json") + config = load_runtime_config("config/runtime.toml") + + early = build_state(data, datetime(2026, 1, 15, 8, 10, tzinfo=UTC), scenario, config) + assert early.signals["ht:2:Mg.Sulfur"].selected.source.value == "pak" + later = build_state(data, datetime(2026, 1, 15, 14, 10, tzinfo=UTC), scenario, config) + assert later.signals["ht:2:Mg.Sulfur"].selected.source.value == "lims" diff --git a/global_tests/test_stage1_cycle.py b/global_tests/test_stage1_cycle.py index fae17fc..dba414d 100644 --- a/global_tests/test_stage1_cycle.py +++ b/global_tests/test_stage1_cycle.py @@ -9,7 +9,7 @@ import pytest from source.config import load_runtime_config, load_scenario -from source.contracts import DecisionContext, RecommendationStatus +from source.contracts import DecisionContext, RecommendationStatus, ScenarioConfig from source.orchestrator import run_cycle FIXTURES = Path(__file__).parent / "fixtures" @@ -42,6 +42,18 @@ def _run_scenario(runtime_config, scenario, tmp_path: Path): ) +def _run_scenario(runtime_config, scenario: ScenarioConfig, tmp_path: Path): + return run_cycle( + data=None, + as_of=datetime(2026, 1, 15, 9, tzinfo=UTC), + model=None, + scenario=scenario, + config=runtime_config, + context=DecisionContext(), + run_dir=tmp_path, + ) + + def _metric(evaluation, name: str): for assessment in evaluation.assessments: if name in assessment.metrics: @@ -49,63 +61,68 @@ def _metric(evaluation, name: str): raise AssertionError(f"metric {name} not found") -def test_stage1_holds_complete_normal_blend( +def test_complete_product_passport_allows_stable_hold( runtime_config, tmp_path: Path, ) -> None: - """Complete model-demo product properties allow a hold decision.""" + """A stable recipe passes sulfur, T95 and cetane conservative bounds.""" result = _run_demo(runtime_config, "blend_normal", tmp_path) assert result.status is RecommendationStatus.HOLD assert result.selected is not None - assert result.selected.candidate.id == "hold" - assert "NO_MATERIAL_IMPROVEMENT" in result.reason_codes + assert [check.status.value for check in result.selected.checks[:3]] == ["pass"] * 3 -def test_stage1_recommends_model_recipe_for_risk_blend( +def test_complete_product_passport_allows_risk_recommendation( runtime_config, tmp_path: Path, ) -> None: - """A risky model-demo blend can recommend a synthetic safer recipe.""" + """The selected recipe must pass all three product-quality constraints.""" result = _run_demo(runtime_config, "blend_risk", tmp_path) + assert result.schema_version == "1.1" assert result.status is RecommendationStatus.RECOMMEND assert result.baseline is not None assert result.baseline.feasible is False assert result.selected is not None - assert result.selected.candidate.blend_mass_fractions == {"A": 0.9, "B": 0.1} - assert "QUALITY_LIMIT" in result.reason_codes + assert result.selected.candidate.blend_mass_fractions == pytest.approx({"A": 0.891, "B": 0.099}) + assert result.selected.candidate.additive_mass_fraction == pytest.approx(0.01) + assert all(check.status.value == "pass" for check in result.selected.checks) + legacy_payload = result.model_dump(mode="json") + legacy_payload["schema_version"] = "1.0" + with pytest.raises(ValueError, match="requires recommendation schema 1.1"): + type(result).model_validate(legacy_payload) -def test_stage1_abstains_when_model_demo_t95_is_missing( +def test_stage1_abstains_when_required_component_quality_is_missing( runtime_config, tmp_path: Path, ) -> None: - """Missing scenario passport properties still block model-demo recommendations.""" - scenario = load_scenario("config/scenarios/blend_normal.json") - first, second = scenario.blend_components - broken = scenario.model_copy( - update={"blend_components": (first.model_copy(update={"t95": None}), second)} - ) - - result = _run_scenario(runtime_config, broken, tmp_path) + """Missing component quality should remain an explained refusal.""" + result = _run_demo(runtime_config, "blend_missing", tmp_path) assert result.status is RecommendationStatus.ABSTAIN assert result.selected is None - assert "UNASSESSED_REQUIRED_PROPERTY" in result.reason_codes + assert "MISSING_REQUIRED_SIGNAL" in result.reason_codes + assert "Надёжной рекомендации нет" in result.explanation -def test_stage1_abstains_when_required_component_quality_is_missing( +@pytest.mark.parametrize("missing_field", ["t95", "cetane_number"]) +def test_stage1_abstains_when_required_product_property_is_unassessed( runtime_config, tmp_path: Path, + missing_field: str, ) -> None: - """Missing component quality should remain an explained refusal.""" - result = _run_demo(runtime_config, "blend_missing", tmp_path) + """Unknown T95 or cetane data must never be treated as a passed constraint.""" + payload = load_scenario("config/scenarios/blend_normal.json").model_dump(mode="json") + payload["blend_components"][0][missing_field] = None + scenario = ScenarioConfig.model_validate(payload) + + result = _run_scenario(runtime_config, scenario, tmp_path) assert result.status is RecommendationStatus.ABSTAIN assert result.selected is None - assert "MISSING_REQUIRED_SIGNAL" in result.reason_codes - assert "Надёжной рекомендации нет" in result.explanation + assert "UNASSESSED_REQUIRED_PROPERTY" in result.reason_codes def test_stage1_writes_journal_files( diff --git a/global_tests/test_stage1_ml.py b/global_tests/test_stage1_ml.py index 5f753a4..1057856 100644 --- a/global_tests/test_stage1_ml.py +++ b/global_tests/test_stage1_ml.py @@ -84,7 +84,7 @@ def test_history_without_confirmed_severity_factors_is_unavailable_not_zero() -> @pytest.mark.parametrize( ("name", "value", "upper"), - [("blend_normal", 8.4, 9.4), ("blend_risk", 13.2, 14.2)], + [("blend_normal", 8.316, 9.306), ("blend_risk", 13.068, 14.058)], ) def test_model_demo_quality_is_calculated_from_components( name: str, value: float, upper: float @@ -100,6 +100,8 @@ def test_model_demo_quality_is_calculated_from_components( assert assessment.metrics["t95"].value is not None assert assessment.metrics["cetane_number"].value is not None assert assessment.metrics["sulfur"].basis.value == "formula" + assert assessment.metrics["t95"].upper is not None + assert assessment.metrics["cetane_number"].lower is not None def test_missing_component_quality_is_unavailable_not_zero() -> None: @@ -168,12 +170,17 @@ def test_candidates_include_hold_and_deterministic_recipe_grid() -> None: candidates = generate_candidates(state, scenario) - assert len(candidates) == 21 + assert len(candidates) == 84 assert candidates[0].kind is CandidateKind.HOLD assert sum(candidate.kind is CandidateKind.HOLD for candidate in candidates) == 1 - assert any(candidate.blend_mass_fractions == {"A": 0.9, "B": 0.1} for candidate in candidates) + assert any( + candidate.blend_mass_fractions == pytest.approx({"A": 0.891, "B": 0.099}) + and candidate.additive_mass_fraction == pytest.approx(0.01) + for candidate in candidates + ) assert all( - abs(sum(candidate.blend_mass_fractions.values()) - 1.0) <= 1e-9 + abs(sum(candidate.blend_mass_fractions.values()) + candidate.additive_mass_fraction - 1.0) + <= 1e-9 for candidate in candidates if candidate.kind is CandidateKind.BLEND ) @@ -183,9 +190,10 @@ def test_blend_effects_match_design_and_keep_state_immutable() -> None: state, scenario = _demo("blend_risk") before = state.model_dump_json() candidate = CandidateAction( - id="blend:A=0.90,B=0.10", + id="blend:A=0.891,B=0.099,additive=0.010", kind="blend", - blend_mass_fractions={"A": 0.9, "B": 0.1}, + blend_mass_fractions={"A": 0.891, "B": 0.099}, + additive_mass_fraction=0.01, horizon_minutes=60, is_model_scenario=True, ) @@ -193,11 +201,11 @@ def test_blend_effects_match_design_and_keep_state_immutable() -> None: metrics = calculate_blend_metrics(candidate, scenario) assessments = assess_blend_candidate(state, candidate, scenario) - assert metrics["sulfur"].value == pytest.approx(8.4) - assert metrics["sulfur"].upper == pytest.approx(9.4) - assert metrics["t95"].value == pytest.approx(351.0) - assert metrics["cetane_number"].value == pytest.approx(53.4) - assert metrics["cost_proxy"].value == pytest.approx(0.98) - assert metrics["change_size"].value == pytest.approx(0.4) + assert metrics["sulfur"].value == pytest.approx(8.316) + assert metrics["sulfur"].upper == pytest.approx(9.306) + assert metrics["t95"].upper == pytest.approx(354.0) + assert metrics["cetane_number"].lower == pytest.approx(52.6) + assert metrics["cost_proxy"].value == pytest.approx(1.9702) + assert metrics["change_size"].value == pytest.approx(0.396) assert [item.agent.value for item in assessments] == ["quality", "reliability", "optimizer"] assert state.model_dump_json() == before diff --git a/global_tests/test_stage1_ml_gaps.py b/global_tests/test_stage1_ml_gaps.py index 5473c22..2a877ec 100644 --- a/global_tests/test_stage1_ml_gaps.py +++ b/global_tests/test_stage1_ml_gaps.py @@ -27,7 +27,7 @@ def test_candidate_grid_fails_instead_of_silently_truncating() -> None: with pytest.raises( ValueError, - match=r"requires 21 candidates including hold, but max_candidates=20", + match=r"requires 84 candidates including hold, but max_candidates=20", ): optimizer.generate_candidates(state, scenario, config) diff --git a/global_tests/test_stage3_guardrails.py b/global_tests/test_stage3_guardrails.py index 77c332a..5d5c891 100644 --- a/global_tests/test_stage3_guardrails.py +++ b/global_tests/test_stage3_guardrails.py @@ -37,31 +37,34 @@ def _baseline_feasible_cost_scenario(cost_threshold: float): scenario = load_scenario("config/scenarios/blend_normal.json") thresholds = dict(scenario.materiality_thresholds) thresholds["cost_proxy"] = cost_threshold + components = list(scenario.blend_components) + components[1] = components[1].model_copy( + update={"sulfur": components[1].sulfur.model_copy(update={"value": 24.0, "upper": 25.0})} + ) return scenario.model_copy( update={ "id": f"blend_materiality_{cost_threshold:g}", - "current_blend_mass_fractions": {"A": 1.0, "B": 0.0}, + "blend_components": tuple(components), "materiality_thresholds": thresholds, } ) def test_stage3_keeps_hold_when_improvement_is_not_material(runtime_config, tmp_path: Path) -> None: - """A feasible baseline should stay hold when the improvement is too small.""" + """A sub-threshold cost improvement keeps the valid current recipe.""" scenario = _baseline_feasible_cost_scenario(cost_threshold=0.05) result = _run(scenario, runtime_config, tmp_path) assert result.status is RecommendationStatus.HOLD assert result.selected is not None - assert result.selected.candidate.id == "hold" assert "NO_MATERIAL_IMPROVEMENT" in result.reason_codes def test_stage3_cooldown_suppresses_repeat_when_baseline_is_feasible( runtime_config, tmp_path: Path ) -> None: - """Cooldown suppresses a repeat recommendation while hold is still feasible.""" + """Cooldown suppresses a repeated material change when hold is feasible.""" scenario = _baseline_feasible_cost_scenario(cost_threshold=0.005) context = DecisionContext(last_recommended_at=AS_OF - timedelta(minutes=30)) diff --git a/global_tests/test_stage4_blending.py b/global_tests/test_stage4_blending.py index 036016d..3b909f8 100644 --- a/global_tests/test_stage4_blending.py +++ b/global_tests/test_stage4_blending.py @@ -65,13 +65,14 @@ def test_mass_balance_uses_point_and_upper_sulfur_by_mass() -> None: assert result.sulfur.value == pytest.approx(8.4) assert result.sulfur.upper == pytest.approx(9.4) - assert result.t95.value == pytest.approx(351.0) - assert result.t95.upper == pytest.approx(356.0) - assert result.cetane_number.value == pytest.approx(53.4) + assert result.t95.value == pytest.approx(352.0) + assert result.t95.upper == pytest.approx(354.0) + assert result.cetane_number.value == pytest.approx(50.1) + assert result.cetane_number.lower == pytest.approx(49.6) assert sulfur_constraint_status(result, 10.0) is ConstraintStatus.PASS assert result.checked_properties == ("sulfur", "t95", "cetane_number", "component_stock") - assert result.unassessed_properties == ("full_product_passport",) - assert result.full_specification_status == "not_assessed" + assert result.unassessed_properties == () + assert result.full_specification_status == "assessed" def test_component_quality_changes_recipe_feasibility() -> None: @@ -91,17 +92,39 @@ def test_hybrid_quality_changes_the_set_of_feasible_recipes() -> None: _components(_forecast(upper=7.0)), 100.0, scenario.current_blend_mass_fractions, + additive_mass_fraction=0.01, + additive=scenario.cetane_additive, ) degraded = rank_feasible_blends( recipes, _components(_forecast(upper=9.0)), 100.0, scenario.current_blend_mass_fractions, + additive_mass_fraction=0.01, + additive=scenario.cetane_additive, ) assert good and degraded assert {option.recipe_id for option in good} != {option.recipe_id for option in degraded} - assert all(option.result.full_specification_status == "not_assessed" for option in good) + assert all(option.result.full_specification_status == "assessed" for option in good) + + +def test_additive_curve_enables_cetane_constraint_inside_model_scenario() -> None: + scenario = load_scenario("config/scenarios/blend_risk.json") + recipe = {"A": 0.891, "B": 0.099} + + without_additive = calculate_mass_blend({"A": 0.9, "B": 0.1}, _components(), 100.0) + with_additive = calculate_mass_blend( + recipe, + _components(), + 100.0, + additive_mass_fraction=0.01, + additive=scenario.cetane_additive, + ) + + assert without_additive.cetane_number.lower == pytest.approx(49.6) + assert with_additive.cetane_number.lower == pytest.approx(52.6) + assert with_additive.sulfur.upper == pytest.approx(9.306) def test_missing_upper_is_unknown_not_zero_or_pass() -> None: diff --git a/global_tests/test_stage6_acceptance.py b/global_tests/test_stage6_acceptance.py index 4cee6a2..398134a 100644 --- a/global_tests/test_stage6_acceptance.py +++ b/global_tests/test_stage6_acceptance.py @@ -13,6 +13,7 @@ JOURNAL_FILES, load_episode_specs, run_acceptance_suite, + sha256_file, verify_model_freeze, ) from source.config import load_runtime_config, load_scenario @@ -26,7 +27,6 @@ Stage, Validity, ) -from source.ml.artifacts import sha256_file from source.orchestrator import run_cycle PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -38,9 +38,17 @@ def test_episode_catalog_is_explicit_and_complete() -> None: assert [episode.id for episode in episodes] == [ "stable_blend", "sulfur_risk", + "t95_risk", + "cetane_risk", "missing_component_quality", ] - assert all(episode.expected_status == "abstain" for episode in episodes) + assert [episode.expected_status for episode in episodes] == [ + "hold", + "recommend", + "recommend", + "recommend", + "abstain", + ] def test_acceptance_suite_reproduces_decisions_and_exports_full_journals( @@ -53,12 +61,15 @@ def test_acceptance_suite_reproduces_decisions_and_exports_full_journals( output_dir=output, ) - assert report["episode_count"] == 3 + assert report["episode_count"] == 5 assert report["max_cycle_seconds"] < 5 risk = next(item for item in report["episodes"] if item["episode_id"] == "sulfur_risk") - assert risk["baseline_upper_mg_kg"] == pytest.approx(14.2) - assert risk["sulfur_only_counterfactual"]["upper_mg_kg"] == pytest.approx(9.4) - assert risk["sulfur_only_counterfactual"]["operator_recommendation"] is False + assert risk["baseline_quality"]["sulfur_upper"] == pytest.approx(14.058) + assert risk["selected_quality"] == pytest.approx( + {"sulfur_upper": 9.306, "t95_upper": 354.0, "cetane_lower": 52.6} + ) + assert risk["selected_additive_fraction"] == pytest.approx(0.01) + assert risk["operator_recommendation"] is True with zipfile.ZipFile(output / "journals.zip") as archive: names = set(archive.namelist()) assert "manifest.json" in names @@ -137,7 +148,7 @@ def test_model_freeze_verifies_hashes_and_rejects_tampering(tmp_path: Path) -> N def test_failed_acceptance_does_not_publish_partial_directory(tmp_path: Path) -> None: bad_catalog = tmp_path / "episodes.json" payload = json.loads((PROJECT_ROOT / "config/demo_episodes.json").read_text(encoding="utf-8")) - payload["episodes"][0]["expected_status"] = "hold" + payload["episodes"][0]["expected_status"] = "abstain" bad_catalog.write_text(json.dumps(payload), encoding="utf-8") output = tmp_path / "failed" diff --git a/global_tests/test_ui.py b/global_tests/test_ui.py index 8d56993..a472bfc 100644 --- a/global_tests/test_ui.py +++ b/global_tests/test_ui.py @@ -34,6 +34,8 @@ def _view(scenario_id: str, run_dir: Path): ( ("blend_normal", RecommendationStatus.HOLD), ("blend_risk", RecommendationStatus.RECOMMEND), + ("blend_t95_risk", RecommendationStatus.RECOMMEND), + ("blend_cetane_risk", RecommendationStatus.RECOMMEND), ("blend_missing", RecommendationStatus.ABSTAIN), ), ) @@ -48,17 +50,22 @@ def test_ui_projection_preserves_backend_status( assert view.status == expected_status.value -def test_ui_offers_synthetic_risk_recipe_and_shows_model_properties(tmp_path: Path) -> None: +def test_ui_exposes_complete_risk_recipe_and_quality_bounds(tmp_path: Path) -> None: _, view = _view("blend_risk", tmp_path) - assert view.baseline_upper == pytest.approx(14.2) - assert view.selected_upper == pytest.approx(9.4) - assert view.current_fractions == {"A": 0.7, "B": 0.3} - assert view.proposed_fractions == {"A": 0.9, "B": 0.1} - future = {row.name: row for row in view.constraints if row.name in {"T95", "Цетановое число"}} - assert set(future) == {"T95", "Цетановое число"} - assert all(row.status == "Оценено" for row in future.values()) - assert all(row.actual != "—" for row in future.values()) + assert view.baseline_upper == pytest.approx(14.058) + assert view.selected_upper == pytest.approx(9.306) + assert view.selected_t95_upper == pytest.approx(354.0) + assert view.selected_cetane_lower == pytest.approx(52.6) + assert view.current_fractions == pytest.approx({"A": 0.693, "B": 0.297}) + assert view.proposed_fractions == pytest.approx({"A": 0.891, "B": 0.099}) + assert view.current_additive_fraction == pytest.approx(0.01) + assert view.proposed_additive_fraction == pytest.approx(0.01) + quality = {row.name: row for row in view.constraints if row.name in {"T95", "Цетановое число"}} + assert all(row.status == "В пределах" for row in quality.values()) + additive = next(row for row in view.constraints if row.name == "Доля присадки") + assert additive.actual == "1 %" + assert additive.limit == "≤ 3 %" def test_ui_keeps_missing_sulfur_unavailable(tmp_path: Path) -> None: diff --git a/source/acceptance.py b/source/acceptance.py index 06cb0d5..3aee268 100644 --- a/source/acceptance.py +++ b/source/acceptance.py @@ -16,12 +16,10 @@ from source.config import load_runtime_config, load_scenario from source.contracts import ( CandidateEvaluation, - ConstraintStatus, DecisionContext, ProcessState, Recommendation, ) -from source.ml.artifacts import sha256_file from source.orchestrator import run_cycle JOURNAL_FILES = ( @@ -34,6 +32,15 @@ ) +def sha256_file(path: Path) -> str: + """Calculate a streaming SHA-256 digest without importing ML runtime deps.""" + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + @dataclass(frozen=True) class EpisodeSpec: """One checked-in acceptance episode and its stable expectations.""" @@ -42,9 +49,10 @@ class EpisodeSpec: scenario: str expected_status: str expected_reason_codes: tuple[str, ...] - expected_baseline_upper: float | None - expected_sulfur_only_upper: float | None - expected_sulfur_only_blend: dict[str, float] | None + expected_baseline_quality: dict[str, float | None] + expected_selected_quality: dict[str, float | None] | None + expected_recipe: dict[str, float] | None + expected_additive_fraction: float | None def load_episode_specs(path: Path) -> tuple[EpisodeSpec, ...]: @@ -64,27 +72,32 @@ def load_episode_specs(path: Path) -> tuple[EpisodeSpec, ...]: "scenario", "expected_status", "expected_reason_codes", - "expected_baseline_upper", - "expected_sulfur_only_upper", - "expected_sulfur_only_blend", + "expected_baseline_quality", + "expected_selected_quality", + "expected_recipe", + "expected_additive_fraction", } missing = required.difference(raw) if missing: raise ValueError(f"episode is missing fields: {sorted(missing)}") - blend = raw["expected_sulfur_only_blend"] + selected_quality = raw["expected_selected_quality"] + recipe = raw["expected_recipe"] episodes.append( EpisodeSpec( id=str(raw["id"]), scenario=str(raw["scenario"]), expected_status=str(raw["expected_status"]), expected_reason_codes=tuple(str(item) for item in raw["expected_reason_codes"]), - expected_baseline_upper=_optional_float(raw["expected_baseline_upper"]), - expected_sulfur_only_upper=_optional_float(raw["expected_sulfur_only_upper"]), - expected_sulfur_only_blend=( + expected_baseline_quality=_quality_expectation(raw["expected_baseline_quality"]), + expected_selected_quality=( + None if selected_quality is None else _quality_expectation(selected_quality) + ), + expected_recipe=( None - if blend is None - else {str(key): float(value) for key, value in dict(blend).items()} + if recipe is None + else {str(key): float(value) for key, value in dict(recipe).items()} ), + expected_additive_fraction=_optional_float(raw["expected_additive_fraction"]), ) ) if len({episode.id for episode in episodes}) != len(episodes): @@ -145,16 +158,19 @@ def run_acceptance_suite( ) cycle_seconds = time.perf_counter() - cycle_started journal_dir = run_root / result.run_id - sulfur_only = _sulfur_only_candidate(journal_dir) - _check_episode(episode, result, sulfur_only) + _check_episode(episode, result, scenario) + recipe, additive_fraction = _effective_selected_recipe(result, scenario) summaries.append( { "episode_id": episode.id, "scenario": episode.scenario, "status": result.status.value, "reason_codes": list(result.reason_codes), - "baseline_upper_mg_kg": _sulfur_upper(result.baseline), - "sulfur_only_counterfactual": sulfur_only, + "baseline_quality": _quality_snapshot(result.baseline), + "selected_quality": _quality_snapshot(result.selected), + "selected_recipe": recipe, + "selected_additive_fraction": additive_fraction, + "operator_recommendation": result.status.value in {"hold", "recommend"}, "decision_fingerprint": recommendation_fingerprint(result), "cycle_seconds": cycle_seconds, "run_id": result.run_id, @@ -171,8 +187,8 @@ def run_acceptance_suite( "episodes": summaries, "journal_archive": archive.name, "evidence_boundary": ( - "Historical metrics assess forecast accuracy; sulfur-only counterfactuals assess " - "the declared synthetic blending model and are not operator recommendations." + "Historical metrics assess forecast accuracy; model-demo recommendations prove " + "only the declared synthetic sulfur/T95/cetane and additive model." ), } _write_json(temporary / "summary.json", report) @@ -286,7 +302,7 @@ def verify_model_freeze(root: Path, manifest_path: Path) -> dict[str, Any]: def _check_episode( episode: EpisodeSpec, result: Recommendation, - sulfur_only: dict[str, Any] | None, + scenario: Any, ) -> None: if result.status.value != episode.expected_status: raise ValueError( @@ -296,71 +312,81 @@ def _check_episode( missing_reasons = set(episode.expected_reason_codes).difference(result.reason_codes) if missing_reasons: raise ValueError(f"episode {episode.id} lost reasons: {sorted(missing_reasons)}") + _check_quality( + _quality_snapshot(result.baseline), + episode.expected_baseline_quality, + f"episode {episode.id} baseline", + ) + actual_selected = _quality_snapshot(result.selected) + if episode.expected_selected_quality is None: + if actual_selected is not None: + raise ValueError(f"episode {episode.id} unexpectedly selected a result") + else: + _check_quality( + actual_selected, + episode.expected_selected_quality, + f"episode {episode.id} selected", + ) + recipe, additive_fraction = _effective_selected_recipe(result, scenario) + if recipe != episode.expected_recipe: + raise ValueError(f"episode {episode.id} selected recipe changed") _check_optional_number( - _sulfur_upper(result.baseline), episode.expected_baseline_upper, "baseline upper" + additive_fraction, + episode.expected_additive_fraction, + f"episode {episode.id} additive fraction", ) - actual_upper = None if sulfur_only is None else sulfur_only["upper_mg_kg"] - _check_optional_number(actual_upper, episode.expected_sulfur_only_upper, "sulfur-only upper") - actual_blend = None if sulfur_only is None else sulfur_only["blend_mass_fractions"] - if actual_blend != episode.expected_sulfur_only_blend: - raise ValueError(f"episode {episode.id} sulfur-only blend changed") - - -def _sulfur_only_candidate(journal_dir: Path) -> dict[str, Any] | None: - candidates = [ - json.loads(line) - for line in (journal_dir / "candidates.jsonl").read_text(encoding="utf-8").splitlines() - if line.strip() - ] - eligible = [] - for candidate in candidates: - checks = candidate["checks"] - if not checks or any(check["status"] != ConstraintStatus.PASS.value for check in checks): - continue - sulfur = _assessment_metric(candidate, "sulfur") - cost = _assessment_metric(candidate, "cost_proxy") - if sulfur is None or sulfur.get("upper") is None or cost is None: - continue - eligible.append((float(cost["value"]), candidate, sulfur)) - if not eligible: + + +def _quality_snapshot(evaluation: CandidateEvaluation | None) -> dict[str, float | None] | None: + if evaluation is None: return None - _, candidate, sulfur = min(eligible, key=lambda item: (item[0], item[1]["candidate"]["id"])) - action = candidate["candidate"] - fractions = action["blend_mass_fractions"] or None + estimates = {} + for assessment in evaluation.assessments: + estimates.update(assessment.metrics) return { - "candidate_id": action["id"], - "upper_mg_kg": float(sulfur["upper"]), - "blend_mass_fractions": fractions, - "operator_recommendation": False, - "blocked_by": list( - dict.fromkeys( - issue["code"] - for assessment in candidate["assessments"] - for issue in assessment["issues"] - if issue["severity"] == "blocking" - ) - ), + "sulfur_upper": _estimate_value(estimates.get("sulfur"), "upper"), + "t95_upper": _estimate_value(estimates.get("t95"), "upper"), + "cetane_lower": _estimate_value(estimates.get("cetane_number"), "lower"), } -def _assessment_metric(candidate: Mapping[str, Any], name: str) -> Mapping[str, Any] | None: - for assessment in candidate["assessments"]: - metric = assessment["metrics"].get(name) - if metric is not None: - if not isinstance(metric, Mapping): - raise ValueError(f"candidate metric {name} must be an object") - return metric - return None +def _estimate_value(estimate: Any, field: str) -> float | None: + value = None if estimate is None else getattr(estimate, field) + return None if value is None else float(value) -def _sulfur_upper(evaluation: CandidateEvaluation | None) -> float | None: - if evaluation is None: - return None - for assessment in evaluation.assessments: - metric = assessment.metrics.get("sulfur") - if metric is not None: - return None if metric.upper is None else float(metric.upper) - return None +def _effective_selected_recipe( + result: Recommendation, scenario: Any +) -> tuple[dict[str, float] | None, float | None]: + if result.selected is None: + return None, None + candidate = result.selected.candidate + if candidate.blend_mass_fractions: + return dict(candidate.blend_mass_fractions), candidate.additive_mass_fraction + return ( + dict(scenario.current_blend_mass_fractions), + scenario.current_additive_mass_fraction, + ) + + +def _quality_expectation(value: object) -> dict[str, float | None]: + if not isinstance(value, Mapping): + raise ValueError("quality expectation must be an object") + expected_keys = {"sulfur_upper", "t95_upper", "cetane_lower"} + if set(value) != expected_keys: + raise ValueError(f"quality expectation needs keys: {sorted(expected_keys)}") + return {str(key): _optional_float(item) for key, item in value.items()} + + +def _check_quality( + actual: dict[str, float | None] | None, + expected: dict[str, float | None], + label: str, +) -> None: + if actual is None: + raise ValueError(f"{label} is unavailable") + for key, expected_value in expected.items(): + _check_optional_number(actual[key], expected_value, f"{label} {key}") def _optional_float(value: object) -> float | None: @@ -407,5 +433,6 @@ def _write_zip_bytes(archive: zipfile.ZipFile, name: str, content: bytes) -> Non "load_episode_specs", "recommendation_fingerprint", "run_acceptance_suite", + "sha256_file", "verify_model_freeze", ] diff --git a/source/agents/effects.py b/source/agents/effects.py index 7d0f043..5f3d90f 100644 --- a/source/agents/effects.py +++ b/source/agents/effects.py @@ -11,6 +11,7 @@ AssessmentStatus, CandidateAction, CandidateKind, + CetaneAdditiveSpec, EstimateBasis, IntervalKind, Issue, @@ -25,32 +26,55 @@ FRACTION_TOLERANCE = 1e-9 -def _recipe(candidate: CandidateAction, scenario: ScenarioConfig) -> dict[str, float]: +def _recipe(candidate: CandidateAction, scenario: ScenarioConfig) -> tuple[dict[str, float], float]: component_ids = {component.id for component in scenario.blend_components} if len(component_ids) != 2: raise ValueError("stage-1 blending requires exactly two components") current = scenario.current_blend_mass_fractions - if set(current) != component_ids or abs(sum(current.values()) - 1.0) > FRACTION_TOLERANCE: - raise ValueError("current blend must contain every component and sum to one") + if set(current) != component_ids: + raise ValueError("current blend must contain every component") if candidate.kind is CandidateKind.HOLD: recipe = dict(current) + additive_fraction = scenario.current_additive_mass_fraction elif candidate.kind is CandidateKind.BLEND: recipe = dict(candidate.blend_mass_fractions) + additive_fraction = candidate.additive_mass_fraction else: raise ValueError("stage-1 effects cannot evaluate setpoint actions") if set(recipe) != component_ids: raise ValueError("blend recipe must contain every scenario component exactly once") - if abs(sum(recipe.values()) - 1.0) > FRACTION_TOLERANCE: - raise ValueError("blend mass fractions must sum to one") - return recipe + if abs(sum(recipe.values()) + additive_fraction - 1.0) > FRACTION_TOLERANCE: + raise ValueError("blend and additive mass fractions must sum to one") + if additive_fraction and scenario.cetane_additive is None: + raise ValueError("additive dose needs a configured cetane additive model") + return recipe, additive_fraction -def _weighted(recipe: Mapping[str, float], values: Mapping[str, float | None]) -> float | None: +def _weighted( + recipe: Mapping[str, float], + values: Mapping[str, float | None], + *, + normalize: bool = False, +) -> float | None: if any(values[component_id] is None for component_id, weight in recipe.items() if weight > 0): return None - return sum(weight * (values[component_id] or 0.0) for component_id, weight in recipe.items()) + total = sum(weight * (values[component_id] or 0.0) for component_id, weight in recipe.items()) + return total / sum(recipe.values()) if normalize else total + + +def _additive_gain(spec: CetaneAdditiveSpec | None, dose: float) -> float: + """Interpolate the explicit scenario response curve without extrapolation.""" + if dose == 0: + return 0.0 + if spec is None or dose > spec.max_mass_fraction + FRACTION_TOLERANCE: + raise ValueError("additive dose is outside the configured scenario model") + for left, right in zip(spec.response_curve, spec.response_curve[1:], strict=False): + if left.mass_fraction <= dose <= right.mass_fraction: + share = (dose - left.mass_fraction) / (right.mass_fraction - left.mass_fraction) + return left.cetane_gain + share * (right.cetane_gain - left.cetane_gain) + raise ValueError("additive response curve does not cover the requested dose") def _weighted_metric( @@ -92,17 +116,14 @@ def _weighted_metric( def calculate_blend_metrics( candidate: CandidateAction, scenario: ScenarioConfig ) -> dict[str, MetricEstimate]: - """Calculate product properties and transparent ranking proxies for one recipe.""" + """Calculate the complete synthetic product passport and ranking proxies.""" if scenario.mode is not OperationMode.MODEL_DEMO: raise ValueError("stage-1 blending effects are only valid in model_demo mode") if scenario.total_mass_t is None: raise ValueError("blend scenario needs total_mass_t") - recipe = _recipe(candidate, scenario) + recipe, additive_fraction = _recipe(candidate, scenario) components = {component.id: component for component in scenario.blend_components} - sulfur_units = {component.sulfur.unit for component in components.values()} - if sulfur_units != {Unit.MG_KG.value}: - raise ValueError("all sulfur components must use mg/kg") sulfur_value = _weighted( recipe, {key: component.sulfur.value for key, component in components.items()} @@ -110,26 +131,68 @@ def calculate_blend_metrics( sulfur_upper = _weighted( recipe, {key: component.sulfur.upper for key, component in components.items()} ) - t95_value, t95_upper, t95_unit, t95_basis, t95_interval, t95_ref = _weighted_metric( - recipe, {key: component.t95 for key, component in components.items()}, "t95" + t95_value = _weighted( + recipe, + { + key: None if component.t95 is None else component.t95.value + for key, component in components.items() + }, + normalize=True, ) - cetane_value, cetane_upper, cetane_unit, cetane_basis, cetane_interval, cetane_ref = ( - _weighted_metric( - recipe, - {key: component.cetane_number for key, component in components.items()}, - "cetane_number", - ) + t95_upper = _weighted( + recipe, + { + key: None if component.t95 is None else component.t95.upper + for key, component in components.items() + }, + normalize=True, + ) + cetane_value = _weighted( + recipe, + { + key: None if component.cetane_number is None else component.cetane_number.value + for key, component in components.items() + }, + normalize=True, + ) + cetane_lower = _weighted( + recipe, + { + key: None if component.cetane_number is None else component.cetane_number.lower + for key, component in components.items() + }, + normalize=True, + ) + cetane_gain = _additive_gain(scenario.cetane_additive, additive_fraction) + if cetane_value is not None: + cetane_value += cetane_gain + if cetane_lower is not None: + cetane_lower += cetane_gain + diesel_fraction = sum(recipe.values()) + risk_index = ( + sum(recipe[key] * component.risk_index for key, component in components.items()) + / diesel_fraction ) - risk_index = sum(recipe[key] * component.risk_index for key, component in components.items()) cost_proxy = sum( recipe[key] * component.cost_proxy_per_t for key, component in components.items() ) + if scenario.cetane_additive is not None: + cost_proxy += additive_fraction * scenario.cetane_additive.cost_proxy_per_t change_size = sum( abs(recipe[key] - scenario.current_blend_mass_fractions.get(key, 0.0)) for key in recipe - ) - assumptions = tuple(scenario.assumptions) + ( - "Sulfur, T95 and cetane number are mixed by mass fraction for model-demo only.", - "Risk and cost are scenario proxies, not plant safety or currency estimates.", + ) + abs(additive_fraction - scenario.current_additive_mass_fraction) + assumptions = ( + tuple(scenario.assumptions) + + ( + "Sulfur and its scenario upper bound are mixed by mass fraction.", + "T95 and base cetane number are linear scenario approximations over diesel components.", + ( + "The additive has zero modeled sulfur/T95 effect; " + "cetane gain follows the configured curve." + ), + "Risk and cost are scenario proxies, not plant safety or currency estimates.", + ) + + (() if scenario.cetane_additive is None else scenario.cetane_additive.assumptions) ) def estimate( @@ -138,12 +201,13 @@ def estimate( basis: EstimateBasis, reference: str, *, + lower: float | None = None, upper: float | None = None, interval_kind: IntervalKind = IntervalKind.NONE, ) -> MetricEstimate: return MetricEstimate( value=value, - lower=None, + lower=lower, upper=upper, unit=unit, basis=basis, @@ -165,19 +229,29 @@ def estimate( ), "t95": estimate( t95_value, - t95_unit, - t95_basis, - t95_ref, + Unit.CELSIUS.value, + EstimateBasis.FORMULA, + "scenario linear T95 blend assumption", upper=t95_upper, - interval_kind=t95_interval, + interval_kind=( + IntervalKind.SCENARIO_BOUND if t95_upper is not None else IntervalKind.NONE + ), ), "cetane_number": estimate( cetane_value, - cetane_unit, - cetane_basis, - cetane_ref, - upper=cetane_upper, - interval_kind=cetane_interval, + Unit.CETANE.value, + EstimateBasis.FORMULA, + "scenario linear cetane blend and additive response curve", + lower=cetane_lower, + interval_kind=( + IntervalKind.SCENARIO_BOUND if cetane_lower is not None else IntervalKind.NONE + ), + ), + "additive_mass_fraction": estimate( + additive_fraction, + Unit.DIMENSIONLESS.value, + EstimateBasis.FORMULA, + "config scenario cetane_additive", ), "risk_index": estimate( risk_index, @@ -216,40 +290,40 @@ def assess_blend_candidate( sulfur = metrics["sulfur"] t95 = metrics["t95"] cetane = metrics["cetane_number"] - quality_status = AssessmentStatus.OK quality_issues: tuple[Issue, ...] = () - if t95.value is None: + quality_status = AssessmentStatus.OK + if sulfur.value is None: quality_issues += ( Issue( - code="UNASSESSED_REQUIRED_PROPERTY", + code="MISSING_REQUIRED_SIGNAL", severity=Severity.BLOCKING, - signal_id="blend:t95", - detail="T95 is required for a blend decision but has no model-demo value.", + signal_id="blend:sulfur", + detail="Sulfur is missing for a component with a positive mass fraction.", source_ref=f"scenario:{scenario.id}", ), ) - if cetane.value is None: + if t95.value is None or t95.upper is None: quality_issues += ( Issue( code="UNASSESSED_REQUIRED_PROPERTY", severity=Severity.BLOCKING, - signal_id="blend:cetane_number", - detail=( - "Cetane number is required for a blend decision but has no model-demo value." - ), + signal_id="blend:t95", + detail="T95 or its required upper scenario bound is unavailable.", source_ref=f"scenario:{scenario.id}", ), ) - if sulfur.value is None: + if cetane.value is None or cetane.lower is None: quality_issues += ( Issue( - code="MISSING_REQUIRED_SIGNAL", + code="UNASSESSED_REQUIRED_PROPERTY", severity=Severity.BLOCKING, - signal_id="blend:sulfur", - detail="Sulfur is missing for a component with a positive mass fraction.", + signal_id="blend:cetane_number", + detail="Cetane number or its required lower scenario bound is unavailable.", source_ref=f"scenario:{scenario.id}", ), ) + if quality_issues: + quality_status = AssessmentStatus.UNAVAILABLE elif scenario.require_upper_bound and sulfur.upper is None: quality_issues += ( Issue( @@ -293,7 +367,7 @@ def assessment( assessment( AssessmentAgent.OPTIMIZER, AssessmentStatus.OK, - ("throughput", "cost_proxy", "change_size"), + ("throughput", "cost_proxy", "change_size", "additive_mass_fraction"), ), ) diff --git a/source/agents/optimizer.py b/source/agents/optimizer.py index 4d9c56e..60d75ac 100644 --- a/source/agents/optimizer.py +++ b/source/agents/optimizer.py @@ -33,7 +33,7 @@ def generate_candidates( scenario: ScenarioConfig, config: RuntimeConfig | None = None, ) -> tuple[CandidateAction, ...]: - """Generate the deterministic stage-1 grid and an explicit hold candidate.""" + """Generate a deterministic recipe/additive grid and an explicit hold candidate.""" if state.mode is not scenario.mode: raise ValueError("state and scenario modes must match") horizon_minutes = config.horizon_minutes if config is not None else 60 @@ -51,16 +51,30 @@ def generate_candidates( component_a, component_b = (component.id for component in scenario.blend_components) current = scenario.current_blend_mass_fractions - if set(current) != {component_a, component_b} or abs(sum(current.values()) - 1.0) > 1e-9: - raise ValueError("current blend must contain both components and sum to one") - - recipes: list[dict[str, float]] = [] - for step in range(21): - fraction_b = step / 20 - recipe = {component_a: 1.0 - fraction_b, component_b: fraction_b} - if all(abs(recipe[key] - current[key]) <= 1e-9 for key in recipe): - continue - recipes.append(recipe) + if set(current) != {component_a, component_b}: + raise ValueError("current blend must contain both components") + + additive = scenario.cetane_additive + dose_steps = ( + range(round(additive.max_mass_fraction / additive.fraction_step) + 1) + if additive is not None + else range(1) + ) + recipes: list[tuple[dict[str, float], float]] = [] + for dose_step in dose_steps: + dose = 0.0 if additive is None else dose_step * additive.fraction_step + diesel_fraction = 1.0 - dose + for step in range(21): + share_b = step / 20 + recipe = { + component_a: diesel_fraction * (1.0 - share_b), + component_b: diesel_fraction * share_b, + } + if abs(dose - scenario.current_additive_mass_fraction) <= 1e-9 and all( + abs(recipe[key] - current[key]) <= 1e-9 for key in recipe + ): + continue + recipes.append((recipe, dose)) candidate_count = 1 + len(recipes) if candidate_count > max_candidates: @@ -70,13 +84,16 @@ def generate_candidates( ) candidates = [hold] - for recipe in recipes: - fraction_b = recipe[component_b] + for recipe, dose in recipes: candidates.append( CandidateAction( - id=f"blend:{component_a}={recipe[component_a]:.2f},{component_b}={fraction_b:.2f}", + id=( + f"blend:{component_a}={recipe[component_a]:.3f}," + f"{component_b}={recipe[component_b]:.3f},additive={dose:.3f}" + ), kind=CandidateKind.BLEND, blend_mass_fractions=recipe, + additive_mass_fraction=dose, horizon_minutes=horizon_minutes, is_model_scenario=scenario.mode is OperationMode.MODEL_DEMO, ) diff --git a/source/constraints.py b/source/constraints.py index 6c2e504..866b371 100644 --- a/source/constraints.py +++ b/source/constraints.py @@ -54,7 +54,12 @@ def _quality_check( evidence_ref=constraint.evidence_ref, reason_code="UNIT_MISMATCH", ) - actual = metric.upper if constraint.use_upper_estimate else metric.value + if constraint.use_upper_estimate: + actual = metric.upper + elif constraint.use_lower_estimate: + actual = metric.lower + else: + actual = metric.value status = ConstraintStatus.PASS reason = "OK" if actual is None: @@ -83,11 +88,24 @@ def _quality_check( def _stock_checks( scenario: ScenarioConfig, candidate: CandidateAction ) -> tuple[ConstraintResult, ...]: - if candidate.kind is not CandidateKind.BLEND or scenario.total_mass_t is None: + if ( + candidate.kind not in {CandidateKind.HOLD, CandidateKind.BLEND} + or scenario.total_mass_t is None + ): return () by_id = {item.id: item for item in scenario.blend_components} + fractions = ( + scenario.current_blend_mass_fractions + if candidate.kind is CandidateKind.HOLD + else candidate.blend_mass_fractions + ) + additive_fraction = ( + scenario.current_additive_mass_fraction + if candidate.kind is CandidateKind.HOLD + else candidate.additive_mass_fraction + ) checks: list[ConstraintResult] = [] - for component_id, fraction in candidate.blend_mass_fractions.items(): + for component_id, fraction in fractions.items(): component = by_id[component_id] requested = fraction * scenario.total_mass_t status = ( @@ -109,6 +127,33 @@ def _stock_checks( reason_code="OK" if status is ConstraintStatus.PASS else "COMPONENT_STOCK", ) ) + additive = scenario.cetane_additive + if additive is not None: + additive_mass = additive_fraction * scenario.total_mass_t + for constraint_id, actual, upper, reason_code in ( + ( + "additive_fraction", + additive_fraction, + additive.max_mass_fraction, + "ADDITIVE_LIMIT", + ), + ("additive_stock", additive_mass, additive.available_mass_t, "ADDITIVE_STOCK"), + ): + status = ConstraintStatus.PASS if actual <= upper + 1e-9 else ConstraintStatus.FAIL + checks.append( + ConstraintResult( + constraint_id=constraint_id, + candidate_id=candidate.id, + status=status, + actual=actual, + lower=None, + upper=upper, + unit="1" if constraint_id == "additive_fraction" else "t", + basis="model_assumption", + evidence_ref=additive.evidence_ref, + reason_code="OK" if status is ConstraintStatus.PASS else reason_code, + ) + ) return tuple(checks) diff --git a/source/contracts.py b/source/contracts.py index 9090526..4a40594 100644 --- a/source/contracts.py +++ b/source/contracts.py @@ -1,518 +1,624 @@ -"""Strict version-1 contracts shared by backend and ML code. - -These models implement the DTOs from DESIGN.md sections 5 and 6. They reject -unknown fields, naive timestamps and non-finite numbers so invalid data cannot -quietly cross a layer boundary. -""" - -from __future__ import annotations - -from enum import StrEnum -from pathlib import Path -from typing import Annotated, Literal - -from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator - -SCHEMA_VERSION: Literal["1.0"] = "1.0" -FiniteFloat = Annotated[float, Field(allow_inf_nan=False)] -NonNegativeFloat = Annotated[FiniteFloat, Field(ge=0)] -PositiveInt = Annotated[int, Field(gt=0)] - - -class ContractModel(BaseModel): - """Common validation and serialization policy for public contracts.""" - - model_config = ConfigDict( - extra="forbid", frozen=True, protected_namespaces=(), validate_default=True - ) - - -class Severity(StrEnum): - WARNING = "warning" - BLOCKING = "blocking" - - -class Stage(StrEnum): - AVT = "avt" - HYDROTREATMENT = "ht" - BLEND = "blend" - - -class SourceKind(StrEnum): - TELEMETRY = "telemetry" - LIMS = "lims" - PAK = "pak" - VAK = "vak" - SCENARIO = "scenario" - - -class Validity(StrEnum): - VALID = "valid" - MISSING = "missing" - INVALID = "invalid" - CONFLICT = "conflict" - - -class Unit(StrEnum): - """Known canonical units; DTO fields remain strings as required by DESIGN.""" - - CELSIUS = "degC" - DENSITY = "kg/m3" - PERCENT_VOLUME = "vol%" - PERCENT_MASS = "mass%" - MG_KG = "mg/kg" - PPM = "ppm" - CETANE = "cetane_number" - MM2_S = "mm2/s" - KPA = "kPa" - MPA = "MPa" - THOUSAND_M3_H = "1000*m3/h" - M3_H = "m3/h" - T_H = "t/h" - PERCENT = "%" - DIMENSIONLESS = "1" - PROXY = "proxy_unit" - RISK_INDEX = "index_0_1" - UNKNOWN = "unknown" - - -class OperationMode(StrEnum): - HISTORY = "history" - MODEL_DEMO = "model_demo" - HYBRID = "hybrid" - - -class CandidateKind(StrEnum): - HOLD = "hold" - SETPOINTS = "setpoints" - BLEND = "blend" - - -class AssessmentAgent(StrEnum): - QUALITY = "quality" - RELIABILITY = "reliability" - OPTIMIZER = "optimizer" - - -class AssessmentStatus(StrEnum): - OK = "ok" - DEGRADED = "degraded" - UNAVAILABLE = "unavailable" - - -class EstimateBasis(StrEnum): - MEASURED = "measured" - FORECAST = "forecast" - FORMULA = "formula" - PROXY = "proxy" - - -class IntervalKind(StrEnum): - NONE = "none" - EMPIRICAL = "empirical" - SCENARIO_BOUND = "scenario_bound" - - -class ConstraintStatus(StrEnum): - PASS = "pass" - FAIL = "fail" - UNKNOWN = "unknown" - - -class ConstraintBasis(StrEnum): - TERMS_OF_REFERENCE = "tz" - CONFIRMED = "confirmed" - MODEL_ASSUMPTION = "model_assumption" - - -class RecommendationStatus(StrEnum): - RECOMMEND = "recommend" - HOLD = "hold" - ABSTAIN = "abstain" - - -class MappingStatus(StrEnum): - CONFIRMED = "confirmed" - AMBIGUOUS = "ambiguous" - EXCLUDED = "excluded" - - -class Conversion(StrEnum): - NONE = "none" - PPM_MASS_TO_MG_KG = "ppm_mass_to_mg_kg" - MASS_PERCENT_TO_MG_KG = "mass_percent_to_mg_kg" - - -class Issue(ContractModel): - code: str - severity: Severity - signal_id: str | None - detail: str - source_ref: str | None - - -class Observation(ContractModel): - id: str - signal_id: str - stage: Stage - source: SourceKind - measured_at: AwareDatetime - available_at: AwareDatetime - value: FiniteFloat | None - unit: str - validity: Validity - source_ref: str - - @model_validator(mode="after") - def validate_times_and_value(self) -> "Observation": - """Ensure publication time, value presence and validity agree.""" - if self.available_at < self.measured_at: - raise ValueError("available_at cannot precede measured_at") - if self.validity is Validity.VALID and self.value is None: - raise ValueError("valid observation must have a value") - if self.validity is Validity.MISSING and self.value is not None: - raise ValueError("missing observation must not have a value") - return self - - -class SignalSnapshot(ContractModel): - selected: Observation | None - alternatives: tuple[Observation, ...] = () - age_seconds: NonNegativeFloat | None - fresh: bool - issues: tuple[Issue, ...] = () - - @model_validator(mode="after") - def validate_freshness(self) -> "SignalSnapshot": - """Require the selected observation and age whenever data is fresh.""" - if self.fresh and (self.selected is None or self.age_seconds is None): - raise ValueError("fresh snapshot needs a selected observation and age") - return self - - -class ProcessState(ContractModel): - schema_version: Literal["1.0"] = SCHEMA_VERSION - state_id: str - as_of: AwareDatetime - dataset_id: str - mode: OperationMode - signals: dict[str, SignalSnapshot] - issues: tuple[Issue, ...] = () - - -class CandidateAction(ContractModel): - id: str - kind: CandidateKind - setpoints: dict[str, FiniteFloat] = Field(default_factory=dict) - blend_mass_fractions: dict[str, NonNegativeFloat] = Field(default_factory=dict) - horizon_minutes: PositiveInt - is_model_scenario: bool = False - - @model_validator(mode="after") - def validate_payload(self) -> "CandidateAction": - """Check that the candidate payload matches its declared action kind.""" - if self.kind is CandidateKind.HOLD and (self.setpoints or self.blend_mass_fractions): - raise ValueError("hold candidate cannot contain changes") - if self.kind is CandidateKind.SETPOINTS and ( - not self.setpoints or self.blend_mass_fractions - ): - raise ValueError("setpoints candidate needs only setpoints") - if self.kind is CandidateKind.BLEND: - if self.setpoints or not self.blend_mass_fractions: - raise ValueError("blend candidate needs only a complete recipe") - if abs(sum(self.blend_mass_fractions.values()) - 1.0) > 1e-9: - raise ValueError("blend mass fractions must sum to one") - return self - - -class MetricEstimate(ContractModel): - value: FiniteFloat | None - lower: FiniteFloat | None - upper: FiniteFloat | None - unit: str - basis: EstimateBasis - interval_kind: IntervalKind - interval_level: Annotated[FiniteFloat, Field(gt=0, lt=1)] | None - reference: str - assumptions: tuple[str, ...] = () - - @model_validator(mode="after") - def validate_interval(self) -> "MetricEstimate": - """Validate interval ordering and the metadata describing its confidence.""" - if self.lower is not None and self.upper is not None and self.lower > self.upper: - raise ValueError("lower cannot exceed upper") - if self.interval_kind is IntervalKind.EMPIRICAL and self.interval_level is None: - raise ValueError("empirical interval needs interval_level") - if self.interval_kind is not IntervalKind.EMPIRICAL and self.interval_level is not None: - raise ValueError("interval_level is only valid for empirical intervals") - return self - - -class AgentAssessment(ContractModel): - agent: AssessmentAgent - state_id: str - candidate_id: str - evaluated_for: AwareDatetime - status: AssessmentStatus - metrics: dict[str, MetricEstimate] - issues: tuple[Issue, ...] = () - - -class ConstraintResult(ContractModel): - constraint_id: str - candidate_id: str - status: ConstraintStatus - actual: FiniteFloat | None - lower: FiniteFloat | None - upper: FiniteFloat | None - unit: str | None - basis: ConstraintBasis - evidence_ref: str - reason_code: str - - -RankKey = tuple[FiniteFloat, FiniteFloat, FiniteFloat, FiniteFloat, str] - - -class CandidateEvaluation(ContractModel): - candidate: CandidateAction - assessments: tuple[AgentAssessment, ...] = () - checks: tuple[ConstraintResult, ...] = () - feasible: bool - rank_key: RankKey | None - - @model_validator(mode="after") - def validate_consistency(self) -> "CandidateEvaluation": - """Keep candidate identifiers and feasibility status consistent.""" - candidate_id = self.candidate.id - if any(item.candidate_id != candidate_id for item in self.assessments): - raise ValueError("assessment candidate_id mismatch") - if any(item.candidate_id != candidate_id for item in self.checks): - raise ValueError("constraint candidate_id mismatch") - if self.feasible and any(item.status is not ConstraintStatus.PASS for item in self.checks): - raise ValueError("feasible candidate cannot have failed or unknown checks") - if not self.feasible and self.rank_key is not None: - raise ValueError("infeasible candidate cannot have rank_key") - return self - - -class Recommendation(ContractModel): - schema_version: Literal["1.0"] = SCHEMA_VERSION - run_id: str - state_id: str - as_of: AwareDatetime - scenario_id: str - mode: OperationMode - status: RecommendationStatus - baseline: CandidateEvaluation | None - selected: CandidateEvaluation | None - alternatives: Annotated[tuple[CandidateEvaluation, ...], Field(max_length=3)] = () - reason_codes: tuple[str, ...] - explanation: str - assumptions: tuple[str, ...] = () - model_id: str | None - - @model_validator(mode="after") - def validate_result(self) -> "Recommendation": - """Ensure the selected result matches the recommendation status.""" - if self.status is RecommendationStatus.ABSTAIN: - if self.selected is not None or not self.reason_codes: - raise ValueError("abstain needs selected=None and at least one reason") - return self - if self.selected is None or not self.selected.feasible: - raise ValueError("hold/recommend needs a feasible selected candidate") - if self.status is RecommendationStatus.HOLD: - if self.selected.candidate.kind is not CandidateKind.HOLD: - raise ValueError("hold must select a hold candidate") - elif self.selected.candidate.kind is CandidateKind.HOLD: - raise ValueError("recommend must select a non-hold candidate") - return self - - -class RunFailure(ContractModel): - run_id: str - occurred_at: AwareDatetime - error_code: str - stage: str - message: str - - -class ControlSpec(ContractModel): - signal_id: str - unit: str - lower: FiniteFloat | None - upper: FiniteFloat | None - max_step: NonNegativeFloat | None - step: Annotated[FiniteFloat, Field(gt=0)] | None - evidence_ref: str - basis: ConstraintBasis - enabled: bool = False - - @model_validator(mode="after") - def validate_enabled(self) -> "ControlSpec": - """Require complete limits and evidence for an enabled control.""" - fields = (self.lower, self.upper, self.max_step, self.step) - if self.enabled and (any(item is None for item in fields) or not self.evidence_ref): - raise ValueError("enabled control needs limits, step and evidence") - if self.lower is not None and self.upper is not None and self.lower > self.upper: - raise ValueError("control lower cannot exceed upper") - return self - - -class ConstraintSpec(ContractModel): - id: str - metric: str - stage: Stage - lower: FiniteFloat | None - upper: FiniteFloat | None - unit: str - use_upper_estimate: bool - required: bool - basis: ConstraintBasis - evidence_ref: str - - @model_validator(mode="after") - def validate_bounds(self) -> "ConstraintSpec": - """Require evidence and at least one valid lower or upper bound.""" - if self.lower is None and self.upper is None: - raise ValueError("constraint needs at least one bound") - if self.lower is not None and self.upper is not None and self.lower > self.upper: - raise ValueError("constraint lower cannot exceed upper") - if not self.evidence_ref: - raise ValueError("constraint needs evidence_ref") - return self - - -class BlendComponent(ContractModel): - id: str - sulfur: MetricEstimate - t95: MetricEstimate | None = None - cetane_number: MetricEstimate | None = None - available_mass_t: NonNegativeFloat - cost_proxy_per_t: NonNegativeFloat - risk_index: Annotated[FiniteFloat, Field(ge=0, le=1)] - source_state_id: str | None - - -class ScenarioConfig(ContractModel): - id: str - mode: OperationMode - required_signals: tuple[str, ...] = () - controls: tuple[ControlSpec, ...] = () - constraints: tuple[ConstraintSpec, ...] = () - blend_components: tuple[BlendComponent, ...] = () - total_mass_t: NonNegativeFloat | None - current_blend_mass_fractions: dict[str, NonNegativeFloat] = Field(default_factory=dict) - active_criteria: tuple[str, ...] = () - materiality_thresholds: dict[str, NonNegativeFloat] = Field(default_factory=dict) - require_upper_bound: bool = True - action_cooldown_minutes: Annotated[int, Field(ge=0)] = 60 - assumptions: tuple[str, ...] = () - - -class DecisionContext(ContractModel): - last_recommended_at: AwareDatetime | None = None - - -class RuntimeConfig(ContractModel): - source_timezone: str = "Europe/Moscow" - lims_delay_hours: NonNegativeFloat = 4.0 - freshness_minutes: dict[SourceKind, PositiveInt] = Field( - default_factory=lambda: { - SourceKind.TELEMETRY: 20, - SourceKind.PAK: 30, - SourceKind.LIMS: 48 * 60, - } - ) - horizon_minutes: PositiveInt = 60 - seed: int = 42 - max_candidates: PositiveInt = 125 - data_dir: Path = Path("data/processed") - materials_dir: Path = Path("materials") - tag_dictionary_path: Path = Path("config/tags.csv") - models_dir: Path = Path("artifacts/models") - reports_dir: Path = Path("reports") - runs_dir: Path = Path("runs") - - -class TagMeta(ContractModel): - signal_id: str - raw_name: str - stage: Stage - meaning: str - raw_unit: str | None - canonical_unit: str - conversion: Conversion - mapping_status: MappingStatus - controllable: bool - evidence_ref: str - - @model_validator(mode="after") - def validate_mapping(self) -> "TagMeta": - """Prevent unsupported mappings from being treated as controllable.""" - if self.mapping_status is MappingStatus.CONFIRMED and not self.evidence_ref: - raise ValueError("confirmed mapping needs evidence_ref") - if self.controllable and self.mapping_status is not MappingStatus.CONFIRMED: - raise ValueError("controllable signal must have confirmed mapping") - return self - - -class SourceArtifact(ContractModel): - path: str - sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] - size_bytes: Annotated[int, Field(ge=0)] - - -class DatasetManifest(ContractModel): - schema_version: Literal["1.0"] = SCHEMA_VERSION - dataset_id: Annotated[str, Field(min_length=12, max_length=12)] - preparation_version: str - created_at: AwareDatetime - source_timezone: str - assumptions: tuple[str, ...] - sources: tuple[SourceArtifact, ...] - config_sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] - tag_dictionary_sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] - row_counts: dict[str, Annotated[int, Field(ge=0)]] - time_ranges: dict[str, tuple[AwareDatetime, AwareDatetime] | None] - - -__all__ = [ - "SCHEMA_VERSION", - "AgentAssessment", - "AssessmentAgent", - "AssessmentStatus", - "BlendComponent", - "CandidateAction", - "CandidateEvaluation", - "CandidateKind", - "ConstraintBasis", - "ConstraintResult", - "ConstraintSpec", - "ConstraintStatus", - "ControlSpec", - "Conversion", - "DatasetManifest", - "DecisionContext", - "EstimateBasis", - "IntervalKind", - "Issue", - "MappingStatus", - "MetricEstimate", - "Observation", - "OperationMode", - "ProcessState", - "Recommendation", - "RecommendationStatus", - "RunFailure", - "RuntimeConfig", - "ScenarioConfig", - "Severity", - "SignalSnapshot", - "SourceArtifact", - "SourceKind", - "Stage", - "TagMeta", - "Unit", - "Validity", -] +"""Strict version-1 contracts shared by backend and ML code. + +These models implement the DTOs from DESIGN.md sections 5 and 6. They reject +unknown fields, naive timestamps and non-finite numbers so invalid data cannot +quietly cross a layer boundary. +""" + +from __future__ import annotations + +from enum import StrEnum +from pathlib import Path +from typing import Annotated, Literal + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator + +SCHEMA_VERSION: Literal["1.0"] = "1.0" +RECOMMENDATION_SCHEMA_VERSION: Literal["1.1"] = "1.1" +FiniteFloat = Annotated[float, Field(allow_inf_nan=False)] +NonNegativeFloat = Annotated[FiniteFloat, Field(ge=0)] +PositiveInt = Annotated[int, Field(gt=0)] + + +class ContractModel(BaseModel): + """Common validation and serialization policy for public contracts.""" + + model_config = ConfigDict( + extra="forbid", frozen=True, protected_namespaces=(), validate_default=True + ) + + +class Severity(StrEnum): + WARNING = "warning" + BLOCKING = "blocking" + + +class Stage(StrEnum): + AVT = "avt" + HYDROTREATMENT = "ht" + BLEND = "blend" + + +class SourceKind(StrEnum): + TELEMETRY = "telemetry" + LIMS = "lims" + PAK = "pak" + VAK = "vak" + SCENARIO = "scenario" + + +class Validity(StrEnum): + VALID = "valid" + MISSING = "missing" + INVALID = "invalid" + CONFLICT = "conflict" + + +class Unit(StrEnum): + """Known canonical units; DTO fields remain strings as required by DESIGN.""" + + CELSIUS = "degC" + DENSITY = "kg/m3" + PERCENT_VOLUME = "vol%" + PERCENT_MASS = "mass%" + MG_KG = "mg/kg" + PPM = "ppm" + CETANE = "cetane_number" + MM2_S = "mm2/s" + KPA = "kPa" + MPA = "MPa" + THOUSAND_M3_H = "1000*m3/h" + M3_H = "m3/h" + T_H = "t/h" + PERCENT = "%" + DIMENSIONLESS = "1" + PROXY = "proxy_unit" + RISK_INDEX = "index_0_1" + UNKNOWN = "unknown" + + +class OperationMode(StrEnum): + HISTORY = "history" + MODEL_DEMO = "model_demo" + HYBRID = "hybrid" + + +class CandidateKind(StrEnum): + HOLD = "hold" + SETPOINTS = "setpoints" + BLEND = "blend" + + +class AssessmentAgent(StrEnum): + QUALITY = "quality" + RELIABILITY = "reliability" + OPTIMIZER = "optimizer" + + +class AssessmentStatus(StrEnum): + OK = "ok" + DEGRADED = "degraded" + UNAVAILABLE = "unavailable" + + +class EstimateBasis(StrEnum): + MEASURED = "measured" + FORECAST = "forecast" + FORMULA = "formula" + PROXY = "proxy" + + +class IntervalKind(StrEnum): + NONE = "none" + EMPIRICAL = "empirical" + SCENARIO_BOUND = "scenario_bound" + + +class ConstraintStatus(StrEnum): + PASS = "pass" + FAIL = "fail" + UNKNOWN = "unknown" + + +class ConstraintBasis(StrEnum): + TERMS_OF_REFERENCE = "tz" + CONFIRMED = "confirmed" + MODEL_ASSUMPTION = "model_assumption" + + +class RecommendationStatus(StrEnum): + RECOMMEND = "recommend" + HOLD = "hold" + ABSTAIN = "abstain" + + +class MappingStatus(StrEnum): + CONFIRMED = "confirmed" + AMBIGUOUS = "ambiguous" + EXCLUDED = "excluded" + + +class Conversion(StrEnum): + NONE = "none" + PPM_MASS_TO_MG_KG = "ppm_mass_to_mg_kg" + MASS_PERCENT_TO_MG_KG = "mass_percent_to_mg_kg" + + +class Issue(ContractModel): + code: str + severity: Severity + signal_id: str | None + detail: str + source_ref: str | None + + +class Observation(ContractModel): + id: str + signal_id: str + stage: Stage + source: SourceKind + measured_at: AwareDatetime + available_at: AwareDatetime + value: FiniteFloat | None + unit: str + validity: Validity + source_ref: str + + @model_validator(mode="after") + def validate_times_and_value(self) -> "Observation": + """Ensure publication time, value presence and validity agree.""" + if self.available_at < self.measured_at: + raise ValueError("available_at cannot precede measured_at") + if self.validity is Validity.VALID and self.value is None: + raise ValueError("valid observation must have a value") + if self.validity is Validity.MISSING and self.value is not None: + raise ValueError("missing observation must not have a value") + return self + + +class SignalSnapshot(ContractModel): + selected: Observation | None + alternatives: tuple[Observation, ...] = () + age_seconds: NonNegativeFloat | None + fresh: bool + issues: tuple[Issue, ...] = () + + @model_validator(mode="after") + def validate_freshness(self) -> "SignalSnapshot": + """Require the selected observation and age whenever data is fresh.""" + if self.fresh and (self.selected is None or self.age_seconds is None): + raise ValueError("fresh snapshot needs a selected observation and age") + return self + + +class ProcessState(ContractModel): + schema_version: Literal["1.0"] = SCHEMA_VERSION + state_id: str + as_of: AwareDatetime + dataset_id: str + mode: OperationMode + signals: dict[str, SignalSnapshot] + issues: tuple[Issue, ...] = () + + +class CandidateAction(ContractModel): + id: str + kind: CandidateKind + setpoints: dict[str, FiniteFloat] = Field(default_factory=dict) + blend_mass_fractions: dict[str, NonNegativeFloat] = Field(default_factory=dict) + additive_mass_fraction: Annotated[FiniteFloat, Field(ge=0, le=0.03)] = 0.0 + horizon_minutes: PositiveInt + is_model_scenario: bool = False + + @model_validator(mode="after") + def validate_payload(self) -> "CandidateAction": + """Check that the candidate payload matches its declared action kind.""" + if self.kind is CandidateKind.HOLD and ( + self.setpoints or self.blend_mass_fractions or self.additive_mass_fraction + ): + raise ValueError("hold candidate cannot contain changes") + if self.kind is CandidateKind.SETPOINTS and ( + not self.setpoints or self.blend_mass_fractions or self.additive_mass_fraction + ): + raise ValueError("setpoints candidate needs only setpoints") + if self.kind is CandidateKind.BLEND: + if self.setpoints or not self.blend_mass_fractions: + raise ValueError("blend candidate needs only a complete recipe") + if ( + abs(sum(self.blend_mass_fractions.values()) + self.additive_mass_fraction - 1.0) + > 1e-9 + ): + raise ValueError("blend and additive mass fractions must sum to one") + return self + + +class MetricEstimate(ContractModel): + value: FiniteFloat | None + lower: FiniteFloat | None + upper: FiniteFloat | None + unit: str + basis: EstimateBasis + interval_kind: IntervalKind + interval_level: Annotated[FiniteFloat, Field(gt=0, lt=1)] | None + reference: str + assumptions: tuple[str, ...] = () + + @model_validator(mode="after") + def validate_interval(self) -> "MetricEstimate": + """Validate interval ordering and the metadata describing its confidence.""" + if self.lower is not None and self.upper is not None and self.lower > self.upper: + raise ValueError("lower cannot exceed upper") + if self.interval_kind is IntervalKind.EMPIRICAL and self.interval_level is None: + raise ValueError("empirical interval needs interval_level") + if self.interval_kind is not IntervalKind.EMPIRICAL and self.interval_level is not None: + raise ValueError("interval_level is only valid for empirical intervals") + return self + + +class AgentAssessment(ContractModel): + agent: AssessmentAgent + state_id: str + candidate_id: str + evaluated_for: AwareDatetime + status: AssessmentStatus + metrics: dict[str, MetricEstimate] + issues: tuple[Issue, ...] = () + + +class ConstraintResult(ContractModel): + constraint_id: str + candidate_id: str + status: ConstraintStatus + actual: FiniteFloat | None + lower: FiniteFloat | None + upper: FiniteFloat | None + unit: str | None + basis: ConstraintBasis + evidence_ref: str + reason_code: str + + +RankKey = tuple[FiniteFloat, FiniteFloat, FiniteFloat, FiniteFloat, str] + + +class CandidateEvaluation(ContractModel): + candidate: CandidateAction + assessments: tuple[AgentAssessment, ...] = () + checks: tuple[ConstraintResult, ...] = () + feasible: bool + rank_key: RankKey | None + + @model_validator(mode="after") + def validate_consistency(self) -> "CandidateEvaluation": + """Keep candidate identifiers and feasibility status consistent.""" + candidate_id = self.candidate.id + if any(item.candidate_id != candidate_id for item in self.assessments): + raise ValueError("assessment candidate_id mismatch") + if any(item.candidate_id != candidate_id for item in self.checks): + raise ValueError("constraint candidate_id mismatch") + if self.feasible and any(item.status is not ConstraintStatus.PASS for item in self.checks): + raise ValueError("feasible candidate cannot have failed or unknown checks") + if not self.feasible and self.rank_key is not None: + raise ValueError("infeasible candidate cannot have rank_key") + return self + + +class Recommendation(ContractModel): + schema_version: Literal["1.0", "1.1"] = RECOMMENDATION_SCHEMA_VERSION + run_id: str + state_id: str + as_of: AwareDatetime + scenario_id: str + mode: OperationMode + status: RecommendationStatus + baseline: CandidateEvaluation | None + selected: CandidateEvaluation | None + alternatives: Annotated[tuple[CandidateEvaluation, ...], Field(max_length=3)] = () + reason_codes: tuple[str, ...] + explanation: str + assumptions: tuple[str, ...] = () + model_id: str | None + + @model_validator(mode="after") + def validate_result(self) -> "Recommendation": + """Ensure the selected result matches the recommendation status.""" + evaluations = tuple( + item for item in (self.baseline, self.selected, *self.alternatives) if item is not None + ) + if self.schema_version == "1.0" and any( + item.candidate.additive_mass_fraction for item in evaluations + ): + raise ValueError("non-zero additive dose requires recommendation schema 1.1") + if self.status is RecommendationStatus.ABSTAIN: + if self.selected is not None or not self.reason_codes: + raise ValueError("abstain needs selected=None and at least one reason") + return self + if self.selected is None or not self.selected.feasible: + raise ValueError("hold/recommend needs a feasible selected candidate") + if self.status is RecommendationStatus.HOLD: + if self.selected.candidate.kind is not CandidateKind.HOLD: + raise ValueError("hold must select a hold candidate") + elif self.selected.candidate.kind is CandidateKind.HOLD: + raise ValueError("recommend must select a non-hold candidate") + return self + + +class RunFailure(ContractModel): + run_id: str + occurred_at: AwareDatetime + error_code: str + stage: str + message: str + + +class ControlSpec(ContractModel): + signal_id: str + unit: str + lower: FiniteFloat | None + upper: FiniteFloat | None + max_step: NonNegativeFloat | None + step: Annotated[FiniteFloat, Field(gt=0)] | None + evidence_ref: str + basis: ConstraintBasis + enabled: bool = False + + @model_validator(mode="after") + def validate_enabled(self) -> "ControlSpec": + """Require complete limits and evidence for an enabled control.""" + fields = (self.lower, self.upper, self.max_step, self.step) + if self.enabled and (any(item is None for item in fields) or not self.evidence_ref): + raise ValueError("enabled control needs limits, step and evidence") + if self.lower is not None and self.upper is not None and self.lower > self.upper: + raise ValueError("control lower cannot exceed upper") + return self + + +class ConstraintSpec(ContractModel): + id: str + metric: str + stage: Stage + lower: FiniteFloat | None + upper: FiniteFloat | None + unit: str + use_upper_estimate: bool + use_lower_estimate: bool = False + required: bool + basis: ConstraintBasis + evidence_ref: str + + @model_validator(mode="after") + def validate_bounds(self) -> "ConstraintSpec": + """Require evidence and at least one valid lower or upper bound.""" + if self.use_upper_estimate and self.use_lower_estimate: + raise ValueError("constraint cannot use both lower and upper estimates") + if self.lower is None and self.upper is None: + raise ValueError("constraint needs at least one bound") + if self.lower is not None and self.upper is not None and self.lower > self.upper: + raise ValueError("constraint lower cannot exceed upper") + if not self.evidence_ref: + raise ValueError("constraint needs evidence_ref") + return self + + +class BlendComponent(ContractModel): + id: str + sulfur: MetricEstimate + t95: MetricEstimate | None = None + cetane_number: MetricEstimate | None = None + available_mass_t: NonNegativeFloat + cost_proxy_per_t: NonNegativeFloat + risk_index: Annotated[FiniteFloat, Field(ge=0, le=1)] + source_state_id: str | None + + @model_validator(mode="after") + def validate_quality_units(self) -> "BlendComponent": + """Reject component passports with incompatible physical units.""" + if self.sulfur.unit != Unit.MG_KG.value: + raise ValueError("component sulfur must use mg/kg") + if self.t95 is not None and self.t95.unit != Unit.CELSIUS.value: + raise ValueError("component T95 must use degC") + if self.cetane_number is not None and self.cetane_number.unit != Unit.CETANE.value: + raise ValueError("component cetane number must use cetane_number") + return self + + +class AdditiveResponsePoint(ContractModel): + mass_fraction: Annotated[FiniteFloat, Field(ge=0, le=0.03)] + cetane_gain: NonNegativeFloat + + +class CetaneAdditiveSpec(ContractModel): + id: str + max_mass_fraction: Annotated[FiniteFloat, Field(gt=0, le=0.03)] + fraction_step: Annotated[FiniteFloat, Field(gt=0, le=0.03)] + available_mass_t: NonNegativeFloat + cost_proxy_per_t: NonNegativeFloat + response_curve: Annotated[tuple[AdditiveResponsePoint, ...], Field(min_length=2)] + evidence_ref: str + assumptions: tuple[str, ...] = () + + @model_validator(mode="after") + def validate_curve(self) -> "CetaneAdditiveSpec": + """Require a complete monotone scenario curve over the allowed dosage.""" + doses = [point.mass_fraction for point in self.response_curve] + gains = [point.cetane_gain for point in self.response_curve] + if doses[0] != 0 or gains[0] != 0: + raise ValueError("additive response curve must start at zero") + if any(right <= left for left, right in zip(doses, doses[1:], strict=False)): + raise ValueError("additive response doses must be strictly increasing") + if any(right < left for left, right in zip(gains, gains[1:], strict=False)): + raise ValueError("additive cetane gain must be nondecreasing") + if doses[-1] < self.max_mass_fraction: + raise ValueError("additive response curve must cover max_mass_fraction") + steps = round(self.max_mass_fraction / self.fraction_step) + if abs(steps * self.fraction_step - self.max_mass_fraction) > 1e-9: + raise ValueError("additive fraction_step must divide max_mass_fraction") + if not self.evidence_ref: + raise ValueError("additive model needs evidence_ref") + return self + + +class ScenarioConfig(ContractModel): + id: str + mode: OperationMode + required_signals: tuple[str, ...] = () + controls: tuple[ControlSpec, ...] = () + constraints: tuple[ConstraintSpec, ...] = () + blend_components: tuple[BlendComponent, ...] = () + cetane_additive: CetaneAdditiveSpec | None = None + total_mass_t: NonNegativeFloat | None + current_blend_mass_fractions: dict[str, NonNegativeFloat] = Field(default_factory=dict) + current_additive_mass_fraction: Annotated[FiniteFloat, Field(ge=0, le=0.03)] = 0.0 + active_criteria: tuple[str, ...] = () + materiality_thresholds: dict[str, NonNegativeFloat] = Field(default_factory=dict) + require_upper_bound: bool = True + action_cooldown_minutes: Annotated[int, Field(ge=0)] = 60 + assumptions: tuple[str, ...] = () + + @model_validator(mode="after") + def validate_blend(self) -> "ScenarioConfig": + """Keep the current product recipe and additive model internally consistent.""" + if not self.blend_components: + return self + component_ids = {component.id for component in self.blend_components} + if len(component_ids) != len(self.blend_components): + raise ValueError("blend component ids must be unique") + if set(self.current_blend_mass_fractions) != component_ids: + raise ValueError("current blend must contain every component exactly once") + total = ( + sum(self.current_blend_mass_fractions.values()) + self.current_additive_mass_fraction + ) + if abs(total - 1.0) > 1e-9: + raise ValueError("current blend and additive mass fractions must sum to one") + if self.current_additive_mass_fraction: + if self.cetane_additive is None: + raise ValueError("current additive dose needs a cetane additive model") + if self.current_additive_mass_fraction > self.cetane_additive.max_mass_fraction: + raise ValueError("current additive dose exceeds the configured maximum") + if self.mode is OperationMode.MODEL_DEMO: + required = { + constraint.metric: constraint + for constraint in self.constraints + if constraint.required + } + missing = {"sulfur", "t95", "cetane_number"}.difference(required) + if missing: + raise ValueError(f"model-demo blend lacks required constraints: {sorted(missing)}") + if not required["sulfur"].use_upper_estimate: + raise ValueError("model-demo sulfur constraint must use the upper estimate") + if not required["t95"].use_upper_estimate: + raise ValueError("model-demo T95 constraint must use the upper estimate") + if not required["cetane_number"].use_lower_estimate: + raise ValueError("model-demo cetane constraint must use the lower estimate") + return self + + +class DecisionContext(ContractModel): + last_recommended_at: AwareDatetime | None = None + + +class RuntimeConfig(ContractModel): + source_timezone: str = "Europe/Moscow" + lims_delay_hours: NonNegativeFloat = 4.0 + freshness_minutes: dict[SourceKind, PositiveInt] = Field( + default_factory=lambda: { + SourceKind.TELEMETRY: 20, + SourceKind.PAK: 30, + SourceKind.LIMS: 48 * 60, + } + ) + horizon_minutes: PositiveInt = 60 + seed: int = 42 + max_candidates: PositiveInt = 125 + data_dir: Path = Path("data/processed") + materials_dir: Path = Path("materials") + tag_dictionary_path: Path = Path("config/tags.csv") + models_dir: Path = Path("artifacts/models") + reports_dir: Path = Path("reports") + runs_dir: Path = Path("runs") + + +class TagMeta(ContractModel): + signal_id: str + raw_name: str + stage: Stage + meaning: str + raw_unit: str | None + canonical_unit: str + conversion: Conversion + mapping_status: MappingStatus + controllable: bool + evidence_ref: str + + @model_validator(mode="after") + def validate_mapping(self) -> "TagMeta": + """Prevent unsupported mappings from being treated as controllable.""" + if self.mapping_status is MappingStatus.CONFIRMED and not self.evidence_ref: + raise ValueError("confirmed mapping needs evidence_ref") + if self.controllable and self.mapping_status is not MappingStatus.CONFIRMED: + raise ValueError("controllable signal must have confirmed mapping") + return self + + +class SourceArtifact(ContractModel): + path: str + sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] + size_bytes: Annotated[int, Field(ge=0)] + + +class DatasetManifest(ContractModel): + schema_version: Literal["1.0"] = SCHEMA_VERSION + dataset_id: Annotated[str, Field(min_length=12, max_length=12)] + preparation_version: str + created_at: AwareDatetime + source_timezone: str + assumptions: tuple[str, ...] + sources: tuple[SourceArtifact, ...] + config_sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] + tag_dictionary_sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] + row_counts: dict[str, Annotated[int, Field(ge=0)]] + time_ranges: dict[str, tuple[AwareDatetime, AwareDatetime] | None] + + +__all__ = [ + "SCHEMA_VERSION", + "AgentAssessment", + "AssessmentAgent", + "AssessmentStatus", + "AdditiveResponsePoint", + "BlendComponent", + "CandidateAction", + "CandidateEvaluation", + "CandidateKind", + "ConstraintBasis", + "ConstraintResult", + "ConstraintSpec", + "ConstraintStatus", + "ControlSpec", + "CetaneAdditiveSpec", + "Conversion", + "DatasetManifest", + "DecisionContext", + "EstimateBasis", + "IntervalKind", + "Issue", + "MappingStatus", + "MetricEstimate", + "Observation", + "OperationMode", + "ProcessState", + "Recommendation", + "RECOMMENDATION_SCHEMA_VERSION", + "RecommendationStatus", + "RunFailure", + "RuntimeConfig", + "ScenarioConfig", + "Severity", + "SignalSnapshot", + "SourceArtifact", + "SourceKind", + "Stage", + "TagMeta", + "Unit", + "Validity", +] diff --git a/source/data/prepare.py b/source/data/prepare.py index 333de8f..460737b 100644 --- a/source/data/prepare.py +++ b/source/data/prepare.py @@ -6,6 +6,7 @@ import json import shutil import subprocess +import tarfile import tempfile from dataclasses import dataclass from datetime import UTC, datetime @@ -146,6 +147,26 @@ def _source_artifact(path: Path, root: Path) -> SourceArtifact: def _extract_telemetry(archive: Path, destination: Path) -> Path: """Validate archive members before extraction to a temporary directory.""" + try: + with tarfile.open(archive) as stream: + members = { + member.name.replace("\\", "/").lstrip("./").rstrip("/") + for member in stream.getmembers() + } + if members != _ARCHIVE_MEMBERS | {"data"}: + raise ValueError(f"unexpected archive members: {sorted(members)}") + destination_root = destination.resolve() + for member in stream.getmembers(): + target = (destination / member.name).resolve() + try: + target.relative_to(destination_root) + except ValueError as exc: + raise ValueError(f"unsafe archive member: {member.name}") from exc + stream.extractall(destination) + return destination / "data" + except tarfile.TarError: + pass + listing = subprocess.run( ["tar", "-tf", str(archive)], check=True, capture_output=True, text=True ).stdout.splitlines() diff --git a/source/explain.py b/source/explain.py index 079598a..eb46e8f 100644 --- a/source/explain.py +++ b/source/explain.py @@ -5,18 +5,28 @@ from source.contracts import CandidateEvaluation, RecommendationStatus, ScenarioConfig -def _sulfur_text(evaluation: CandidateEvaluation | None) -> str: +def _quality_text(evaluation: CandidateEvaluation | None) -> str: if evaluation is None: - return "сера не рассчитана" + return "паспорт качества не рассчитан" + metrics = {} for assessment in evaluation.assessments: - sulfur = assessment.metrics.get("sulfur") - if sulfur is not None: - if sulfur.value is None: - return "сера не рассчитана" - if sulfur.upper is None: - return f"сера {sulfur.value:.3g} {sulfur.unit}, верхняя оценка недоступна" - return f"сера {sulfur.value:.3g} {sulfur.unit}, верхняя оценка {sulfur.upper:.3g}" - return "сера не рассчитана" + metrics.update(assessment.metrics) + sulfur = metrics.get("sulfur") + t95 = metrics.get("t95") + cetane = metrics.get("cetane_number") + return ", ".join( + ( + "сера недоступна" + if sulfur is None or sulfur.upper is None + else f"сера upper {sulfur.upper:.3g} {sulfur.unit}", + "T95 недоступен" + if t95 is None or t95.upper is None + else f"T95 upper {t95.upper:.3g} {t95.unit}", + "цетановое число недоступно" + if cetane is None or cetane.lower is None + else f"цетановое lower {cetane.lower:.3g}", + ) + ) def _checks_text(evaluation: CandidateEvaluation | None) -> str: @@ -56,15 +66,15 @@ def build_explanation( if status is RecommendationStatus.HOLD: suffix = f" Причины выбора: {', '.join(reason_codes)}." if reason_codes else "" return ( - f"Рекомендуется сохранить режим: {_sulfur_text(selected)}. " + f"Рекомендуется сохранить режим: {_quality_text(selected)}. " f"Проверки: {_checks_text(selected)}.{suffix}" ) action = selected.candidate.id if selected else "unknown" return ( f"Рекомендуется вариант {action}: текущий режим не проходит ограничения " - f"({_sulfur_text(baseline)}; проверки: {_checks_text(baseline)}), " + f"({_quality_text(baseline)}; проверки: {_checks_text(baseline)}), " f"выбранный вариант проходит " - f"({_sulfur_text(selected)}; проверки: {_checks_text(selected)})." + f"({_quality_text(selected)}; проверки: {_checks_text(selected)})." ) diff --git a/source/main.py b/source/main.py index b2173af..39b92de 100644 --- a/source/main.py +++ b/source/main.py @@ -28,13 +28,28 @@ SplitName = Literal["train", "validation", "test"] TRAINING_TELEMETRY_SIGNALS = ("ht:P8", "ht:T11", "ht:F19") +MODEL_DEMO_SCENARIOS = ( + "blend_normal", + "blend_risk", + "blend_t95_risk", + "blend_cetane_risk", + "blend_missing", +) PROJECT_ROOT = Path(__file__).resolve().parent.parent -MODEL_DEMO_SCENARIOS: tuple[str, ...] = ("blend_normal", "blend_risk", "blend_missing") +MODEL_DEMO_SCENARIOS: tuple[str, ...] = ( + "blend_normal", + "blend_risk", + "blend_t95_risk", + "blend_cetane_risk", + "blend_missing", +) STAGE6_SCENARIOS = MODEL_DEMO_SCENARIOS STAGE6_EXPECTED_STATUSES: dict[str, str] = { - "blend_normal": "abstain", - "blend_risk": "abstain", + "blend_normal": "hold", + "blend_risk": "recommend", + "blend_t95_risk": "recommend", + "blend_cetane_risk": "recommend", "blend_missing": "abstain", } STAGE6_JOURNAL_FILES: tuple[str, ...] = ( @@ -431,9 +446,16 @@ def main(argv: Sequence[str] | None = None) -> int: subparsers = parser.add_subparsers(dest="command", required=True) subparsers.add_parser("validate-stage0", help="validate contracts, configs and fixtures") demo = subparsers.add_parser("run-model-demo", help="run a stage-1 model-demo scenario") - demo.add_argument("scenario", choices=MODEL_DEMO_SCENARIOS, help="scenario id from config") + demo.add_argument( + "scenario", + choices=MODEL_DEMO_SCENARIOS, + help="scenario id from config/scenarios", + ) demo_alias = subparsers.add_parser("demo", help="run a deterministic model-demo episode") - demo_alias.add_argument("scenario", choices=MODEL_DEMO_SCENARIOS) + demo_alias.add_argument( + "scenario", + choices=MODEL_DEMO_SCENARIOS, + ) prepare = subparsers.add_parser( "prepare", help="prepare original materials into data/processed" ) diff --git a/source/ml/blending.py b/source/ml/blending.py index 8f0291d..c3f8cd8 100644 --- a/source/ml/blending.py +++ b/source/ml/blending.py @@ -8,6 +8,7 @@ from source.contracts import ( BlendComponent, + CetaneAdditiveSpec, ConstraintStatus, EstimateBasis, IntervalKind, @@ -52,7 +53,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True) class BlendResult: - """Partial blend result: core demo properties are assessed, not the full passport.""" + """Synthetic product-passport result with explicit scenario bounds.""" sulfur: MetricEstimate t95: MetricEstimate @@ -60,7 +61,7 @@ class BlendResult: stock_shortfalls_t: tuple[tuple[str, float], ...] checked_properties: tuple[str, ...] unassessed_properties: tuple[str, ...] - full_specification_status: Literal["not_assessed"] + full_specification_status: Literal["assessed", "not_assessed"] assumptions: tuple[str, ...] @property @@ -161,8 +162,11 @@ def calculate_mass_blend( mass_fractions: dict[str, float], components: tuple[BlendComponent, ...], total_mass_t: float, + *, + additive_mass_fraction: float = 0.0, + additive: CetaneAdditiveSpec | None = None, ) -> BlendResult: - """Calculate sulfur by mass and preserve missing values instead of imputing zero.""" + """Calculate the declared synthetic blend model without imputing missing quality.""" if total_mass_t <= 0: raise ValueError("total_mass_t must be positive") if not components: @@ -175,15 +179,18 @@ def calculate_mass_blend( raise ValueError("mass fractions must contain every component exactly once") if any(weight < 0 for weight in mass_fractions.values()): raise ValueError("mass fractions must be nonnegative") - if abs(sum(mass_fractions.values()) - 1.0) > FRACTION_TOLERANCE: - raise ValueError("mass fractions must sum to one") - if {component.sulfur.unit for component in components} != {Unit.MG_KG.value}: - raise ValueError("all component sulfur estimates must use mg/kg") + if abs(sum(mass_fractions.values()) + additive_mass_fraction - 1.0) > FRACTION_TOLERANCE: + raise ValueError("mass fractions and additive must sum to one") + if additive_mass_fraction and additive is None: + raise ValueError("additive dose needs an additive model") positive_ids = [key for key, weight in mass_fractions.items() if weight > 0] def weighted( - metric: Literal["sulfur", "t95", "cetane_number"], field: Literal["value", "upper"] + metric: Literal["sulfur", "t95", "cetane_number"], + field: Literal["value", "lower", "upper"], + *, + normalize: bool = False, ) -> float | None: total = 0.0 for key in positive_ids: @@ -192,72 +199,110 @@ def weighted( if value is None: return None total += mass_fractions[key] * value - return total - - def metric_estimate(metric: Literal["sulfur", "t95", "cetane_number"]) -> MetricEstimate: - estimates = [getattr(component_map[key], metric) for key in positive_ids] - present = [item for item in estimates if item is not None] - if len(present) != len(estimates): - return MetricEstimate( - value=None, - lower=None, - upper=None, - unit=Unit.UNKNOWN.value, - basis=EstimateBasis.FORMULA, - interval_kind=IntervalKind.NONE, - interval_level=None, - reference="DESIGN.md#9", - assumptions=assumptions, - ) - units = {item.unit for item in present} - if len(units) != 1: - raise ValueError(f"all component {metric} estimates must use one unit") - upper = weighted(metric, "upper") - return MetricEstimate( - value=weighted(metric, "value"), - lower=None, - upper=upper, - unit=present[0].unit, - basis=EstimateBasis.FORMULA, - interval_kind=IntervalKind.SCENARIO_BOUND if upper is not None else IntervalKind.NONE, - interval_level=None, - reference="DESIGN.md#9", - assumptions=assumptions - + tuple( - assumption - for component in components - for estimate in (getattr(component, metric),) - if estimate is not None - for assumption in estimate.assumptions - ), - ) - + return total / sum(mass_fractions.values()) if normalize else total + + sulfur_value = weighted("sulfur", "value") + sulfur_upper = weighted("sulfur", "upper") + t95_value = weighted("t95", "value", normalize=True) + t95_upper = weighted("t95", "upper", normalize=True) + cetane_value = weighted("cetane_number", "value", normalize=True) + cetane_lower = weighted("cetane_number", "lower", normalize=True) + cetane_gain = _additive_gain(additive, additive_mass_fraction) + if cetane_value is not None: + cetane_value += cetane_gain + if cetane_lower is not None: + cetane_lower += cetane_gain shortfalls = tuple( (component.id, mass_fractions[component.id] * total_mass_t - component.available_mass_t) for component in components if mass_fractions[component.id] * total_mass_t > component.available_mass_t + FRACTION_TOLERANCE ) + if ( + additive is not None + and additive_mass_fraction * total_mass_t > additive.available_mass_t + FRACTION_TOLERANCE + ): + shortfalls += ( + ( + additive.id, + additive_mass_fraction * total_mass_t - additive.available_mass_t, + ), + ) assumptions = ( - "Sulfur, T95, cetane number and their upper bounds are mixed by mass fraction.", - "The weighted upper bounds are conservative scenario bounds without joint coverage claims.", - "Only the model-demo product properties and component stock are assessed.", + "Sulfur and its upper bound are mixed by mass fraction.", + "The weighted upper bound is conservative and has no joint coverage claim.", + "T95 and base cetane number are explicit linear scenario approximations.", + "Additive has zero modeled sulfur/T95 effect and follows its scenario cetane curve.", + ) + sulfur = MetricEstimate( + value=sulfur_value, + lower=None, + upper=sulfur_upper, + unit=Unit.MG_KG.value, + basis=EstimateBasis.FORMULA, + interval_kind=( + IntervalKind.SCENARIO_BOUND if sulfur_upper is not None else IntervalKind.NONE + ), + interval_level=None, + reference="DESIGN.md#9", + assumptions=assumptions + + tuple( + assumption for component in components for assumption in component.sulfur.assumptions + ), + ) + t95 = MetricEstimate( + value=t95_value, + lower=None, + upper=t95_upper, + unit=Unit.CELSIUS.value, + basis=EstimateBasis.FORMULA, + interval_kind=IntervalKind.SCENARIO_BOUND if t95_upper is not None else IntervalKind.NONE, + interval_level=None, + reference="scenario linear T95 blend assumption", + assumptions=assumptions, + ) + cetane = MetricEstimate( + value=cetane_value, + lower=cetane_lower, + upper=None, + unit=Unit.CETANE.value, + basis=EstimateBasis.FORMULA, + interval_kind=( + IntervalKind.SCENARIO_BOUND if cetane_lower is not None else IntervalKind.NONE + ), + interval_level=None, + reference="scenario linear cetane blend and additive response curve", + assumptions=assumptions, + ) + missing = tuple( + name + for name, value in (("t95", t95.upper), ("cetane_number", cetane.lower)) + if value is None ) - sulfur = metric_estimate("sulfur") - t95 = metric_estimate("t95") - cetane_number = metric_estimate("cetane_number") return BlendResult( sulfur=sulfur, t95=t95, - cetane_number=cetane_number, + cetane_number=cetane, stock_shortfalls_t=shortfalls, checked_properties=("sulfur", "t95", "cetane_number", "component_stock"), - unassessed_properties=("full_product_passport",), - full_specification_status="not_assessed", + unassessed_properties=missing, + full_specification_status="not_assessed" if missing else "assessed", assumptions=assumptions, ) +def _additive_gain(additive: CetaneAdditiveSpec | None, dose: float) -> float: + if dose == 0: + return 0.0 + if additive is None or dose > additive.max_mass_fraction + FRACTION_TOLERANCE: + raise ValueError("additive dose is outside the configured model") + for left, right in zip(additive.response_curve, additive.response_curve[1:], strict=False): + if left.mass_fraction <= dose <= right.mass_fraction: + share = (dose - left.mass_fraction) / (right.mass_fraction - left.mass_fraction) + return left.cetane_gain + share * (right.cetane_gain - left.cetane_gain) + raise ValueError("additive response curve does not cover dose") + + def sulfur_constraint_status( result: BlendResult, limit_mg_kg: float, *, require_upper: bool = True ) -> ConstraintStatus: @@ -308,30 +353,55 @@ def rank_feasible_blends( current_fractions: dict[str, float], *, sulfur_upper_limit: float = 10.0, + t95_upper_limit: float = 360.0, + cetane_lower_limit: float = 51.0, + additive_mass_fraction: float = 0.0, + additive: CetaneAdditiveSpec | None = None, ) -> tuple[BlendOption, ...]: - """Filter quality/stock failures and rank the remaining hybrid recipes.""" + """Filter the complete scenario passport and rank remaining recipes.""" component_map = {component.id: component for component in components} if set(current_fractions) != set(component_map): raise ValueError("current fractions must contain every component") options: list[BlendOption] = [] for recipe in recipes: - result = calculate_mass_blend(recipe, components, total_mass_t) + final_recipe = { + key: value * (1.0 - additive_mass_fraction) for key, value in recipe.items() + } + result = calculate_mass_blend( + final_recipe, + components, + total_mass_t, + additive_mass_fraction=additive_mass_fraction, + additive=additive, + ) if sulfur_constraint_status(result, sulfur_upper_limit) is not ConstraintStatus.PASS: continue - recipe_id = "blend:" + ",".join(f"{key}={recipe[key]:.2f}" for key in sorted(recipe)) + if ( + result.t95.upper is None + or result.t95.upper > t95_upper_limit + or result.cetane_number.lower is None + or result.cetane_number.lower < cetane_lower_limit + ): + continue + recipe_id = "blend:" + ",".join( + f"{key}={final_recipe[key]:.3f}" for key in sorted(final_recipe) + ) options.append( BlendOption( recipe_id=recipe_id, - mass_fractions=recipe, + mass_fractions=final_recipe, result=result, risk_index=sum( - recipe[key] * component_map[key].risk_index for key in component_map + final_recipe[key] * component_map[key].risk_index for key in component_map ), throughput=total_mass_t, cost_proxy=sum( - recipe[key] * component_map[key].cost_proxy_per_t for key in component_map + final_recipe[key] * component_map[key].cost_proxy_per_t for key in component_map + ) + + (0.0 if additive is None else additive_mass_fraction * additive.cost_proxy_per_t), + change_size=sum( + abs(final_recipe[key] - current_fractions[key]) for key in component_map ), - change_size=sum(abs(recipe[key] - current_fractions[key]) for key in component_map), ) ) return tuple(sorted(options, key=lambda option: option.rank_key)) diff --git a/source/orchestrator.py b/source/orchestrator.py index a787fb6..ded8266 100644 --- a/source/orchestrator.py +++ b/source/orchestrator.py @@ -39,26 +39,39 @@ def _model_demo_state(as_of: datetime, scenario: ScenarioConfig) -> ProcessState issues: list[Issue] = [] signals: dict[str, SignalSnapshot] = {} for component in scenario.blend_components: - if component.sulfur.value is not None and ( - not scenario.require_upper_bound or component.sulfur.upper is not None - ): - continue - signal_id = f"blend:{component.id}:sulfur" - issue = Issue( - code="MISSING_REQUIRED_SIGNAL", - severity=Severity.BLOCKING, - signal_id=signal_id, - detail=f"component {component.id} has no complete sulfur estimate", - source_ref=component.sulfur.reference, - ) - issues.append(issue) - signals[signal_id] = SignalSnapshot( - selected=None, - alternatives=(), - age_seconds=None, - fresh=False, - issues=(issue,), + properties = ( + ("sulfur", component.sulfur, "upper", "MISSING_REQUIRED_SIGNAL"), + ("t95", component.t95, "upper", "UNASSESSED_REQUIRED_PROPERTY"), + ( + "cetane_number", + component.cetane_number, + "lower", + "UNASSESSED_REQUIRED_PROPERTY", + ), ) + for name, estimate, bound, reason_code in properties: + if ( + estimate is not None + and estimate.value is not None + and getattr(estimate, bound) is not None + ): + continue + signal_id = f"blend:{component.id}:{name}" + issue = Issue( + code=reason_code, + severity=Severity.BLOCKING, + signal_id=signal_id, + detail=f"component {component.id} has no complete {name} estimate", + source_ref=f"scenario:{scenario.id}", + ) + issues.append(issue) + signals[signal_id] = SignalSnapshot( + selected=None, + alternatives=(), + age_seconds=None, + fresh=False, + issues=(issue,), + ) payload = { "schema_version": "1.0", "as_of": as_of.isoformat(), diff --git a/source/ui.py b/source/ui.py index 34dffce..6d52ce2 100644 --- a/source/ui.py +++ b/source/ui.py @@ -1,7 +1,7 @@ """Tkinter desktop interface for the currently implemented recommendation system. The UI deliberately exposes model-demo calculations as model scenarios. It -does not imply that real setpoints, T95 or cetane-number models are available. +does not imply that real setpoint or product-quality action models are available. """ from __future__ import annotations @@ -52,6 +52,8 @@ SCENARIO_LABELS = { "Нормальный режим": "blend_normal", "Повышенная сера": "blend_risk", + "Риск T95": "blend_t95_risk", + "Низкое цетановое число": "blend_cetane_risk", "Недостающие данные": "blend_missing", } @@ -75,24 +77,32 @@ class DashboardView: action_title: str action_detail: str baseline_value: float | None + baseline_t95_upper: float | None + baseline_cetane_lower: float | None baseline_upper: float | None selected_value: float | None + selected_t95_upper: float | None + selected_cetane_lower: float | None selected_upper: float | None current_fractions: dict[str, float] proposed_fractions: dict[str, float] + current_additive_fraction: float + proposed_additive_fraction: float constraints: tuple[ConstraintRow, ...] explanation: str reasons: tuple[str, ...] -def _metric(evaluation: CandidateEvaluation | None, name: str) -> tuple[float | None, float | None]: +def _metric( + evaluation: CandidateEvaluation | None, name: str +) -> tuple[float | None, float | None, float | None]: if evaluation is None: - return None, None + return None, None, None for assessment in evaluation.assessments: estimate = assessment.metrics.get(name) if estimate is not None: - return estimate.value, estimate.upper - return None, None + return estimate.value, estimate.lower, estimate.upper + return None, None, None def _limit_text(lower: float | None, upper: float | None, unit: str | None) -> str: @@ -107,7 +117,13 @@ def _limit_text(lower: float | None, upper: float | None, unit: str | None) -> s def _display_unit(unit: str | None) -> str: - return {"mg/kg": "мг/кг", "t": "т"}.get(unit or "", unit or "") + return { + "mg/kg": "мг/кг", + "degC": "°C", + "cetane_number": "ед.", + "t": "т", + "1": "доля", + }.get(unit or "", unit or "") def _constraint_rows(result: Recommendation) -> tuple[ConstraintRow, ...]: @@ -118,6 +134,14 @@ def _constraint_rows(result: Recommendation) -> tuple[ConstraintRow, ...]: name = "Запас " + check.constraint_id.rsplit(":", 1)[-1] elif check.constraint_id == "blend_sulfur": name = "Сера" + elif check.constraint_id == "blend_t95": + name = "T95" + elif check.constraint_id == "blend_cetane": + name = "Цетановое число" + elif check.constraint_id == "additive_fraction": + name = "Доля присадки" + elif check.constraint_id == "additive_stock": + name = "Запас присадки" else: name = check.constraint_id status = { @@ -130,40 +154,47 @@ def _constraint_rows(result: Recommendation) -> tuple[ConstraintRow, ...]: ConstraintStatus.FAIL: "bad", ConstraintStatus.UNKNOWN: "unknown", }[check.status] - actual = ( - "—" if check.actual is None else f"{check.actual:g} {_display_unit(check.unit)}".strip() - ) + if check.constraint_id == "additive_fraction": + actual = "—" if check.actual is None else f"{check.actual * 100:g} %" + limit = _limit_text( + None if check.lower is None else check.lower * 100, + None if check.upper is None else check.upper * 100, + "%", + ) + else: + actual = ( + "—" + if check.actual is None + else f"{check.actual:g} {_display_unit(check.unit)}".strip() + ) + limit = _limit_text(check.lower, check.upper, check.unit) rows.append( ConstraintRow( name, actual, - _limit_text(check.lower, check.upper, check.unit), + limit, status, tone, ) ) - metric_labels = (("t95", "T95"), ("cetane_number", "Цетановое число")) - for metric_name, label in metric_labels: - value, upper = _metric(evaluation, metric_name) - if value is None: - rows.append(ConstraintRow(label, "—", "Модельное значение", "Не оценено", "unknown")) - continue - unit = "°C" if metric_name == "t95" else "" - actual = f"{value:g} {unit}".strip() - if upper is not None and upper != value: - actual = f"{actual} / upper {upper:g} {unit}".strip() - rows.append(ConstraintRow(label, actual, "Модельное значение", "Оценено", "ok")) return tuple(rows) def recommendation_to_view(result: Recommendation, scenario: ScenarioConfig) -> DashboardView: """Translate strict backend output into honest operator-facing content.""" - baseline_value, baseline_upper = _metric(result.baseline, "sulfur") - selected_value, selected_upper = _metric(result.selected, "sulfur") + baseline_value, _, baseline_upper = _metric(result.baseline, "sulfur") + _, _, baseline_t95_upper = _metric(result.baseline, "t95") + _, baseline_cetane_lower, _ = _metric(result.baseline, "cetane_number") + selected_value, _, selected_upper = _metric(result.selected, "sulfur") + _, _, selected_t95_upper = _metric(result.selected, "t95") + _, selected_cetane_lower, _ = _metric(result.selected, "cetane_number") current = dict(scenario.current_blend_mass_fractions) proposed = dict(current) + current_additive = scenario.current_additive_mass_fraction + proposed_additive = current_additive if result.selected is not None and result.selected.candidate.blend_mass_fractions: proposed = dict(result.selected.candidate.blend_mass_fractions) + proposed_additive = result.selected.candidate.additive_mass_fraction if result.status is RecommendationStatus.RECOMMEND: changed = [key for key in proposed if proposed.get(key, 0.0) > current.get(key, 0.0) + 1e-9] @@ -171,7 +202,7 @@ def recommendation_to_view(result: Recommendation, scenario: ScenarioConfig) -> banner_title = "Доступен модельный вариант" banner_detail = "Расчёт относится только к синтетическому сценарию блендинга." action_title = f"Увеличить долю компонента {component}" - action_detail = "Текущая рецептура нарушает ограничение по сере." + action_detail = "Текущая рецептура нарушает одно или несколько ограничений качества." elif result.status is RecommendationStatus.HOLD: banner_title = "Изменение режима не требуется" banner_detail = "Текущая модельная рецептура проходит доступные проверки." @@ -179,7 +210,7 @@ def recommendation_to_view(result: Recommendation, scenario: ScenarioConfig) -> action_detail = "Материального улучшения относительно hold не найдено." else: banner_title = "Рекомендация недоступна" - banner_detail = "Обязательные данные или верхняя оценка качества отсутствуют." + banner_detail = "Обязательные данные или консервативная оценка качества отсутствуют." action_title = "Требуется ручная проверка" action_detail = "Система не подставляет неизвестные значения и не выбирает действие." @@ -190,11 +221,17 @@ def recommendation_to_view(result: Recommendation, scenario: ScenarioConfig) -> action_title=action_title, action_detail=action_detail, baseline_value=baseline_value, + baseline_t95_upper=baseline_t95_upper, + baseline_cetane_lower=baseline_cetane_lower, baseline_upper=baseline_upper, selected_value=selected_value, + selected_t95_upper=selected_t95_upper, + selected_cetane_lower=selected_cetane_lower, selected_upper=selected_upper, current_fractions=current, proposed_fractions=proposed, + current_additive_fraction=current_additive, + proposed_additive_fraction=proposed_additive, constraints=_constraint_rows(result), explanation=result.explanation, reasons=result.reason_codes, @@ -530,9 +567,15 @@ def _render_kpis(self, parent: tk.Frame, view: DashboardView | None) -> None: strip = tk.Frame(parent, bg=SURFACE) strip.pack(fill="x", padx=26, pady=(18, 12)) values = ( - ("Текущая смесь", _format_value(view.baseline_value) if view else "—"), - ("Выбранный вариант +60 мин", _format_value(view.selected_value) if view else "—"), - ("Верхняя оценка", _format_value(view.selected_upper) if view else "—"), + ("Сера · верхняя", _format_value(view.selected_upper) if view else "—"), + ( + "T95 · верхняя", + _format_value(view.selected_t95_upper, "°C") if view else "—", + ), + ( + "Цетановое · нижняя", + _format_value(view.selected_cetane_lower, "ед.") if view else "—", + ), ) for index, (label, value) in enumerate(values): cell = tk.Frame(strip, bg=SURFACE) @@ -773,6 +816,16 @@ def _render_recommendation(self) -> None: tk.Frame(right, bg=BORDER, height=1).pack(fill="x", padx=20, pady=18) self._info_row(right, "Текущая", _format_value(view.baseline_upper if view else None)) self._info_row(right, "Предел сценария", "≤ 10 мг/кг") + self._info_row( + right, + "T95 · верхняя", + _format_value(view.selected_t95_upper if view else None, "°C"), + ) + self._info_row( + right, + "Цетановое · нижняя", + _format_value(view.selected_cetane_lower if view else None, "ед."), + ) checks = self._surface(page) checks.pack(fill="x", pady=(16, 0)) @@ -821,7 +874,7 @@ def _recipe_table(self, parent: tk.Frame, view: DashboardView | None) -> None: parent, columns=("component", "current", "proposed"), show="headings", - height=max(2, len(view.current_fractions) if view else 2), + height=max(3, len(view.current_fractions) + 1 if view else 3), style="Petrol.Treeview", ) for key, title, width in ( @@ -842,6 +895,15 @@ def _recipe_table(self, parent: tk.Frame, view: DashboardView | None) -> None: f"{view.proposed_fractions.get(component, 0.0) * 100:.0f} %", ), ) + table.insert( + "", + "end", + values=( + "Цетаноповышающая присадка", + f"{view.current_additive_fraction * 100:.1f} %", + f"{view.proposed_additive_fraction * 100:.1f} %", + ), + ) table.pack(fill="x") @staticmethod @@ -1083,7 +1145,7 @@ def _alternatives_content(self, parent: tk.Frame) -> None: return lines = [] for alternative in self._result.alternatives: - value, upper = _metric(alternative, "sulfur") + value, _, upper = _metric(alternative, "sulfur") lines.append( f"{alternative.candidate.id}: point {_format_value(value)}, " f"upper {_format_value(upper)}" From 1ce74a5d052b56923033ab3b235d5b707c5e95cd Mon Sep 17 00:00:00 2001 From: bug00n Date: Mon, 21 Sep 2026 19:47:09 +0300 Subject: [PATCH 05/10] fix --- DESIGN.md | 29 ++++++++++++++++ IMPLEMENTATION_PLAN.md | 15 +++++++++ QA_CLARIFICATIONS.md | 76 ++++++++++++++++++++++++++++++++++++++++++ README.md | 1 + materials/README.md | 4 +++ 5 files changed, 125 insertions(+) create mode 100644 QA_CLARIFICATIONS.md diff --git a/DESIGN.md b/DESIGN.md index 4745367..d6090c6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -24,6 +24,11 @@ training/evaluation/replay. Исторический ML-артефакт ещё | Объяснение | Шаблон из численных результатов и причин проверок; не влияет на решение | | Управление установкой | Только рекомендации оператору; команд исполнительным устройствам нет | +Q&A 11.09 подтверждает локальный запуск без внешнего интернета и допустимость +обоснованных инженерных моделей без обязательной LLM. Данные, зависимости и модели +готовятся до переноса в закрытую среду; наличие внутренней LLM не является обещанием +ресурсов команде. Основания и открытые вопросы: [разбор встречи](QA_CLARIFICATIONS.md). + В версии 1 не вводятся HTTP API, брокер сообщений, микросервисы, СУБД, оркестратор LLM, обучение в интерфейсе или собственный фреймворк агентов. Новые зависимости: `openpyxl` для Excel; `tzdata` для часовых поясов на Windows, где системная база IANA может отсутствовать ([документация Python](https://docs.python.org/3.11/library/zoneinfo.html)). Остальной обязательный стек уже указан в репозитории. ### Три режима работы @@ -185,6 +190,11 @@ Backend не дублирует признаки в UI; ML не пишет ал ### Временные правила +Точки ЛИМС АВТ — разные физические места/стадии, не варианты времени одного анализа +(Q&A 11.09, 00:09:33). Общая подпись «Дизельное топливо» не разрешает объединять точки. +Схема АВТ не подтверждает теги `ht:*`; точная карта потоков и имя `Pipeline` остаются +открытыми вопросами [Q1–Q3](QA_CLARIFICATIONS.md#незакрытые-вопросы-и-границы-использования). + Внутри приложения — timezone-aware UTC. Исходные даты без timezone интерпретируются согласно `source_timezone`. `Europe/Moscow` по умолчанию — **экспериментальное допущение**, не установленное свойство пакета; записывается в manifest. UI показывает часовой пояс рядом со временем. - `measured_at` — время измерения/отбора пробы. @@ -390,6 +400,13 @@ sequenceDiagram Для `supports_actions=true` по реальным уставкам нужны: подтверждённый смысл и управляемость, описанные пределы шага, проверка совместной области применимости, отдельная временная оценка на эпизодах изменений, описанные задержки и ограничения причинной интерпретации. ML сохраняет ссылку на этот отчёт в metadata. До этого реальные controls отключены. +Эксперт подтвердил наличие обратной связи на АВТ и 24-2000 (Q&A 11.09, 00:16:15). +Поэтому рост температуры после роста серы может отражать реакцию управления: +историческая зависимость не задаёт причинный коэффициент. Числа из вопросов +участников, включая 0.03 мг/кг на градус и отклик около часа, не подтверждены. +Для action model ML отдельно обосновывает отделение воздействия от реакции +управления; при недостаточных данных способность к рекомендациям не включается. + За пределами области применимости модель не экстраполирует. Результат — `unavailable` с причиной. Сравнение прогнозов на истории не выдаётся за доказанный производственный эффект вмешательства. ### Надёжность @@ -568,6 +585,11 @@ CI работает на малых синтетических fixtures без Перед финальной сдачей: запустить временную оценку на реальных данных, проверить минимум нормальный эпизод, риск качества и неполные/аномальные данные; дополнительно показать полный агентный цикл модельной оптимизации. Искусственно повреждённый эпизод явно подписать. В отчёте отдельно указать ошибки прогноза на истории и условный эффект в модельной среде. +Q&A 11.09 добавляет явную приёмочную проверку: на заранее подготовленном окружении +выполнить расчёт без внешней сети, с локальными зависимостями, данными и артефактами. +Backend фиксирует команду и фактический исход; ML — идентификатор модели и границы +применимости. Это требование к проверке, не утверждение, что она уже выполнена. + Целевой бюджет после загрузки данных: один цикл с максимум 126 кандидатами до 5 секунд на рабочем CPU-ноутбуке; это инженерная цель, не измеренный результат. В отчёте указать CPU, объём памяти, число кандидатов и фактическое время. ## 14. Работа двух исполнителей @@ -661,6 +683,13 @@ Backend → ML: подготовленные таблицы, manifest, отчё Основание этого раздела — ответы составителей задания в рабочем чате, переданные команде 10.09.2026. Они заменяют противоречащие им начальные допущения выше. +**Оговорка после Q&A 11.09:** в [вопросах участников](QA_CLARIFICATIONS.md) оспорены +физические соответствия `P8/T11/F19` и полнота проверки ВАК. Нового содержательного +ответа в предоставленной записи нет. Утверждения ниже сохраняются как история +прежнего подтверждения, но не закрывают противоречие по этим тегам. Их нельзя +переставлять по диапазонам значений или допускать к реальному управлению без +контрольного примера с единицами; зависимые физические интерпретации требуют проверки. + - Короткие имена колонок `avt_tags.csv` и `242000_tags.csv` напрямую соответствуют листу «КИП» в `Теги_хакатон.xlsx`; дополнительное масштабирование значений не требуется. Это подтверждает идентичность тегов, но не создаёт отсутствующие в материалах технологические пределы. - Строка единиц в ЛИМС содержит ошибки. Каноническая единица определяется подтверждённым смыслом показателя: температуры кипения — `degC`, `D15` — `kg/m3`, `I250/I350` — `vol%`. Исходный ошибочный заголовок сохраняется в provenance. - Время ЛИМС — момент отбора пробы; публикация занимает до 4 часов. Все источники используют один общий часовой пояс. До получения точного IANA-идентификатора сохраняется настроенный `Europe/Moscow`, явно записываемый в manifest. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index ffcdc6a..aa06cc0 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -203,6 +203,21 @@ ML предоставляет `predict_quality`, `assess_reliability` и `evalua ## 4. Обязательные ограничения и проверки +Уточнения встречи 11.09 и незакрытые вопросы собраны в [QA_CLARIFICATIONS.md](QA_CLARIFICATIONS.md). +Они дополняют этапы выше, не подтверждают новые возможности runtime. + +| Приоритет после Q&A | Backend | ML | Критерий | +| --- | --- | --- | --- | +| Проверить семантику входов | Сохранять происхождение и исполнять запреты для неподтверждённых действий | Разрешить противоречия `P8/T11/F19`, `F25`, `Pipeline`, карты точек и формул на контрольных примерах | Связанные единицы/формулы подтверждены; отсутствие ответа явно остаётся блокером | +| Уточнить применимость прогноза | Показать причины отказа и воспроизвести согласованные эпизоды | Проверить режимы и задержки с учётом обратной связи; не выводить причинный эффект из корреляции | Общие входы/выходы воспроизводимы; прогноз и основание для действий разделены | +| Подготовить защиту | Проверить расчёт без внешней сети на подготовленном окружении; описать взаимодействия компонентов | Представить метрики, ограничения и условность модельного эффекта | Есть фактический результат офлайн-проверки и описание архитектуры | + +Обоснованные инженерные модели разрешены; LLM не обязательна. Реальные коэффициенты +воздействий, паспортные пределы, межустановочная задержка и экономическая функция +по вопросам без ответов не назначаются. Пуск/останов вне проверенной области +применимости требует предупреждения/отказа от совета; конкретный порог режима ещё +нужно обосновать. Подробная передача между двумя исполнителями — в разборе Q&A. + ### Правила для всех этапов - Сера товарной смеси — не более **10 мг/кг**; остальные ограничения берутся из подтверждённой спецификации выбранного сценария. diff --git a/QA_CLARIFICATIONS.md b/QA_CLARIFICATIONS.md new file mode 100644 index 0000000..f79344f --- /dev/null +++ b/QA_CLARIFICATIONS.md @@ -0,0 +1,76 @@ +# Q&A Нефтекод: уточнения для реализации + +Встреча: **11.09.2026**. Разбор предоставленных файлов: **14.09.2026**. +Документ дополняет [дизайн](DESIGN.md) и [план](IMPLEMENTATION_PLAN.md). +Он фиксирует свидетельства и решения команды, а не завершение реализации. + +## Источники и достоверность + +- **Т** — `Транскрипция_Запись_11_09_Q&A_Сессия_Нефтекод.txt`, заголовок датирует запись 11 сентября 2026 года; ссылки ниже — таймкоды записи. +- **Ч** — `13.09 вопросы из чата.docx`; содержит вопросы участников с временем сообщений, **без письменных ответов экспертов**. Дата имени файла не доказывает отдельную встречу 13 сентября. Ссылки ниже — время сообщения и его тема. + +Исходники переданы отдельно от репозитория. SHA-256 для идентификации версии: + +```text +Т: 551e09d02d3c9fe42350513766adb06add3f4b66a92e8678a9f051c3b6af6d4f +Ч: 7caa8ee4ee16dc8eaa57169178a00b936fc9f678d7bd1d70d7d54d1675ffec57 +``` + +В транскрипции многие ответы Виталия Пампуры представлены одиночными словами +или отсутствуют. Реплика ведущей «спасибо за ответ» не восстанавливает содержание. +Числа и гипотезы из вопросов не считаются экспертным подтверждением или нашими +проверенными метриками. Прежние ответы от 10.09 сохраняются с оговорками ниже; +неразборчивая запись не заменяет их новой трактовкой. + +## Что подтверждено + +| Уточнение | Основание | Следствие для проекта | +| --- | --- | --- | +| Производственная технологическая сеть не связана с внешним интернетом; решение должно работать локально | Т 00:03:44, Александр Кызродев | Обязательный расчётный путь работает офлайн. Зависимости, данные и модели готовятся до переноса в закрытую среду. | +| LLM и ML не обязательны в каждом компоненте; обоснованные инженерные, физические и химические модели допустимы | Т 00:23:41, Александр Шишкин; общее отсутствие ограничений по подходам — 00:03:44 | Сохраняем численные агенты и детерминированный оркестратор. Формулы допустимы при подтверждённых входах и области применения. | +| Во внутренней сети есть локальные LLM; назван ориентир до примерно 30 млрд параметров | Т 00:04:38, 00:07:09, Александр Шишкин | Это возможная будущая интеграция, не обязательная зависимость. Команде не обещаны конкретные GPU, доступ или скорость. Названия моделей и точный протокол API в записи неразборчивы. | +| Важны архитектурная грамотность и запуск в закрытой среде | Т 00:13:00, Александр Шишкин | На защите нужны поток данных, взаимодействие ролей, ограничения и воспроизводимость наряду с метриками. Веса критериев не сообщены в доступном тексте. | +| Точки ЛИМС АВТ относятся к разным местам/стадиям процесса, а не к разным временным меткам | Т 00:09:02–00:09:33, Александр Шишкин | Сохраняем идентичность точки отбора; не объединяем анализы только по названию продукта. Точная привязка всех номеров к потокам ещё требует схемы/таблицы. | +| Теги 24-2000 не относятся к схеме АВТ | Т 00:11:16, Александр Шишкин | Пространства `avt:*` и `ht:*` остаются раздельными; по схеме АВТ нельзя подтверждать смысл тега гидроочистки. | +| На АВТ и 24-2000 работают системы управления с обратной связью, быстро реагирующие на изменения качества/режима | Т 00:16:15, Александр Шишкин | **Вывод команды:** историческая корреляция может отражать реакцию управления. Обычный прогноз и доказательство эффекта вмешательства остаются разными задачами. Численная скорость реакции не подтверждена. | +| Для гидроочистки описаны точки отбора до и после установки | Т 00:22:34, Александр Шишкин | Различаем сырьё и продукт. Общий ответ не доказывает точное соответствие имени `Pipeline` номеру точки. | + +Реплика Т 00:21:14, вероятно, предлагает расширять набор ВАК по лабораторным +и технологическим данным, но термин распознан как «баг». Это направление для +уточнения, а не подтверждение конкретных формул или доступности новых целей. + +## Незакрытые вопросы и границы использования + +| ID | Вопрос и свидетельство | Что требуется; поведение до ответа | +| --- | --- | --- | +| Q1 | АВТ: схема с позициями, точная карта ЛИМС 1/2/2.1/3, соответствия тегов аппаратам и потокам. Ч 16:08, вопросы 1–2; 16:09 | Нужна согласованная карта «установка → тег/точка → параметр → единица → аппарат/поток». Общее объяснение точек не подтверждает три предложенных участниками AVT-control. | +| Q2 | `ht:P8`, `ht:T11`, `ht:F19`: спор с прежним ответом. Участник сообщает диапазоны 0.12–0.22, 349–379, 147–248, не похожие на ранее названные величины. Ч 16:08, вопрос 3; 16:19 | Нужен контрольный timestamp со значениями, единицами и подтверждённым смыслом каждой колонки. Это сообщение о противоречии, не доказанная перестановка тегов. Реальное управление отключено; зависимые формулы и интерпретации требуют проверки. | +| Q3 | Непроверенные ВАК, контрольный пример; `F25` с заявленной участником медианой около 13091; отсутствующее имя `Pipeline` в формуле T95. Ч 16:08, вопросы 4–5; 16:22 | Нужны проверенные формулы, единицы и пример входов/выхода, точка для `Pipeline`. Не исправлять масштаб `F25` или привязку `Pipeline` догадкой; для `F25` сначала установить пространство имён. Ранее принятые исправления не означают валидацию всего набора ВАК. | +| Q4 | Температура → сера: участники приводят 0.03 мг/кг на 1 °C, сравнивают с 5–10%, предполагают отклик около часа и реакцию оператора за полчаса. Ч 16:11, 16:14; Т 00:13:20–00:16:15 | Нужны данные о динамике и воздействиях с учётом обратной связи. Ни один коэффициент или точная задержка не подтверждены записью; не переносить их в action model. | +| Q5 | Возможный промежуточный резервуар между АВТ и гидроочисткой; отсутствие отклика на лагах 0–12 ч по анализу участника. Ч 16:17 | Нужны схема маршрута, наличие смешения/парка, объём и время пребывания. Наличие резервуара не установлено; автоматический перенос влияния между установками не обоснован. | +| Q6 | Паспортные пределы оборудования, допустимые диапазоны и шаги уставок. Ч 16:08, вопрос 3; 16:27; Т 00:25:13 | Нужен письменный перечень пределов и единиц. Ответ в транскрипции не сохранился; исторические квантили не становятся паспортными ограничениями. | +| Q7 | Более частые измерения серы сырья и испытания катализатора; участник сообщает 132 пробы и неустойчивую кинетическую оценку. Ч 16:27 | Нужны доступные измерения/испытания и проверка идентифицируемости модели. Количество проб и оценка энергии активации — данные вопроса, не результат нашего анализа. Размножение редких проб не создаёт новые обучающие цели. | +| Q8 | Что происходит с некондиционной партией, цена ошибки, потери выпуска и избыточной очистки. Ч 16:08, вопрос 6; 16:30 | Нужны подтверждённые сценарии обращения с партией и сопоставимые затраты. Пересказ участником слов эксперта не задаёт экономическую функцию. Сохраняем прозрачные прокси и жёсткие ограничения; цель «вести серу вплотную к 10» не вводится. | +| Q9 | Конкретный стандарт качества и обязательность дашборда. Т 00:04:53–00:05:39; Ч 16:30, вопрос об интерфейсе | Содержательные ответы отсутствуют. Сохраняются требования ТЗ, предел серы 10 мг/кг и текущий UI; нельзя объявлять остальные показатели необязательными или подтверждать полный ГОСТ по этой записи. | +| Q10 | Поведение при останове/пуске; участник оценивает простой в 3–4% и падение температуры почти до нуля. Ч 16:33 | Нужны подтверждённые признаки режимов и требования к ним. **Решение команды:** без проверенной применимости — предупреждение/отказ от совета, не «нарушений нет». Эти проценты и температурный порог не становятся правилами детектора. | + +## Работа backend и ML после встречи + +Приоритеты уточняют существующие этапы; новая инфраструктура ради Q&A не вводится. + +| Очерёдность | ML передаёт | Backend обеспечивает | Условие завершения | +| --- | --- | --- | --- | +| 1. Источники и семантика — Q1–Q3, Q6 | Для каждого спорного входа: источник, проверенное значение/единица, статус противоречия, зависимые признаки/формулы | Сохранение provenance, блокирование неподтверждённых действий, совместимость словаря и артефактов | Оба воспроизводят контрольный пример; неподтверждённые соответствия не получают статус подтверждённых по догадке | +| 2. Прогноз и режимы — Q4, Q5, Q7, Q10 | Отчёт по временным срезам, пуску/останову и отклику; отдельно гипотезы, прогнозная точность и основания для действий | Отображение применимости/причин отказа и воспроизводимый replay тех же эпизодов | Совпадают входы и результаты на общих timestamp; ограничения явно видны, `supports_actions` не включается от улучшения MAE | +| 3. Защита и офлайн-поставка | Метрики, ограничения данных/модели, условность модельного эффекта | Подготовленные зависимости/артефакты, инструкция локального запуска и схема взаимодействий | На заранее подготовленном окружении расчётный путь проходит без внешней сети; фактический результат проверки фиксируется при её выполнении | +| 4. Только после получения подтверждений — Q4–Q9 | Проверенная модель действий, инженерные пределы, спецификация и экономика | Согласованное подключение через существующие контракты и единый фильтр | Есть отдельные свидетельства применимости действий; прогнозная точность сама по себе этап не закрывает | + +Статус открытого вопроса меняется только со ссылкой на содержательный ответ или +воспроизводимую проверку. ML отвечает за смысл, backend — за исполнение правила; +изменения общих файлов передаются по протоколу [DESIGN.md, раздел 14](DESIGN.md#14-работа-двух-исполнителей). + +## Организационное + +- На отборочном этапе допустимо описание решения вместо презентации; для финала презентация обязательна. Т 00:11:41. Точное название формата файла в записи искажено; требований к объёму, шаблону и roadmap здесь нет. +- Проезд и проживание не оплачиваются; при невозможности приехать предусмотрена онлайн-защита. Т 00:16:47. +- Организаторы обещали дополнить ответы в чате. Т 00:10:58, 00:20:01, 00:34:46–00:35:51. В переданных файлах этих письменных ответов нет. diff --git a/README.md b/README.md index d076c34..32cc57c 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ - [Актуальные проблемы]() - найденные ошибки, пробелы проверок, ограничения демо и статус исправлений. - [План реализации](IMPLEMENTATION_PLAN.md) — этапы, сроки и разделение работы между backend и ML. - [Технический дизайн](DESIGN.md) — архитектура, структура файлов, типы данных, взаимодействие компонентов и проверки. +- [Уточнения Q&A 11.09](QA_CLARIFICATIONS.md) — подтверждённые ответы, открытые вопросы и последствия для backend/ML. - [Гайд по проекту](PROJECT_GUIDE.md) — объяснение с нуля: суть ТЗ, роли backend/ML, объекты, этапы и рабочий процесс. - [Stage 1](STAGE1.md) — первый сквозной backend-цикл, demo-сценарии, журнал и ограничения этапа. - [Stage 2](STAGE2.md) — как оригинальные материалы превращаются в prepared dataset и `ProcessState`. diff --git a/materials/README.md b/materials/README.md index 448f054..f8d2fbb 100644 --- a/materials/README.md +++ b/materials/README.md @@ -2,6 +2,10 @@ Пакет включает техническое задание, схемы, справочник тегов, анализы качества и архив технологической телеметрии. + +[Разбор Q&A 11.09.2026](../QA_CLARIFICATIONS.md) дополняет материалы: содержит +таймкоды ответов, открытые вопросы и идентификаторы двух отдельно переданных +исходников. DOCX с вопросами не является письменными ответами экспертов. | Файл | Содержание | | --- | --- | From ef0fbdc90c0997159aafbdfe547a71131d69c63c Mon Sep 17 00:00:00 2001 From: bug00n Date: Mon, 21 Sep 2026 19:49:37 +0300 Subject: [PATCH 06/10] fix --- DESIGN.md | 253 ++++++++ README.md | 27 +- STAGE7.md | 58 ++ STAGE8.md | 79 +++ STAGE9.md | 96 +++ global_tests/test_ml_diagnostics.py | 115 ++++ global_tests/test_ml_v2.py | 161 +++++ global_tests/test_safety_forecast.py | 269 +++++++++ global_tests/test_stage3_controls.py | 60 +- requirements.lock.txt | 1 + requirements.txt | 5 +- source/agents/quality.py | 84 ++- source/main.py | 105 ++++ source/ml/__init__.py | 26 + source/ml/action_effects.py | 24 +- source/ml/artifacts.py | 107 +++- source/ml/controls.py | 107 +++- source/ml/diagnostics.py | 325 ++++++++++ source/ml/safety.py | 762 +++++++++++++++++++++++ source/ml/v2.py | 873 +++++++++++++++++++++++++++ 20 files changed, 3504 insertions(+), 33 deletions(-) create mode 100644 STAGE8.md create mode 100644 STAGE9.md create mode 100644 global_tests/test_ml_diagnostics.py create mode 100644 global_tests/test_ml_v2.py create mode 100644 global_tests/test_safety_forecast.py create mode 100644 source/ml/diagnostics.py create mode 100644 source/ml/safety.py create mode 100644 source/ml/v2.py diff --git a/DESIGN.md b/DESIGN.md index d6090c6..89a486d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,8 +1,13 @@ # Технический дизайн системы рекомендаций Нефтекода Версия контракта: **1.0**. Статус: **частично реализованная спецификация**. +<<<<<<< HEAD Документ содержит целевую архитектуру. Актуальный runtime: Stages 0–6, полный +======= + +Документ содержит целевую архитектуру. Актуальный runtime: Stages 0–8, полный +>>>>>>> f0ad14f (Complete ML stages and safety diagnostics) синтетический паспорт S/T95/CN с присадкой в Tkinter UI и CLI для frozen training/evaluation/replay. Исторический ML-артефакт ещё не подключён к UI. @@ -274,6 +279,7 @@ Backend не дублирует признаки в UI; ML не пишет ал | `DecisionContext` | `last_recommended_at: Timestamp \| None`; время последней выданной рекомендации для подавления повторов | Численные технологические границы без источника не имеют значения по умолчанию. Тогда `enabled=false`. Активное ограничение без предела, единицы или обоснования — ошибка конфигурации до запуска цикла. В `model_demo` обязательны верхние оценки серы и T95 и нижняя оценка цетанового числа. Сера имеет `upper=10 mg/kg` из ТЗ; T95/CN и их границы явно помечены `model_assumption`. +<<<<<<< HEAD Доли считаются равными 1 при абсолютной погрешности не более `1e-9`. Этот допуск используется только для арифметики долей, не для разрешения серы выше 10 мг/кг. Сравнение качества выполняется до округления UI. @@ -400,6 +406,149 @@ sequenceDiagram Для `supports_actions=true` по реальным уставкам нужны: подтверждённый смысл и управляемость, описанные пределы шага, проверка совместной области применимости, отдельная временная оценка на эпизодах изменений, описанные задержки и ограничения причинной интерпретации. ML сохраняет ссылку на этот отчёт в metadata. До этого реальные controls отключены. +======= + +Доли считаются равными 1 при абсолютной погрешности не более `1e-9`. Этот допуск используется только для арифметики долей, не для разрешения серы выше 10 мг/кг. Сравнение качества выполняется до округления UI. + +### Вызываемые интерфейсы + +`PreparedData` и `ModelBundle` — обычные локальные dataclass-контейнеры, а не сетевые DTO. `FeatureFrame` — `pandas.DataFrame` с фиксированным порядком признаков. + +```text +prepare_dataset(materials_dir: Path, config: RuntimeConfig) -> PreparedData +build_state(data: PreparedData, as_of: datetime, + scenario: ScenarioConfig, config: RuntimeConfig) -> ProcessState +build_features(data: PreparedData, as_of: datetime, + state: ProcessState, model: ModelBundle) -> FeatureFrame +predict_quality(state: ProcessState, features: FeatureFrame, + model: ModelBundle | None, scenario: ScenarioConfig) -> AgentAssessment +assess_reliability(state: ProcessState, + scenario: ScenarioConfig) -> AgentAssessment +generate_candidates(state: ProcessState, + scenario: ScenarioConfig) -> list[CandidateAction] +evaluate_candidates(state: ProcessState, features: FeatureFrame, + candidates: list[CandidateAction], model: ModelBundle | None, + scenario: ScenarioConfig) -> list[CandidateEvaluation] +check_constraints(state: ProcessState, candidate: CandidateAction, + assessments: list[AgentAssessment], + scenario: ScenarioConfig) -> list[ConstraintResult] +run_cycle(data: PreparedData, as_of: datetime, model: ModelBundle | None, + scenario: ScenarioConfig, config: RuntimeConfig, + context: DecisionContext, run_dir: Path) -> Recommendation +``` + +`predict_quality` и `assess_reliability` оценивают исходное состояние с `candidate_id='hold'`. `effects.py` оценивает действие в отдельном прогнозном состоянии/оценках; исходный объект не изменяется. Данные, рассчитанные моделью, получают соответствующий `basis`, а не `measured`. + +В `model_demo` без ML-модели `build_features` не вызывается: передаётся пустой `FeatureFrame`, а качество считается из компонентов сценария. Baseline на истории оформляется как `ModelBundle` с явным типом predictor; отсутствие необходимой модели не переключает режим автоматически. + +`PreparedData` хранит отсортированную телеметрию, длинную таблицу качества, issues и manifest. `ModelBundle` хранит predictor, порядок признаков, preprocessing, метаданные и при наличии модель последствий. Backend не должен знать внутреннюю структуру sklearn estimator. + +## 7. Один цикл принятия решения + +```mermaid +sequenceDiagram + participant UI as UI или CLI + participant O as Оркестратор + participant D as Данные и признаки + participant Q as Качество + participant R as Надёжность + participant P as Оптимизатор + participant C as Ограничения + participant J as Журнал + UI->>O: run_cycle(t, scenario, model) + O->>D: build_state и build_features только до t + D-->>O: состояние и признаки + O->>Q: predict_quality + Q-->>O: качество, доверие, причины + O->>R: assess_reliability + R-->>O: индекс и факторы + alt Обязательных данных или модели последствий нет + O->>J: оценки и abstain с причиной + else Оценка действий доступна + O->>P: кандидаты, включая hold + loop Каждый кандидат + P->>Q: прогноз качества последствий + P->>R: оценка риска последствий + P->>C: check_constraints + C-->>P: pass, fail или unknown + end + P-->>O: оценки и ранжирование + O->>C: повторная проверка выбранного кандидата + O->>J: входы, все оценки, решение + end + O-->>UI: Recommendation и объяснение +``` + +Порядок внутри `run_cycle`: + +1. Проверить совместимость config/scenario/model и создать `run_id`. +2. Построить состояние и признаки. Сохранить применённые допущения и идентификаторы выбранных наблюдений. +3. Получить оценки качества и надёжности. Если роль недоступна, сохранить доступные результаты остальных. +4. При блокирующих входах вернуть `abstain`; не генерировать видимость оптимизации. +5. Создать `hold` и разрешённые кандидаты. Предварительно отсеять нарушение формата, управления и рецептуры. +6. Рассчитать последствия, затем выполнить полный `check_constraints`. Никакие баллы не используются до этого фильтра. +7. Выбрать лучший допустимый вариант по правилу ниже. При отсутствии вариантов — `abstain`. +8. Повторно проверить выбранный кандидат тем же кодом ограничений. Расхождение результатов — `RunFailure`, а не автоматический переход к другому ответу. +9. Построить объяснение исключительно из сохранённых оценок; записать журнал; вернуть результат UI/CLI. + +### Правило выбора + +Ключ сортировки: `(risk_index, -throughput, cost_proxy, change_size, candidate_id)`, по возрастанию. Последний элемент разрешает равенство. Индексы/затраты сравниваются только внутри одного сценария с одинаковыми определениями. Если критерий не обеспечен данными, его **заранее** отключают в конфигурации для всех кандидатов с нейтральным местом в ключе; это отображается в UI. Пропуск у отдельного кандидата по активному критерию делает его оценку недоступной. + +`change_size` для уставок — сумма `abs(new-current)/step`; для рецептуры — сумма абсолютных изменений массовых долей. + +Если `hold` допустим, изменение выдаётся только при существенном улучшении первого отличающегося активного критерия. Начальные экспериментальные пороги: риск 0.02 абсолютных пункта, выпуск 2%, затраты 2%. При сравнении около нулевой базы используются обязательные абсолютные допуски сценария. Разница только в `change_size` не является улучшением процесса. Иначе возвращается `hold`. + +Если `hold` недопустим, порог экономической существенности не мешает выбрать допустимое действие. Начальный cooldown — 60 минут: он подавляет повторную выдачу изменений только при допустимом `hold`. При нарушении качества система повторно предупреждает и оценивает варианты независимо от cooldown. Совет не означает его выполнение: состояние меняется только по новым данным/явно запущенной симуляции. + +`DecisionContext` относится к одному сценарию и последовательному воспроизведению. При смене сценария или переходе назад по времени он сбрасывается; иначе обновляется только после `recommend`. Контекст сохраняется в журнале и учитывается при сравнении повторных запусков. + +## 8. ML, неопределённость и модель последствий + +### Признаки и обучение + +- Первая цель — сера на выходе гидроочистки через 60 минут. Эта точка не отождествляется автоматически с товарной смесью. +- Телеметрические лаги первой версии: 10, 30, 60 и 180 минут; прошлые средние и стандартные отклонения в окнах 60 и 180 минут. Окно заканчивается на `as_of`, будущие значения не участвуют. +- Исходные признаки берутся только из подтверждённого словаря. Сера анализаторов и её лаги явно маркируются как autoregressive-признаки; тот же будущий целевой анализ не может попасть в входы. +- Признаки, отсутствующие во всём обучающем периоде, исключаются. Появление ПАК-плотности в 2025 году не позволяет использовать её как обученный признак модели 2023–2024 без отдельного эксперимента. +- Imputer/scaler и отбор признаков обучаются только на train и сохраняются вместе с моделью. Для Ridge — медианная импутация и масштабирование; для HGB — фиксированная обработка пропусков. Строки без цели не становятся обучающими примерами. +- Для ПАК цель выбирается на сетке `t+60 минут`; без точного допустимого наблюдения пример исключается; цель не переносится из будущего. Для ЛИМС создаётся один пример на реальный анализ: `as_of = measured_at - 60 минут`. +- ПАК и ЛИМС не объединяются в одну безымянную цель. Модель ПАК оценивается по ПАК и отдельно по контрольным ЛИМС; перенос источника отражается в отчёте. +- Начальное разделение: train до 2025-01-01; validation до 2026-01-01; test с 2026-01-01 по конец доступной телеметрии. Границы задаются в исходном часовом поясе и преобразуются в UTC. +- Внутри train — три последовательных временных блока для подбора. Цели, пересекающие границу следующего блока, исключаются. Внутреннее early stopping у HGB отключается (`early_stopping=False`); число итераций выбирается временной валидацией, без автоматической внутренней проверочной выборки ([описание параметров](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.HistGradientBoostingRegressor.html)). +- Метрики: MAE, MAE в области 8–12 мг/кг, пропущенные превышения 10 мг/кг, ложные тревоги; для каждого показателя число примеров. Если превышений нет, соответствующая доля — `null` с пояснением. +- Выбор модели выполняется до test: минимизировать MAE при числе пропущенных превышений не больше baseline; при равенстве оставить более простую модель. Если подходящих моделей нет — baseline сохраняется, ограничение качества указывается в отчёте. + +### Неопределённость + +До этапа 5 прогноз без интервала имеет `interval_kind='none'`. Он не удовлетворяет сценарию с `require_upper_bound=true`. Первая сквозная демонстрация использует явные границы модельных компонентов, поэтому не зависит от готовности статистических интервалов. + +На этапе 5: верхняя квантильная оценка 0.95; первая половина validation по времени — выбор настроек, вторая — проверка покрытия/калибровка. Сдвиг границы равен `max(0, quantile_0.95(y - upper_raw))` на калибровочной части. Финальный test не участвует в настройке. Реальное покрытие и средняя ширина интервала публикуются; 0.95 — номинальный уровень, не гарантия безопасности при сдвиге режима. + +Без требуемой верхней оценки кандидат получает `unknown` по ограничению серы. Обычная ошибка модели не трактуется как вероятность отказа оборудования. + +### Safety-first alarm и совместная применимость + +Stage 7 не заменяет численный прогноз бинарной классификацией. Artifact schema 1.1 +объединяет point, upper и отдельную калиброванную вероятность `S(t+60) > 10 mg/kg`. +Классификатор обучается с весами положительного класса из фиксированной сетки на +purged temporal folds. Первая половина validation калибрует вероятность, вторая выбирает +порог с минимальным числом пропусков при `false-positive rate <= 20%`. Test не участвует +в выборе. Продвижение запрещено, если validation `false-negative rate > 10%`. + +Обязательный autoregressive sulfur-признак проверяется жёстко. Остальные признаки после +train-only median imputation и scaling проверяются совместно в whitened PCA-пространстве; +порог квадрата расстояния равен train-квантилю 0.99. Это заменяет пересечение множества +одномерных квантильных диапазонов, но не превращает статистическую область в инженерные +пределы. Старые artifacts schema 1.0 продолжают загружаться без вероятности риска. + +### Модель последствий + +`effects.py` имеет два явных пути: формулы модельного смешения и подтверждённая модель изменений уставок. Это обычное ветвление по режиму/возможностям, без реестра плагинов. + +Для `supports_actions=true` по реальным уставкам нужны: подтверждённый смысл и управляемость, описанные пределы шага, проверка совместной области применимости, отдельная временная оценка на эпизодах изменений, описанные задержки и ограничения причинной интерпретации. ML сохраняет ссылку на этот отчёт в metadata. До этого реальные controls отключены. + +>>>>>>> f0ad14f (Complete ML stages and safety diagnostics) Эксперт подтвердил наличие обратной связи на АВТ и 24-2000 (Q&A 11.09, 00:16:15). Поэтому рост температуры после роста серы может отражать реакцию управления: историческая зависимость не задаёт причинный коэффициент. Числа из вопросов @@ -590,6 +739,7 @@ Q&A 11.09 добавляет явную приёмочную проверку: Backend фиксирует команду и фактический исход; ML — идентификатор модели и границы применимости. Это требование к проверке, не утверждение, что она уже выполнена. +<<<<<<< HEAD Целевой бюджет после загрузки данных: один цикл с максимум 126 кандидатами до 5 секунд на рабочем CPU-ноутбуке; это инженерная цель, не измеренный результат. В отчёте указать CPU, объём памяти, число кандидатов и фактическое время. ## 14. Работа двух исполнителей @@ -683,6 +833,104 @@ Backend → ML: подготовленные таблицы, manifest, отчё Основание этого раздела — ответы составителей задания в рабочем чате, переданные команде 10.09.2026. Они заменяют противоречащие им начальные допущения выше. +======= +Целевой бюджет после загрузки данных: один цикл с максимум 126 кандидатами до 5 секунд на рабочем CPU-ноутбуке; это инженерная цель, не измеренный результат. В отчёте указать CPU, объём памяти, число кандидатов и фактическое время. + +## 14. Работа двух исполнителей + +### Владение и изоляция изменений + +Интеграционная ветка — `dev`. Рабочие ветки — `backend/` и `ml/` от актуальной `dev`, по одной небольшой поставке. У каждого исполнителя отдельный checkout или Git worktree. Два агента не переключают ветки, не выполняют reset и не меняют общий индекс Git в одном каталоге. + +В поставке — одна завершённая возможность с проверкой. Автор меняет свою область из раздела 3. Если нужен файл партнёра, передаёт описание изменения владельцу; владение можно временно передать явно, с указанием файлов и commit базы. Одновременное редактирование общего файла не считается способом интеграции. + +Backend собирает интеграционную версию и координирует слияние. ML проверяет семантику признаков, единиц, ограничений и численных результатов. Проверка партнёром обязательна для общих контрактов и сквозной логики; отдельный третий ревьюер не требуется. + +### Первая совместная поставка + +До независимой разработки оба исполнителя фиксируют: + +1. Pydantic-типы из раздела 5, сигнатуры раздела 6 и один сериализованный пример `ProcessState`/`Recommendation`. +2. В `global_tests/fixtures/` — три состояния для модельного смешения, ожидаемые статусы и численные значения из раздела 9. ML задаёт значения, backend оформляет контрактные fixtures. +3. Малую телеметрию и ЛИМС с задержанным анализом для проверки времени; подготовленный manifest и известный порядок признаков. +4. Команду проверки контрактов и модельного цикла. До реализации цикла допускаются отдельные тесты DTO; этап 1 закрывается только реальным сквозным запуском. + +Затем backend строит приложение с фиксированными результатами агентов **только в тестовых fixtures**. ML разрабатывает настоящие функции с теми же входами/выходами. В демонстрационном режиме используются реальные формулы сценария, а не заранее записанный успешный ответ. + +### Изменение контракта + +1. Инициатор описывает проблему, старую и новую форму входа/выхода, затронутые функции и единицы. Сообщение «нужен ещё один параметр» без примера недостаточно. +2. Второй исполнитель проверяет влияние на свою часть. Backend фиксирует согласованное изменение в DTO, этом дизайне и fixtures одной поставкой. +3. Потребитель обновляется в том же PR либо отдельным зависимым PR до слияния в `dev`. Ветка `dev` не остаётся в состоянии, где один модуль возвращает новый контракт, а другой читает старый. +4. Изменение схемы сопровождается версией и проверкой совместимости артефактов. Старые модели не пересохраняются под новым ID без повторной проверки признаков. + +Для гипотез ML не меняет общий контракт: эксперимент ведётся внутри своей ветки. В контракт попадает только принятая потребность, которую использует другая сторона. + +### Передача результата + +Каждая передача через PR или сообщение содержит: + +```text +Задача и владелец: +Базовый commit dev и commit результата: +Что изменилось для второго исполнителя: +Версия контракта; dataset_id / model_id при наличии: +Как воспроизвести: команда и пример входа: +Ожидаемый результат: статус, ключевые числа, путь артефакта: +Что проверено: команды и фактический исход: +Известные ограничения и блокеры: +Следующее действие второго исполнителя: +``` + +Модель передаётся не одним `model.joblib`, а полным каталогом из раздела 11, manifest данных/командой их подготовки и примером применения. Локальный абсолютный путь одного исполнителя не передаёт артефакт: второй должен иметь доступ к артефакту или воспроизводимой команде создания по закреплённому commit. Для первого этапа достаточно небольшой baseline-модели, которую оба могут обучить на CPU. + +Backend → ML: подготовленные таблицы, manifest, отчёт проблем и тестовую точку времени. ML → backend: модель/формулы, metadata, ограничения применимости и ожидаемый результат на этой точке. Получатель воспроизводит результат, а не подтверждает только получение файла. + +### Регулярная интеграция + +Минимум раз в рабочий день и после изменения контракта: + +1. Каждый сообщает готовую поставку, зависимость от партнёра и блокер с конкретным входом/ошибкой. +2. Backend собирает оба изменения от актуальной `dev`; конфликт семантики разрешается вместе с владельцем, не автоматическим выбором одной стороны. +3. Оба запускают три модельных сценария и проверяют `hold/recommend/abstain`. Для имеющейся модели проверяются загрузка и одна общая историческая точка. +4. Сравниваются выбранные источники, возраст анализов, список признаков, оценки, ограничения и итог; расхождения разбираются до нового улучшения. +5. Фиксируется один интеграционный commit с зелёными проверками. Следующая работа начинается от него. + +Общий критерий завершения задачи: код/конфигурация доступны в интеграционной версии, контракт совместим, целевой сценарий проходит, второй исполнитель воспроизвёл результат, существенные ограничения описаны. «Ноутбук работает у автора» или «UI показывает заглушку» не закрывает совместную поставку. + +### Что делать при блокировке + +- ML исследует данные или модель: backend продолжает на согласованных fixtures и модельных формулах, не придумывает новые численные правила. +- Backend меняет загрузчик: ML работает на закреплённом prepared dataset и не создаёт второй production-пайплайн чтения Excel. +- Не подтверждён технологический параметр: действие остаётся отключённым; интерфейс и сценарии отказа продолжают разрабатываться. +- Общий контракт ещё обсуждается: зависимая часть не сливается; независимые задачи выполняются без изменения интерфейса. + +## 15. Порядок фиксации и расширения + +| Этап плана | Какие части дизайна вводятся | +| --- | --- | +| 0 | Структура пакета, нормализация, словарь, временные правила и contracts | +| 1 | Сквозной цикл, baseline, модельные fixtures, журнал и минимальный UI | +| 2 | Общие признаки, обученная модель, metadata и временная оценка | +| 3 | Единый фильтр, кандидаты, ранжирование, проверка возможности оценки действий | +| 4 | Полный интерфейс модельного блендинга и hybrid-сценарий при подтверждённых связях | +| 5 | Статистические границы, область применимости, существенность и cooldown | +| 6 | Чистый запуск, отчёты, негативные сценарии и демонстрация | +| 7 | Safety-first alarm, joint applicability и усиленный action capability gate | +| 8 | Read-only диагностика временного drift, режимов, ПАК--ЛИМС и устойчивости признаков | +| 9 | Эпизодный multi-horizon shadow-прогноз schema 1.2 и отложенный LIMS-контроль | + +Незавершённая возможность обозначается через capabilities и понятный отказ, не скрывается условной константой. + +На этапе 0 исследуются, а не назначаются архитектурой: истинные единицы спорных тегов, управляющие параметры гидроочистки, технологические пределы, подтверждённые связи потоков, задержки, реальная товарная спецификация сверх серы. До подтверждения соответствующие действия и заявления о полноте проверки отключены. Владелец исследования — ML; backend обеспечивает исполнение запрета. + +Изменения полей DTO, единиц или временной семантики требуют обновления `schema_version` и явной проверки совместимости. Замена baseline на более точную модель с теми же входами меняет `model_id`, но не контракт. HTTP API, внешние интеграции и промышленное управление рассматриваются отдельным будущим дизайном после готовности этой версии. + +## 16. Подтверждения экспертов от 10.09.2026 + +Основание этого раздела — ответы составителей задания в рабочем чате, переданные команде 10.09.2026. Они заменяют противоречащие им начальные допущения выше. + +>>>>>>> f0ad14f (Complete ML stages and safety diagnostics) **Оговорка после Q&A 11.09:** в [вопросах участников](QA_CLARIFICATIONS.md) оспорены физические соответствия `P8/T11/F19` и полнота проверки ВАК. Нового содержательного ответа в предоставленной записи нет. Утверждения ниже сохраняются как история @@ -699,3 +947,8 @@ Backend → ML: подготовленные таблицы, manifest, отчё - Допустимый шаг рекомендаций — 15–60 минут, горизонт — 0–3 часа. Для Stage 2 сохраняется заранее выбранная точка 60 минут. - Для модельного блендинга обязательны сера, `T95` и цетановое число. Реализована явно модельная имитация резервуаров; присадка до 3% и её цена в 100 раз выше цены ДТ заданы в сценариях. Кривая эффективности остаётся синтетическим настраиваемым допущением и не подменяет отсутствующие реальные данные. - Отложенная историческая оценка прогноза и собственная модель альтернативных действий показываются раздельно; история прогноза сама по себе не доказывает эффект невыполненных воздействий. +<<<<<<< HEAD +======= +- Подтверждение пользователя от 13.09.2026: шесть известных экстремальных LIMS-наблюдений серы (`34`, `35.1`, `45.9`, `107`, `120`, `2120 mg/kg`) считаются выбросами для ML. Они сохраняются в prepared data и provenance, а обучение исключает только их стабильные `observation_id`; общий численный cap для будущих наблюдений не вводится. +- Метка LIMS означает момент отбора пробы. Четырёхчасовая задержка относится к доступности результата: `available_at = measured_at + 4h`. Историческое обучение вправе использовать результат как отложенную метку, но признаки и состояние на момент `t` используют его только при `available_at <= t`. +>>>>>>> f0ad14f (Complete ML stages and safety diagnostics) diff --git a/README.md b/README.md index 32cc57c..77fd6b4 100644 --- a/README.md +++ b/README.md @@ -13,18 +13,34 @@ - [Stage 4](STAGE4.md) — hybrid chain, model blending и газовые теги как context-only сигналы. - [Stage 5](STAGE5.md) — uncertainty, applicability, robustness и policy guardrails без action model. - [Stage 6](STAGE6.md) — чистый запуск, frozen models, исторические метрики и приёмочная демонстрация. +<<<<<<< HEAD - [Stage 7](STAGE7.md) — trusted local history artifact serving через CLI/UI в forecast-only режиме. +======= +- [Stage 7](STAGE7.md) — safety-first alarm, joint applicability и усиленный action gate. +- [Stage 8](STAGE8.md) — диагностика drift, режимов, ПАК–ЛИМС и устойчивости признаков. +- [Stage 9](STAGE9.md) — эпизодный multi-horizon shadow-прогноз и отложенный LIMS-контроль. +>>>>>>> f0ad14f (Complete ML stages and safety diagnostics) - [Code walkthrough](CODE_WALKTHROUGH.md) — папки, файлы и хронология вызовов почти построчно. - [ML system design](DESIGN.md#8-ml-неопределённость-и-модель-последствий) — обучение, метрики, анализ ошибок и жизненный цикл модели; общие контракты и данные описаны в том же документе. - [Материалы задания](materials/README.md) — ТЗ, схемы и исходные данные. ## Текущее состояние проекта -Реализация дошла до Stage 6 и содержит desktop UI, воспроизводимое обучение, +Реализация дошла до Stage 9 и содержит desktop UI, воспроизводимое обучение, историческую оценку, replay и приёмочную демонстрацию. Промышленная action model не заявлена. Stage 7 forecast-only history path сохранён как legacy `run-history`; актуальный исполняемый контракт описан ниже. +Read-only диагностика ML: + +```bash +python -m source.main diagnose-ml --dataset data/processed/aacc7c1ab3d9 +``` + +Stage 9 добавляет schema-1.2 эпизодный multi-horizon прогноз только в shadow-режиме. +Ответы Q&A 11.09 не подтверждают физический смысл/единицы `P8/T11/F19`, поэтому эти +колонки не считаются разрешёнными действиями и требуют отдельной PAK-only ablation. + Сейчас реализованы: - этап 0: строгие DTO, конфигурация, словарь сигналов, чтение и нормализация исходников, @@ -180,8 +196,17 @@ python -m source.main build-state --dataset data/processed/ --scenar ```bash python -m source.main train --dataset data/processed/ --with-uncertainty + +# Обучить point + upper + calibrated exceedance alarm. +python -m source.main train --dataset data/processed/ --with-uncertainty --with-safety + +# Эпизодный прогноз 10/20/30/60 минут; создаёт только shadow-артефакт schema 1.2. +python -m source.main train-v2-shadow --dataset data/processed/ ``` +Контракт и ограничения ML v2 описаны в [STAGE9.md](STAGE9.md). Test 2026 служит +только audit-набором; production требует нового shadow-периода. + Сравнить frozen point model с persistence baseline на одинаковых timestamp: ```bash diff --git a/STAGE7.md b/STAGE7.md index 3e96762..4c71b3c 100644 --- a/STAGE7.md +++ b/STAGE7.md @@ -1,3 +1,4 @@ +<<<<<<< HEAD # Stage 7: trusted history artifact serving > Current status: Stage 7 connects a trusted local forecast artifact to the @@ -61,3 +62,60 @@ python -m pytest python -m ruff check . python -m mypy source ``` +======= +# Stage 7: constraint-aware safety forecast + +## Что реализовано + +- отдельная бинарная цель `S(t+60) > 10 mg/kg` поверх прежнего point forecast; +- Logistic Regression, HistGradientBoostingClassifier и LightGBM с весами превышений + `1, 2, 5, 10, 20` и purged temporal folds; +- для LightGBM зона 8--12 mg/kg получает дополнительный вес `2`; +- Platt calibration на первой половине validation; +- safety-first выбор alarm threshold на второй половине validation; +- gate: `FNR <= 10%` при `FPR <= 20%`; +- composite artifact schema 1.1: point, upper, probability, alarm и applicability; +- joint PCA/Mahalanobis applicability вместо пересечения всех marginal bounds; +- отдельный исследовательский PAK-to-LIMS correction helper; +- action capability требует не менее 100 эпизодов на control, 10% temporal gain, + coverage 95%, устойчивый знак, shadow replay и одобрение технолога. + +## Обучение + +```bash +python -m source.main train \ + --dataset data/processed/ \ + --with-uncertainty \ + --with-safety +``` + +`--with-safety` без `--with-uncertainty` отклоняется. Если validation gate не пройден, +safety artifact не публикуется. Старые schema-1.0 point/upper artifacts остаются +совместимыми fallback-моделями. + +## Граница доказательств + +Safety alarm улучшает обнаружение риска качества, но не доказывает эффект изменения +уставок. `supports_actions` остаётся `false`, пока отсутствуют подтверждённые пределы +`P8/T11/F19`, достаточные change episodes, shadow replay и пилот с технологом. Hard +constraints продолжают проверяться после ML и не заменяются штрафом в loss. + +## Результат на закреплённом dataset `aacc7c1ab3d9` + +Лучший sklearn-кандидат -- HistGradientBoostingClassifier с весом превышения `2`. +На второй половине validation он получил `FNR=11.63%` при `FPR=19.93%`, поэтому +не прошёл обязательный gate. На test: `FNR=6.08%`, `FPR=31.90%`, transition recall +`79.11%`, joint applicability `98.78%`. Test не участвовал в выборе. + +После этого проверен LightGBM. Он оказался хуже sklearn: validation `FNR=13.22%` +при `FPR=19.92%`, test `FNR=7.61%`, `FPR=32.04%`, transition recall `74.57%`. +LightGBM не продвигается. + +Отдельная LIMS-коррекция также не проходит gate: исходный PAK point forecast имеет +test MAE `1.781 mg/kg`, скорректированный -- `2.094 mg/kg`, coverage верхней оценки +`84.98%`. Production LIMS capability не объявляется. + +Итог: schema 1.1 и весь safety pipeline реализованы, но новый artifact намеренно +не публикуется. Runtime продолжает использовать frozen fallback. PyTorch и +decision-focused loss откладываются до появления достоверных action outcomes. +>>>>>>> f0ad14f (Complete ML stages and safety diagnostics) diff --git a/STAGE8.md b/STAGE8.md new file mode 100644 index 0000000..350d6a5 --- /dev/null +++ b/STAGE8.md @@ -0,0 +1,79 @@ +# Stage 8: диагностика данных и причин слабого ML + +Диагностика выполняется read-only командой: + +```bash +python -m source.main diagnose-ml --dataset data/processed/aacc7c1ab3d9 +``` + +Команда не обучает и не публикует модель. Она отдельно показывает временной drift, +режимы текущей серы, согласование ПАК--ЛИМС и устойчивость связей признаков. + +## Результаты на `aacc7c1ab3d9` + +### ПАК и ЛИМС + +В LIMS присутствуют шесть значений выше 30 mg/kg: `34`, `35.1`, `45.9`, `107`, +`120`, `2120`. Они сохраняются как факты, потому что без технолога нельзя объявить их +ошибками. На полном наборе ПАК и ЛИМС почти не коррелируют; лучший MAE среди проверенных +сдвигов равен `3.217 mg/kg` при `-60 min`. + +Подтверждённая ML-политика исключает ровно эти шесть `observation_id`, но не удаляет +строки из prepared data. После исключения лучший alignment MAE +равен `1.568 mg/kg` при `-60 min`, а максимальная корреляция `0.367` достигается около +`-90 min`. Другие высокие значения автоматически не исключаются. + +Повторная LIMS-коррекция после исключения выбросов улучшилась с `2.251` до +`2.094 mg/kg`, но всё ещё хуже исходного PAK point MAE `1.781 mg/kg`; upper coverage +осталась `84.98%`. Поэтому LIMS-модель не продвигается. + +`measured_at` LIMS означает момент отбора пробы, а `available_at` устанавливается на +четыре часа позже. Значение используется как отложенная метка прошлого, но не может +попасть в признаки или состояние оператора до `available_at`. Сдвиг `-60...-90 min` +из alignment-анализа не применяется автоматически: четыре часа публикации -- это +задержка знания результата, а не перенос физического момента отбора. + +### Temporal drift + +| Период | Exceedance rate | Переходов `current<=10 -> future>10` | Persistence MAE | Corr current/future | +| --- | ---: | ---: | ---: | ---: | +| train | 13.80% | 8943 | 1.142 | 0.505 | +| validation | 6.88% | 1994 | 0.689 | 0.749 | +| test | 14.79% | 1345 | 0.684 | 0.929 | + +Test существенно отличается от validation: дисперсия цели выросла с `1.51` до +`3.02 mg/kg`, а доля превышений более чем удвоилась. Поэтому один глобальный +калибровочный порог не сохраняет заданный FPR во времени. + +### Режимы + +На test `1147` из `1345` новых превышений начинаются при текущей сере `8--10 mg/kg`. +Это основной режим для раннего предупреждения. При текущей сере выше `12 mg/kg` test +содержит `2400` точек против `350` на validation; это главный наблюдаемый сдвиг режима. + +### Признаки + +Сырые телеметрические значения почти не объясняют изменение серы за 60 минут: +максимальная абсолютная train-корреляция около `0.026`. У `P8`, `T11`, `F19` связи +также малы. Несколько их delta-признаков меняют знак между train/validation/test. + +Наиболее сильный устойчивый сигнал -- собственная история ПАК. Однако знак связи +`pak_delta_30m`, `pak_delta_60m` и коротких delta controls меняется на test. Добавление +всех 98 телеметрических колонок поэтому не обосновано: это увеличит переобучение без +устойчивого сигнала. + +## Проверенная, но отклонённая гипотеза + +Отдельный HGB-классификатор переходов, обученный только при `current_S<=10`, сохранил +validation FNR `11.63%` и снизил test transition recall с `79.11%` до `78.07%`. +Кандидат удалён из production-кода как не дающий выигрыша. + +## Следующие необходимые данные + +1. Нужно подтвердить, совпадает ли timestamp ЛИМС с отбором на той же точке, которую + измеряет ПАК, и допустим ли физический сдвиг 60--90 минут. +2. Для `P8/T11/F19` нужны фактические единицы, рабочие диапазоны и журналы изменений. +3. После подтверждения задержек следует строить lagged response features и temporal + regime model, особенно для диапазона текущей серы 8--10 mg/kg. + +До этих подтверждений frozen fallback и `supports_actions=false` сохраняются. diff --git a/STAGE9.md b/STAGE9.md new file mode 100644 index 0000000..d233328 --- /dev/null +++ b/STAGE9.md @@ -0,0 +1,96 @@ +# Stage 9 — ML v2: эпизодный shadow-прогноз + +## Запуск + +```bash +python -m source.main train-v2-shadow \ + --dataset data/processed/aacc7c1ab3d9 \ + --output artifacts/models +``` + +Команда обучает schema `1.2`. Артефакт всегда получает статус `shadow_only` и +`supports_actions=false`. + +## Что моделируется + +- `delta_60m` и итоговые `point_60m`, `upper_60m`; +- вероятность хотя бы одного перехода через `10 mg/kg` за 10/20/30/60 минут; +- событие считается обнаруженным, если alarm появился за 10–60 минут до начала + непрерывного эпизода превышения; +- строки внутри одного эпизода получают обратный длине эпизода вес, месяцы + балансируются отдельно; +- `current_S > 10` всегда включает тревогу независимо от модели. + +Признаки строятся только из доступного к `as_of` ПАК: динамика и диапазоны за +30/60/180 минут, пересечения 8/9/10, длительность режима, missingness, возраст, +а также `P8/T11/F19`. Остальные телеметрические сигналы не подключаются. + +## Временная проверка + +- 2024: rolling-origin сравнение HGB и LightGBM; +- январь–июнь 2025: калибровка вероятностей; +- июль–декабрь 2025: фиксация alarm threshold; +- 2026: только audit, без выбора признаков, модели или порога. + +Порог обязан одновременно соблюдать row-level и event-opportunity FPR не выше +20%. В отчёте сохраняются event/row FNR/FPR, Brier, PR-AUC, MAE, q95 coverage, +срезы `<8`, `8–10`, `>10`, месяцы, bootstrap CI и предупреждения по каждому +горизонту. + +## LIMS и продвижение + +LIMS — отдельный отложенный слой. Признак доступен только при +`available_at <= as_of`; шесть подтверждённых выбросов исключаются по +`observation_id`, prepared data не меняется. Коррекция проходит собственный gate: +MAE минимум на 5% лучше ПАК и coverage не ниже 95%. + +Audit 2026 не разрешает production. После фиксации нужен новый shadow-период: +100 независимых эпизодов или три полных месяца. Действия `P8/T11/F19` остаются +запрещены до отдельной модели эффектов и инженерного gate. + +## Влияние Q&A 11.09 + +Новые ответы не меняют target и временную схему v2, но усиливают границы: + +- решение и артефакты должны полностью работать офлайн; +- точки АВТ нельзя объединять с гидроочисткой, а физический lag между ними нельзя + назначать без карты потоков; +- смысл и единицы `ht:P8`, `ht:T11`, `ht:F19` оспорены. В v2 это только именованные + наблюдаемые колонки; их нельзя подписывать как подтверждённые физические controls. + Перед promotion требуется PAK-only ablation: добавление этих колонок должно давать + устойчивое улучшение на rolling folds; +- действующая обратная связь АСУ может создавать корреляцию «состояние → действие». + Поэтому прогнозная важность control-признака не является оценкой эффекта действия; +- численные правила пуска/останова не подтверждены. Такие режимы обрабатываются + applicability/drift предупреждением, а не придуманным порогом. + +Ни один ответ Q&A не разрешает ослабить `FNR/FPR`, coverage или action gates. + +## Результат backtest на `aacc7c1ab3d9` + +Выбран HGB: на rolling-origin 2024 event FNR `3.03%` при event-opportunity FPR +`20.00%`. На периоде фиксации порога (июль–декабрь 2025) threshold `0.02768` +получил event FNR `0%`, event FPR `20.00%` и row FPR `13.10%`. + +Audit-2026 подтвердил сильное обнаружение, но не устойчивость ложных тревог: + +| Метрика | Результат | +| --- | ---: | +| Независимых эпизодов | 392 | +| Event FNR | 0.26% | +| Event-opportunity FPR | 38.84% | +| Row FNR | 4.22% | +| Row FPR | 25.83% | +| PR-AUC | 0.909 | +| Brier | 0.0496 | +| Point MAE | 1.092 mg/kg | +| q95 coverage | 93.80% | + +По горизонтам event FNR равен `1.53% / 0.77% / 0.26% / 0.26%` для +10/20/30/60 минут. Но event FPR растёт до `15.44% / 22.26% / 28.87% / 38.84%`. +Старый persistence point на том же audit-периоде имел MAE `0.684 mg/kg`, поэтому +delta-регрессор v2 численно хуже baseline. + +Вывод: эпизодная постановка решила проблему пропуска начала событий, но не прошла +production gate из-за drift ложных тревог и coverage ниже 95%. Артефакт остаётся +`shadow_only`; порог по audit не перенастраивается. diff --git a/global_tests/test_ml_diagnostics.py b/global_tests/test_ml_diagnostics.py new file mode 100644 index 0000000..fc56098 --- /dev/null +++ b/global_tests/test_ml_diagnostics.py @@ -0,0 +1,115 @@ +"""Tests for read-only ML data diagnostics.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pandas as pd + +from source.contracts import SourceKind +from source.ml.diagnostics import ( + CONFIRMED_LIMS_OUTLIER_IDS, + engineered_feature_screen, + exclude_confirmed_lims_outliers, + pak_lims_alignment, + regime_diagnostics, + temporal_drift, +) +from source.ml.features import SupervisedDataset + + +def _dataset() -> SupervisedDataset: + times = pd.to_datetime( + [ + "2024-01-01T00:00:00Z", + "2024-06-01T00:00:00Z", + "2025-01-01T00:00:00Z", + "2025-06-01T00:00:00Z", + "2026-01-01T00:00:00Z", + "2026-06-01T00:00:00Z", + ] + ) + frame = pd.DataFrame( + { + "as_of": times, + "target_at": times + pd.Timedelta(minutes=60), + "target_available_at": times + pd.Timedelta(minutes=60), + "y": [9.0, 11.0, 8.0, 12.0, 9.5, 10.5], + "baseline": [8.0, 9.0, 8.0, 9.0, 9.0, 9.0], + "trend": [1.0, 2.0, 0.0, 3.0, 0.5, 1.5], + } + ) + return SupervisedDataset( + frame=frame, + feature_names=("baseline", "trend"), + target_signal_id="ht:2:Mg.Sulfur", + target_unit="mg/kg", + target_source=SourceKind.PAK, + feature_source=SourceKind.PAK, + baseline_feature="baseline", + feature_definition={}, + excluded_counts={}, + ) + + +def test_alignment_keeps_raw_rows_and_labels_sensitivity_exclusions() -> None: + times = pd.to_datetime(["2024-01-01T00:00:00Z", "2024-01-01T01:00:00Z"]) + quality = pd.DataFrame( + { + "signal_id": ["ht:2:Mg.Sulfur"] * 4, + "observation_id": [ + "pak-1", + "pak-2", + "lims-normal", + next(iter(CONFIRMED_LIMS_OUTLIER_IDS)), + ], + "source": ["pak", "pak", "lims", "lims"], + "measured_at": [*times, *times], + "value": [8.0, 9.0, 8.2, 120.0], + } + ) + data = SimpleNamespace(quality=quality) + + report = pak_lims_alignment(data, offsets_minutes=(0,)) # type: ignore[arg-type] + + assert report["raw"][0]["n"] == 2 + assert report["training_policy"]["excluded_values"] == [120.0] + assert "prepared data" in report["training_policy"]["interpretation"] + + +def test_outlier_policy_uses_confirmed_ids_not_a_blanket_value_cap() -> None: + confirmed = next(iter(CONFIRMED_LIMS_OUTLIER_IDS)) + frame = pd.DataFrame({"observation_id": [confirmed, "future-real-high"], "y": [120.0, 120.0]}) + + eligible, excluded = exclude_confirmed_lims_outliers(frame) + + assert excluded == (confirmed,) + assert eligible["observation_id"].tolist() == ["future-real-high"] + + +def test_temporal_drift_reports_transition_counts_per_fixed_period() -> None: + report = temporal_drift(_dataset()) + + assert report["train"]["n"] == 2 + assert report["validation"]["transition_count"] == 1 + assert report["test"]["transition_count"] == 1 + + +def test_engineered_screen_uses_train_association_and_reports_later_signs() -> None: + report = engineered_feature_screen(_dataset(), top_n=2, min_samples=2) + + by_feature = {row["feature"]: row for row in report} + assert "trend" in by_feature + assert set(by_feature["trend"]) >= { + "train_correlation", + "validation_correlation", + "test_correlation", + "stable_sign", + } + + +def test_regime_diagnostics_keeps_near_limit_band_separate() -> None: + report = regime_diagnostics(_dataset()) + + assert report["validation"]["8_to_10"]["transition_count"] == 1 + assert report["test"]["8_to_10"]["transition_count"] == 1 diff --git a/global_tests/test_ml_v2.py b/global_tests/test_ml_v2.py new file mode 100644 index 0000000..d8c6bd0 --- /dev/null +++ b/global_tests/test_ml_v2.py @@ -0,0 +1,161 @@ +"""Regression tests for episode-aware multi-horizon sulfur forecasting.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest + +from source.contracts import SourceKind +from source.ml.features import SupervisedDataset +from source.ml.v2 import ( + EpisodeSafetyPredictor, + build_episode_dataset, + episode_sample_weights, + event_metrics, + rolling_month_folds, + select_event_threshold, +) + + +def _event_frame() -> pd.DataFrame: + return pd.DataFrame( + { + "as_of": pd.date_range("2025-01-01", periods=7, freq="10min", tz="UTC"), + "target_available_at": pd.date_range( + "2025-01-01 01:00", periods=7, freq="10min", tz="UTC" + ), + "baseline": [9.0] * 7, + "crossing_60m": [True, True, True, False, True, False, False], + "event_id": [1.0, 1.0, 1.0, np.nan, 2.0, np.nan, np.nan], + } + ) + + +def test_long_positive_episode_has_same_total_weight_as_short_episode() -> None: + frame = _event_frame() + weights = episode_sample_weights(frame) + + assert weights[:3].sum() == pytest.approx(weights[4]) + + +def test_event_metrics_count_one_long_episode_once() -> None: + frame = _event_frame() + probability = np.asarray([0.8, 0.2, 0.1, 0.1, 0.2, 0.1, 0.1]) + + metrics = event_metrics(frame, probability, 0.5) + + assert metrics["event_count"] == 2 + assert metrics["detected_events"] == 1 + assert metrics["event_false_negative_rate"] == pytest.approx(0.5) + + +def test_event_threshold_respects_false_alarm_budget() -> None: + frame = _event_frame() + probability = np.asarray([0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.1]) + + policy, metrics = select_event_threshold(frame, probability, false_alarm_budget=1 / 3) + + assert metrics["row_false_positive_rate"] <= 1 / 3 + assert metrics["event_false_positive_rate"] <= 1 / 3 + assert policy.threshold > 0.4 + + +def test_rolling_month_fold_purges_labels_published_after_boundary() -> None: + frame = pd.DataFrame( + { + "as_of": pd.to_datetime(["2024-01-01", "2024-01-31", "2024-02-01"], utc=True), + "target_available_at": pd.to_datetime( + ["2024-01-01 01:00", "2024-02-01 02:00", "2024-02-01 01:00"], utc=True + ), + } + ) + + folds = rolling_month_folds(frame, "2024-02-01", "2024-03-01") + + assert folds[0][0].tolist() == [0] + assert folds[0][1].tolist() == [2] + + +def test_current_violation_always_triggers_event_alarm() -> None: + class Delta: + def predict(self, frame: pd.DataFrame) -> np.ndarray: + return np.zeros(len(frame)) + + class Risk: + def predict_proba(self, frame: pd.DataFrame) -> np.ndarray: + probability = np.full(len(frame), 0.1) + return np.column_stack((1 - probability, probability)) + + class Calibrator: + def predict(self, probability: np.ndarray) -> np.ndarray: + return probability + + predictor = EpisodeSafetyPredictor( + "baseline", + Delta(), + Delta(), + {horizon: Risk() for horizon in (10, 20, 30, 60)}, + {horizon: Calibrator() for horizon in (10, 20, 30, 60)}, # type: ignore[arg-type] + SimpleNamespace(threshold=0.5), + SimpleNamespace(), + ) + + assert predictor.predict_alarm(pd.DataFrame({"baseline": [9.0, 11.0]})).tolist() == [ + False, + True, + ] + + +def test_episode_dataset_builds_future_labels_and_causal_features() -> None: + times = pd.date_range("2024-01-01", periods=25, freq="10min", tz="UTC") + quality = pd.DataFrame( + { + "observation_id": [f"pak-{index}" for index in range(len(times))], + "signal_id": ["ht:2:Mg.Sulfur"] * len(times), + "source": ["pak"] * len(times), + "validity": ["valid"] * len(times), + "measured_at": times, + "available_at": times, + "unit": ["mg/kg"] * len(times), + "value": [9.0] * 7 + [11.0] * 3 + [9.0] * 15, + } + ) + as_of = times[:19] + base_frame = pd.DataFrame( + { + "as_of": as_of, + "target_at": as_of + pd.Timedelta(minutes=60), + "target_available_at": as_of + pd.Timedelta(minutes=60), + "y": quality["value"].to_numpy()[6:25], + "baseline": quality["value"].to_numpy()[:19], + "ht:2:Mg.Sulfur__pak_last": quality["value"].to_numpy()[:19], + "ht:2:Mg.Sulfur__pak_lag_30m": [np.nan] * 3 + quality["value"].to_list()[:16], + "ht:2:Mg.Sulfur__pak_lag_60m": [np.nan] * 6 + quality["value"].to_list()[:13], + "ht:2:Mg.Sulfur__pak_lag_180m": [np.nan] * 18 + quality["value"].to_list()[:1], + } + ) + names = tuple(base_frame.columns[5:]) + base = SupervisedDataset( + base_frame, + names, + "ht:2:Mg.Sulfur", + "mg/kg", + SourceKind.PAK, + SourceKind.PAK, + "ht:2:Mg.Sulfur__pak_last", + {}, + {}, + ) + data = SimpleNamespace(quality=quality) + + result = build_episode_dataset(data, base) # type: ignore[arg-type] + + assert result.frame.loc[1, "crossing_60m"] + assert result.frame.loc[1, "event_id"] == 1 + assert result.frame.loc[5, "crossing_20m"] + assert not result.frame.loc[5, "crossing_10m"] + assert "pak_range_60m" in result.feature_names + assert "pak_minutes_since_crossing_10" in result.feature_names diff --git a/global_tests/test_safety_forecast.py b/global_tests/test_safety_forecast.py new file mode 100644 index 0000000..536ff5f --- /dev/null +++ b/global_tests/test_safety_forecast.py @@ -0,0 +1,269 @@ +"""Tests for constraint-aware sulfur risk and joint applicability.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest + +from source.agents.quality import predict_quality +from source.config import load_scenario +from source.contracts import ProcessState +from source.ml.artifacts import LastValueRegressor, ModelBundle, ModelMetadata, feature_schema_hash +from source.ml.safety import ( + CompositeSafetyPredictor, + PlattCalibrator, + binary_alarm_metrics, + fit_joint_applicability, + published_lims_features, + select_alarm_threshold, +) + + +def test_threshold_minimizes_misses_inside_false_alarm_budget() -> None: + actual = np.asarray([False, False, False, False, True, True]) + probability = np.asarray([0.05, 0.10, 0.20, 0.80, 0.40, 0.90]) + + policy, metrics = select_alarm_threshold(actual, probability, false_alarm_budget=0.25) + + assert policy.threshold == pytest.approx(0.4) + assert metrics["false_negative_rate"] == 0.0 + assert metrics["false_positive_rate"] == pytest.approx(0.25) + + +def test_alarm_metrics_reject_invalid_probabilities() -> None: + with pytest.raises(ValueError, match="finite"): + binary_alarm_metrics([True], [np.nan], 0.5) + + +def test_joint_applicability_does_not_reject_one_marginally_unusual_feature() -> None: + rng = np.random.default_rng(42) + training = pd.DataFrame( + { + "baseline": rng.normal(9.0, 0.5, 500), + "x": rng.normal(0.0, 1.0, 500), + "correlated": rng.normal(0.0, 1.0, 500), + } + ) + domain = fit_joint_applicability(training, required_features=("baseline",)) + + assert domain.assess( + pd.DataFrame({"baseline": [9.0], "x": [2.0], "correlated": [0.0]}) + ).available + missing = domain.assess(pd.DataFrame({"baseline": [np.nan], "x": [0.0], "correlated": [0.0]})) + assert missing.available is False + assert missing.reason_code == "FEATURES_UNAVAILABLE" + + +def test_composite_predictor_exposes_point_upper_probability_and_alarm() -> None: + features = pd.DataFrame({"baseline": [9.0, 11.0], "x": [0.0, 1.0]}) + point = LastValueRegressor("baseline").fit(features) + + class Upper: + def predict_upper(self, frame: pd.DataFrame) -> np.ndarray: + return frame["baseline"].to_numpy(dtype=float) + 1.0 + + class Risk: + def predict_proba(self, frame: pd.DataFrame) -> np.ndarray: + p = np.asarray([0.2, 0.8]) + return np.column_stack((1.0 - p, p)) + + calibrator_estimator = SimpleNamespace( + predict_proba=lambda values: np.column_stack( + (1.0 - np.asarray([0.2, 0.8]), np.asarray([0.2, 0.8])) + ) + ) + domain = fit_joint_applicability(features, required_features=("baseline",), coverage=0.75) + predictor = CompositeSafetyPredictor( + point, + Upper(), + Risk(), + PlattCalibrator(calibrator_estimator), # type: ignore[arg-type] + SimpleNamespace(threshold=0.5), + domain, + ) + + assert predictor.predict(features).tolist() == [9.0, 11.0] + assert predictor.predict_upper(features).tolist() == [10.0, 12.0] + assert predictor.predict_exceedance_probability(features).tolist() == pytest.approx([0.2, 0.8]) + assert predictor.predict_alarm(features).tolist() == [False, True] + + +def test_schema_12_bundle_validates_capability_and_old_schemas_remain_valid() -> None: + names = ("baseline",) + common = { + "model_id": "safety-test", + "model_sha256": "0" * 64, + "model_type": "test", + "training_dataset_id": "dataset", + "git_commit": "commit", + "python_version": "3.12.0", + "sklearn_version": "1.9.0", + "target_signal": "ht:2:Mg.Sulfur", + "target_source": "pak", + "target_unit": "mg/kg", + "horizon_minutes": 60, + "feature_names": names, + "baseline_feature": "baseline", + "tag_dictionary_sha256": "a" * 64, + "feature_schema_hash": feature_schema_hash(names), + "processing": {"feature_definition": {}}, + "time_boundaries": { + "train_end_local": "2025-01-01", + "validation_end_local": "2026-01-01", + "source_timezone": "UTC", + }, + "seed": 42, + "applicability": {}, + "reports": (), + } + old = ModelMetadata( + **common, + schema_version="1.0", + capabilities={"supports_forecast": True, "supports_actions": False}, + ) + safety = ModelMetadata( + **common, + schema_version="1.1", + capabilities={ + "supports_forecast": True, + "supports_actions": False, + "supports_uncertainty": True, + "supports_exceedance_probability": True, + }, + ) + multi_horizon = ModelMetadata( + **common, + schema_version="1.2", + capabilities={ + "supports_forecast": True, + "supports_actions": False, + "supports_uncertainty": True, + "supports_exceedance_probability": True, + "supports_multi_horizon": True, + }, + ) + + assert old.capabilities.supports_exceedance_probability is False + assert safety.capabilities.supports_exceedance_probability is True + assert multi_horizon.capabilities.supports_multi_horizon is True + + +def test_bundle_refuses_probability_when_capability_is_absent() -> None: + metadata = SimpleNamespace( + feature_names=("baseline",), + capabilities=SimpleNamespace(supports_exceedance_probability=False), + ) + bundle = ModelBundle(LastValueRegressor(), metadata) # type: ignore[arg-type] + + with pytest.raises(ValueError, match="does not declare"): + bundle.predict_exceedance_probability(pd.DataFrame({"baseline": [9.0]})) + + +def test_bundle_returns_complete_safety_contract() -> None: + features = pd.DataFrame({"baseline": [9.0]}) + + class Predictor: + def predict(self, frame: pd.DataFrame) -> np.ndarray: + return np.asarray([9.2]) + + def predict_upper(self, frame: pd.DataFrame) -> np.ndarray: + return np.asarray([10.4]) + + def predict_exceedance_probability(self, frame: pd.DataFrame) -> np.ndarray: + return np.asarray([0.7]) + + def predict_alarm(self, frame: pd.DataFrame) -> np.ndarray: + return np.asarray([True]) + + def check_applicability(self, frame: pd.DataFrame) -> object: + return SimpleNamespace(available=False, reason_code="OUT_OF_DOMAIN") + + metadata = SimpleNamespace( + feature_names=("baseline",), + capabilities=SimpleNamespace( + supports_uncertainty=True, + supports_exceedance_probability=True, + ), + ) + bundle = ModelBundle(Predictor(), metadata) # type: ignore[arg-type] + + assert bundle.predict_safety(features) == [ + { + "point": 9.2, + "upper": 10.4, + "exceedance_probability": 0.7, + "alarm": True, + "applicable": False, + "reason_codes": ("OUT_OF_DOMAIN",), + } + ] + + +def test_history_quality_exposes_risk_and_blocks_on_alarm() -> None: + state = ProcessState.model_validate_json( + Path("global_tests/fixtures/contracts/process_state.json").read_text(encoding="utf-8") + ) + scenario = load_scenario("config/scenarios/history.json") + + class RiskFixture: + metadata = SimpleNamespace( + model_id="risk-fixture", + target_signal="ht:2:Mg.Sulfur", + target_source="pak", + target_unit="mg/kg", + horizon_minutes=60, + capabilities=SimpleNamespace( + supports_forecast=True, + supports_actions=False, + supports_uncertainty=True, + supports_exceedance_probability=True, + ), + ) + + def predict(self, features: pd.DataFrame) -> np.ndarray: + return np.asarray([9.0]) + + def predict_upper(self, features: pd.DataFrame) -> np.ndarray: + return np.asarray([9.8]) + + def predict_exceedance_probability(self, features: pd.DataFrame) -> np.ndarray: + return np.asarray([0.7]) + + def predict_alarm(self, features: pd.DataFrame) -> np.ndarray: + return np.asarray([True]) + + def check_applicability(self, features: pd.DataFrame) -> object: + return SimpleNamespace(available=True, reason_code=None) + + assessment = predict_quality(state, pd.DataFrame({"baseline": [9.0]}), RiskFixture(), scenario) + + assert assessment.status.value == "degraded" + assert assessment.metrics["sulfur_exceedance_probability"].value == pytest.approx(0.7) + assert {issue.code for issue in assessment.issues} == {"SULFUR_EXCEEDANCE_RISK"} + + +def test_published_lims_features_never_see_unpublished_analysis() -> None: + quality = pd.DataFrame( + { + "observation_id": ["old", "hidden"], + "signal_id": ["ht:2:Mg.Sulfur"] * 2, + "source": ["lims"] * 2, + "validity": ["valid"] * 2, + "unit": ["mg/kg"] * 2, + "measured_at": pd.to_datetime(["2025-01-01 00:00Z", "2025-01-01 06:00Z"]), + "available_at": pd.to_datetime(["2025-01-01 04:00Z", "2025-01-01 10:00Z"]), + "value": [8.0, 999.0], + } + ) + data = SimpleNamespace(quality=quality) + + features = published_lims_features( # type: ignore[arg-type] + data, pd.Series(pd.to_datetime(["2025-01-01 09:00Z"])) + ) + + assert features.loc[0, "lims_published_lag_0"] == pytest.approx(8.0) + assert features.loc[0, "lims_age_minutes_0"] == pytest.approx(9 * 60) diff --git a/global_tests/test_stage3_controls.py b/global_tests/test_stage3_controls.py index ab1e38f..f18a277 100644 --- a/global_tests/test_stage3_controls.py +++ b/global_tests/test_stage3_controls.py @@ -25,6 +25,7 @@ CONTROL_IDS, ActionEffectEvidence, assess_action_capability, + extract_change_episodes, fit_joint_control_domain, generate_setpoint_candidates, summarize_observed_controls, @@ -136,12 +137,69 @@ def test_joint_domain_rejects_individually_typical_but_implausible_combination() def test_action_capability_requires_temporal_gain_on_change_episodes() -> None: controls = _controls() weak = ActionEffectEvidence("2025-01-01", "2026-01-01", 30, 60, 1.0, 1.1) - useful = ActionEffectEvidence("2025-01-01", "2026-01-01", 30, 60, 1.0, 0.8) + useful = ActionEffectEvidence( + "2025-01-01", + "2026-01-01", + 300, + 60, + 1.0, + 0.8, + per_control_episode_counts={signal_id: 100 for signal_id in CONTROL_IDS}, + conservative_coverage=0.96, + sign_stable_folds=3, + shadow_replay_passed=True, + pilot_approved=True, + ) assert not assess_action_capability(controls, weak).supports_actions assert assess_action_capability(controls, useful).supports_actions +def test_action_capability_stays_disabled_without_pilot_even_with_good_metrics() -> None: + controls = _controls() + evidence = ActionEffectEvidence( + "2025-01-01", + "2026-01-01", + 300, + 60, + 1.0, + 0.8, + per_control_episode_counts={signal_id: 100 for signal_id in CONTROL_IDS}, + conservative_coverage=0.96, + sign_stable_folds=3, + shadow_replay_passed=True, + pilot_approved=False, + ) + + report = assess_action_capability(controls, evidence) + + assert report.supports_actions is False + assert "TECHNOLOGIST_PILOT_MISSING" in report.reason_codes + + +def test_change_episode_extraction_keeps_only_isolated_stable_interventions() -> None: + timestamp = pd.date_range("2025-01-01", periods=40, freq="10min", tz="UTC") + frame = pd.DataFrame( + { + "timestamp": timestamp, + "ht:P8": np.r_[np.full(10, 10.0), np.full(30, 11.0)], + "ht:T11": np.full(40, 100.0), + "ht:F19": np.full(40, 30.0), + "ht:2:Mg.Sulfur": np.r_[np.full(10, 10.0), np.linspace(10.0, 8.0, 30)], + } + ) + + episodes = extract_change_episodes( + frame, + {"ht:P8": 0.5, "ht:T11": 1.0, "ht:F19": 0.5}, + horizons_minutes=(30, 60), + ) + + assert len(episodes) == 1 + assert episodes.iloc[0]["control_id"] == "ht:P8" + assert episodes.iloc[0]["control_delta"] == pytest.approx(1.0) + + def test_grid_is_deterministic_bounded_and_jointly_filtered() -> None: frame = _training_frame() domain = fit_joint_control_domain(frame, coverage=0.99) diff --git a/requirements.lock.txt b/requirements.lock.txt index c1613d7..a7f23ce 100644 --- a/requirements.lock.txt +++ b/requirements.lock.txt @@ -7,6 +7,7 @@ openpyxl==3.1.5 tzdata==2026.3 scikit-learn==1.9.0 joblib==1.6.0 +lightgbm==4.7.0 # Development and acceptance tools mypy==1.20.2 diff --git a/requirements.txt b/requirements.txt index 36bcf02..0f062b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,8 +3,9 @@ numpy>=1.26.0,<2 pydantic>=2.6.0,<3 openpyxl>=3.1.0,<4 tzdata>=2024.1.0 -scikit-learn>=1.5.0,<2 -joblib>=1.4.0,<2 +scikit-learn>=1.5.0,<2 +joblib>=1.4.0,<2 +lightgbm>=4.5.0,<5 # Development tools mypy>=1.13,<2 diff --git a/source/agents/quality.py b/source/agents/quality.py index f694346..19ee047 100644 --- a/source/agents/quality.py +++ b/source/agents/quality.py @@ -179,6 +179,8 @@ def _forecast_history_quality( ) prediction = float(predict(features)[0]) upper: float | None = None + exceedance_probability: float | None = None + alarm = False interval_kind = IntervalKind.NONE interval_level: float | None = None if getattr(capabilities, "supports_uncertainty", False): @@ -193,46 +195,82 @@ def _forecast_history_quality( upper = float(predict_upper(features)[0]) interval_kind = IntervalKind.EMPIRICAL interval_level = 0.95 - issues: tuple[Issue, ...] = () + issues_list: list[Issue] = [] status = AssessmentStatus.OK if scenario.require_upper_bound and upper is None: status = AssessmentStatus.DEGRADED - issues = ( + issues_list.append( Issue( code="UNCERTAINTY_UNAVAILABLE", severity=Severity.BLOCKING, signal_id=target_signal, detail="Stage-2 point forecasts have no validated upper interval.", source_ref=f"model:{metadata.model_id}", + ) + ) + if getattr(capabilities, "supports_exceedance_probability", False): + predict_probability = getattr(model, "predict_exceedance_probability", None) + predict_alarm = getattr(model, "predict_alarm", None) + if not callable(predict_probability) or not callable(predict_alarm): + return _unavailable( + state, + "RISK_MODEL_UNAVAILABLE", + target_signal, + "The artifact declares exceedance risk but exposes no compatible predictor.", + ) + exceedance_probability = float(predict_probability(features)[0]) + alarm = bool(predict_alarm(features)[0]) + if alarm: + status = AssessmentStatus.DEGRADED + issues_list.append( + Issue( + code="SULFUR_EXCEEDANCE_RISK", + severity=Severity.BLOCKING, + signal_id=target_signal, + detail="The calibrated safety head predicts material risk of S > 10 mg/kg.", + source_ref=f"model:{metadata.model_id}", + ) + ) + metrics = { + "sulfur": MetricEstimate( + value=prediction, + lower=None, + upper=upper, + unit=target_unit, + basis=EstimateBasis.FORECAST, + interval_kind=interval_kind, + interval_level=interval_level, + reference=f"model:{metadata.model_id}", + assumptions=( + f"{horizon_minutes}-minute point forecast", + f"training target source: {metadata.target_source}", + "supports_forecast does not imply supports_actions", + "0.95 empirical coverage is not a safety guarantee" + if upper is not None + else "upper estimate unavailable", ), ) + } + if exceedance_probability is not None: + metrics["sulfur_exceedance_probability"] = MetricEstimate( + value=exceedance_probability, + lower=None, + upper=None, + unit="index_0_1", + basis=EstimateBasis.FORECAST, + interval_kind=IntervalKind.NONE, + interval_level=None, + reference=f"model:{metadata.model_id}", + assumptions=("validation-calibrated probability", "alarm threshold stored in artifact"), + ) return AgentAssessment( agent=AssessmentAgent.QUALITY, state_id=state.state_id, candidate_id="hold", evaluated_for=state.as_of + timedelta(minutes=horizon_minutes), status=status, - metrics={ - "sulfur": MetricEstimate( - value=prediction, - lower=None, - upper=upper, - unit=target_unit, - basis=EstimateBasis.FORECAST, - interval_kind=interval_kind, - interval_level=interval_level, - reference=f"model:{metadata.model_id}", - assumptions=( - f"{horizon_minutes}-minute point forecast", - f"training target source: {metadata.target_source}", - "supports_forecast does not imply supports_actions", - "0.95 empirical coverage is not a safety guarantee" - if upper is not None - else "upper estimate unavailable", - ), - ) - }, - issues=issues, + metrics=metrics, + issues=tuple(issues_list), ) diff --git a/source/main.py b/source/main.py index 39b92de..67597a9 100644 --- a/source/main.py +++ b/source/main.py @@ -233,14 +233,22 @@ def train_command( *, target_signal: str = "ht:2:Mg.Sulfur", with_uncertainty: bool = False, + with_safety: bool = False, config_path: str | Path = "config/runtime.toml", root: Path = PROJECT_ROOT, ) -> dict[str, object]: """Train the selected point model and optionally its frozen upper model.""" + from source.ml.safety import save_safety_model from source.ml.train import train_model from source.ml.uncertainty import save_stage5_model +<<<<<<< HEAD source, target_output = _source_or_output(target_source, output) +======= + if with_safety and not with_uncertainty: + raise ValueError("--with-safety requires --with-uncertainty") + +>>>>>>> f0ad14f (Complete ML stages and safety diagnostics) config = load_runtime_config(_resolve_path(config_path, root)) revision = _git_revision(root) data = load_prepared_dataset(_resolve_path(dataset, root)) @@ -283,9 +291,82 @@ def train_command( "test_applicability_rate": fitted.report["test_applicability_rate"], } ) + if with_safety: + safety_recipe = f"{data.manifest.dataset_id}:{upper.metadata.model_id}:safety:1.1" + safety_id = f"sulfur-safety-{hashlib.sha256(safety_recipe.encode()).hexdigest()[:12]}" + safety_path = models_root / safety_id + safety, safety_fit = save_safety_model( + safety_path, + supervised, + upper, + source_timezone=config.source_timezone, + seed=config.seed, + include_lightgbm=True, + ) + result.update( + { + "safety_model_id": safety.metadata.model_id, + "safety_model_path": safety_path.as_posix(), + "alarm_threshold": safety_fit.report["alarm_threshold"], + "validation_safety": safety_fit.report["validation_policy"], + "test_safety": safety_fit.report["test"], + "test_transition_recall": safety_fit.report["test_transition_recall"], + "test_joint_applicability_rate": safety_fit.report["test_applicability_rate"], + } + ) return result +def diagnose_ml_command( + dataset: str | Path, + *, + root: Path = PROJECT_ROOT, +) -> dict[str, object]: + """Run read-only alignment, drift and telemetry diagnostics.""" + from source.ml.diagnostics import build_diagnostic_report + + data = load_prepared_dataset(_resolve_path(dataset, root)) + supervised = _supervised_dataset(data, SourceKind.PAK) + return build_diagnostic_report(data, supervised) + + +def train_v2_shadow_command( + dataset: str | Path, + output: str | Path | None = None, + *, + config_path: str | Path = "config/runtime.toml", + root: Path = PROJECT_ROOT, +) -> dict[str, object]: + """Train and persist a schema-1.2 artifact that is restricted to shadow use.""" + from source.ml.v2 import save_episode_safety_model + + config = load_runtime_config(_resolve_path(config_path, root)) + revision = _git_revision(root) + data = load_prepared_dataset(_resolve_path(dataset, root)) + supervised = _supervised_dataset(data, SourceKind.PAK) + models_root = _resolve_path(output if output is not None else config.models_dir, root) + recipe = f"{data.manifest.dataset_id}:episode-multihorizon:1.2:{revision}" + model_id = f"sulfur-v2-shadow-{hashlib.sha256(recipe.encode()).hexdigest()[:12]}" + path = models_root / model_id + bundle, fitted = save_episode_safety_model( + path, + data, + supervised, + git_commit=revision, + seed=config.seed, + ) + return { + "dataset_id": data.manifest.dataset_id, + "model_id": bundle.metadata.model_id, + "model_path": path.as_posix(), + "schema_version": bundle.metadata.schema_version, + "production_status": "shadow_only", + "selected_family": fitted.report["selected_family"], + "threshold_metrics": fitted.report["threshold_metrics"], + "audit_2026": fitted.report["audit_2026"], + } + + def _load_trusted_model(model_path: str | Path, data: PreparedData, root: Path) -> ModelBundle: from source.ml.artifacts import load_model @@ -485,6 +566,21 @@ def main(argv: Sequence[str] | None = None) -> int: action="store_true", help="also fit the Stage-5 empirical upper model", ) + train.add_argument( + "--with-safety", + action="store_true", + help="also fit the calibrated safety alarm and joint applicability model", + ) + diagnose = subparsers.add_parser( + "diagnose-ml", help="inspect sulfur alignment, temporal drift and candidate signals" + ) + diagnose.add_argument("--dataset", required=True, help="prepared dataset directory") + v2 = subparsers.add_parser( + "train-v2-shadow", help="train the episode-aware schema-1.2 shadow artifact" + ) + v2.add_argument("--dataset", required=True, help="prepared dataset directory") + v2.add_argument("--output", default=None, help="model artifacts root") + v2.add_argument("--config", default="config/runtime.toml", help="runtime config path") evaluate = subparsers.add_parser( "evaluate", help="compare a frozen model with persistence on one temporal split" ) @@ -560,10 +656,19 @@ def main(argv: Sequence[str] | None = None) -> int: args.output, target_signal=args.target_signal, with_uncertainty=args.with_uncertainty, + with_safety=args.with_safety, config_path=args.config, ) print(json.dumps(training_result, ensure_ascii=False, indent=2)) return 0 + if args.command == "diagnose-ml": + diagnostic_result = diagnose_ml_command(args.dataset) + print(json.dumps(diagnostic_result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "train-v2-shadow": + v2_result = train_v2_shadow_command(args.dataset, args.output, config_path=args.config) + print(json.dumps(v2_result, ensure_ascii=False, indent=2)) + return 0 if args.command == "evaluate": evaluation_result = evaluate_command( args.dataset, diff --git a/source/ml/__init__.py b/source/ml/__init__.py index 493ab99..5fc996a 100644 --- a/source/ml/__init__.py +++ b/source/ml/__init__.py @@ -5,6 +5,7 @@ from importlib import import_module _EXPORT_MODULES = { + "ActionModelBundle": "source.ml.action_effects", "EXPERT_VAK_CORRECTIONS": "source.ml.formulas", "GAS_CONTEXT_SIGNAL_IDS": "source.ml.blending", "DEFAULT_POLICY": "source.ml.policy", @@ -12,10 +13,15 @@ "BlendOption": "source.ml.blending", "BlendResult": "source.ml.blending", "CalibratedPointUpperRegressor": "source.ml.uncertainty", + "CompositeSafetyPredictor": "source.ml.safety", + "EpisodeDataset": "source.ml.v2", + "EpisodeFitResult": "source.ml.v2", + "EpisodeSafetyPredictor": "source.ml.v2", "FeatureFrame": "source.ml.features", "GasContextSignal": "source.ml.blending", "HybridComponentForecast": "source.ml.blending", "ModelBundle": "source.ml.artifacts", + "SafetyFitResult": "source.ml.safety", "PolicyDecision": "source.ml.policy", "PolicyParameters": "source.ml.policy", "PolicyReplayCase": "source.ml.policy", @@ -31,6 +37,7 @@ "apply_hydrotreater_forecast": "source.ml.blending", "assess_change_policy": "source.ml.policy", "build_features": "source.ml.features", + "build_episode_dataset": "source.ml.v2", "build_supervised_dataset": "source.ml.features", "calculate_mass_blend": "source.ml.blending", "check_applicability": "source.ml.uncertainty", @@ -40,6 +47,10 @@ "evaluate_robustness_cases": "source.ml.uncertainty", "evaluate_upper_bounds": "source.ml.uncertainty", "fit_stage5_uncertainty": "source.ml.uncertainty", + "fit_joint_applicability": "source.ml.safety", + "fit_lims_correction": "source.ml.safety", + "fit_safety_model": "source.ml.safety", + "fit_episode_safety_model": "source.ml.v2", "fit_upper_calibrator": "source.ml.uncertainty", "load_lab_parameters": "source.ml.formulas", "load_model": "source.ml.artifacts", @@ -47,6 +58,8 @@ "pinball_loss": "source.ml.uncertainty", "rank_feasible_blends": "source.ml.blending", "save_stage5_model": "source.ml.uncertainty", + "save_safety_model": "source.ml.safety", + "save_episode_safety_model": "source.ml.v2", "sulfur_constraint_status": "source.ml.blending", "tune_policy": "source.ml.policy", "train_model": "source.ml.train", @@ -64,6 +77,7 @@ def __getattr__(name: str) -> object: __all__ = [ + "ActionModelBundle", "EXPERT_VAK_CORRECTIONS", "GAS_CONTEXT_SIGNAL_IDS", "DEFAULT_POLICY", @@ -71,10 +85,15 @@ def __getattr__(name: str) -> object: "BlendOption", "BlendResult", "CalibratedPointUpperRegressor", + "CompositeSafetyPredictor", + "EpisodeDataset", + "EpisodeFitResult", + "EpisodeSafetyPredictor", "FeatureFrame", "GasContextSignal", "HybridComponentForecast", "ModelBundle", + "SafetyFitResult", "PolicyDecision", "PolicyParameters", "PolicyReplayCase", @@ -90,6 +109,7 @@ def __getattr__(name: str) -> object: "apply_hydrotreater_forecast", "assess_change_policy", "build_features", + "build_episode_dataset", "build_supervised_dataset", "calculate_mass_blend", "check_applicability", @@ -99,6 +119,10 @@ def __getattr__(name: str) -> object: "evaluate_robustness_cases", "evaluate_upper_bounds", "fit_stage5_uncertainty", + "fit_joint_applicability", + "fit_lims_correction", + "fit_safety_model", + "fit_episode_safety_model", "fit_upper_calibrator", "load_lab_parameters", "load_model", @@ -106,6 +130,8 @@ def __getattr__(name: str) -> object: "pinball_loss", "rank_feasible_blends", "save_stage5_model", + "save_safety_model", + "save_episode_safety_model", "sulfur_constraint_status", "tune_policy", "train_model", diff --git a/source/ml/action_effects.py b/source/ml/action_effects.py index bff8068..f3f6168 100644 --- a/source/ml/action_effects.py +++ b/source/ml/action_effects.py @@ -6,7 +6,28 @@ from math import isfinite from source.contracts import CandidateAction, CandidateKind, ControlSpec -from source.ml.controls import JointControlDomain +from source.ml.controls import ActionEffectEvidence, JointControlDomain + + +@dataclass(frozen=True) +class ActionModelBundle: + """Action model kept separate from an ordinary forecast artifact.""" + + predictor: object + control_ids: tuple[str, ...] + outcome_names: tuple[str, ...] + horizons_minutes: tuple[int, ...] + joint_domain: JointControlDomain + evidence: ActionEffectEvidence + supports_actions: bool = False + + def __post_init__(self) -> None: + if not self.control_ids or not self.outcome_names or not self.horizons_minutes: + raise ValueError("action bundle needs controls, outcomes and horizons") + if self.supports_actions and not ( + self.evidence.shadow_replay_passed and self.evidence.pilot_approved + ): + raise ValueError("action capability requires shadow replay and technologist pilot") @dataclass(frozen=True) @@ -169,6 +190,7 @@ def rank_linear_actions( __all__ = [ + "ActionModelBundle", "ActionOutcome", "LinearActionEffectModel", "evaluate_linear_action", diff --git a/source/ml/artifacts.py b/source/ml/artifacts.py index 6bb5805..b066021 100644 --- a/source/ml/artifacts.py +++ b/source/ml/artifacts.py @@ -18,7 +18,8 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from sklearn.base import BaseEstimator, RegressorMixin -MODEL_METADATA_VERSION: Literal["1.0"] = "1.0" +MODEL_METADATA_VERSION: Literal["1.2"] = "1.2" +SUPPORTED_MODEL_METADATA_VERSIONS = frozenset({"1.0", "1.1", "1.2"}) _SHA256_PATTERN = r"^[0-9a-f]{64}$" @@ -30,6 +31,8 @@ class ModelCapabilities(BaseModel): supports_forecast: bool supports_actions: bool supports_uncertainty: bool = False + supports_exceedance_probability: bool = False + supports_multi_horizon: bool = False class ModelMetadata(BaseModel): @@ -38,7 +41,7 @@ class ModelMetadata(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) model_id: str - schema_version: Literal["1.0"] = MODEL_METADATA_VERSION + schema_version: Literal["1.0", "1.1", "1.2"] = "1.0" model_sha256: str = Field(pattern=_SHA256_PATTERN) model_type: str training_dataset_id: str @@ -85,6 +88,8 @@ def validate_features_and_capabilities(self) -> Self: raise ValueError("a stage-2 artifact must support forecasting") if self.capabilities.supports_actions: raise ValueError("a stage-2 forecast cannot claim action support") + if self.capabilities.supports_multi_horizon and self.schema_version != "1.2": + raise ValueError("multi-horizon capability requires schema 1.2") return self @@ -156,10 +161,101 @@ def predict_upper(self, features: pd.DataFrame) -> np.ndarray: raise ValueError("upper prediction cannot be below point prediction") return prediction + def predict_exceedance_probability(self, features: pd.DataFrame) -> np.ndarray: + """Return a calibrated probability only when the artifact declares it.""" + if not self.metadata.capabilities.supports_exceedance_probability: + raise ValueError("model artifact does not declare exceedance probability support") + self._check_feature_order(features) + predict_probability = getattr(self.predictor, "predict_exceedance_probability", None) + if not callable(predict_probability): + raise ValueError( + "safety predictor must expose predict_exceedance_probability(features)" + ) + probability = np.asarray(predict_probability(features), dtype=float) + if probability.shape != (len(features),) or not np.isfinite(probability).all(): + raise ValueError("risk predictor must return one finite value per feature row") + if np.any((probability < 0.0) | (probability > 1.0)): + raise ValueError("exceedance probabilities must be in [0, 1]") + return probability + + def predict_alarm(self, features: pd.DataFrame) -> np.ndarray: + """Apply the validation-selected threshold stored in the predictor.""" + self.predict_exceedance_probability(features) + predict_alarm = getattr(self.predictor, "predict_alarm", None) + if not callable(predict_alarm): + raise ValueError("safety predictor must expose predict_alarm(features)") + alarm = np.asarray(predict_alarm(features), dtype=bool) + if alarm.shape != (len(features),): + raise ValueError("alarm predictor must return one flag per feature row") + return alarm + + def predict_safety(self, features: pd.DataFrame) -> list[dict[str, object]]: + """Return the complete schema-1.1 safety contract for every row.""" + point = self.predict(features) + upper = self.predict_upper(features) + probability = self.predict_exceedance_probability(features) + alarm = self.predict_alarm(features) + rows: list[dict[str, object]] = [] + for position in range(len(features)): + applicability = self.check_applicability(features.iloc[[position]]) + available = bool(getattr(applicability, "available", False)) + reason = getattr(applicability, "reason_code", None) + rows.append( + { + "point": float(point[position]), + "upper": float(upper[position]), + "exceedance_probability": float(probability[position]), + "alarm": bool(alarm[position]), + "applicable": available, + "reason_codes": () if reason is None else (str(reason),), + } + ) + return rows + + def predict_v2(self, features: pd.DataFrame) -> list[dict[str, object]]: + """Return the schema-1.2 multi-horizon contract after capability checks.""" + if not self.metadata.capabilities.supports_multi_horizon: + raise ValueError("model artifact does not declare multi-horizon support") + self._check_feature_order(features) + predict = getattr(self.predictor, "predict_v2", None) + if not callable(predict): + raise ValueError("v2 predictor must expose predict_v2(features)") + rows = predict(features) + if not isinstance(rows, list) or len(rows) != len(features): + raise ValueError("v2 predictor must return one object per feature row") + required = { + "point", + "upper", + "exceedance_probability", + "alarm", + "horizon_probabilities", + "crossing_probability_60m", + "event_alarm", + "predicted_delta", + "point_60m", + "upper_60m", + "applicable", + "reason_codes", + } + if any(not isinstance(row, Mapping) or not required.issubset(row) for row in rows): + raise ValueError("v2 predictor returned an incomplete safety contract") + return rows + + def _check_feature_order(self, features: pd.DataFrame) -> None: + actual = tuple(str(name) for name in features.columns) + if actual != self.metadata.feature_names: + raise ValueError( + f"feature order mismatch: expected {self.metadata.feature_names}, received {actual}" + ) + def check_applicability(self, features: pd.DataFrame) -> object: """Check persisted train-only feature bounds before a Stage-5 forecast.""" from source.ml.uncertainty import ApplicabilityResult, check_applicability + joint_check = getattr(self.predictor, "check_applicability", None) + if callable(joint_check): + return joint_check(features) + raw_bounds = self.metadata.applicability.get("feature_bounds") if not isinstance(raw_bounds, Mapping): return ApplicabilityResult(False, "APPLICABILITY_UNAVAILABLE") @@ -239,7 +335,7 @@ def load_model( directory: Path, *, trusted: bool = False, - expected_schema_version: str = MODEL_METADATA_VERSION, + expected_schema_version: str | None = None, expected_horizon_minutes: int | None = None, expected_feature_names: Sequence[str] | None = None, expected_feature_schema_hash: str | None = None, @@ -257,7 +353,9 @@ def load_model( metadata = ModelMetadata.model_validate_json(metadata_path.read_text(encoding="utf-8")) if metadata.model_id != directory.name: raise ValueError("metadata model_id does not match the artifact directory") - if metadata.schema_version != expected_schema_version: + if metadata.schema_version not in SUPPORTED_MODEL_METADATA_VERSIONS: + raise ValueError("model schema version is unsupported") + if expected_schema_version is not None and metadata.schema_version != expected_schema_version: raise ValueError("model schema version is incompatible") if _python_major_minor(metadata.python_version) != platform.python_version_tuple()[:2]: raise ValueError("model Python version is incompatible") @@ -309,6 +407,7 @@ def _write_json(path: Path, payload: Mapping[str, Any]) -> None: __all__ = [ "MODEL_METADATA_VERSION", + "SUPPORTED_MODEL_METADATA_VERSIONS", "LastValueRegressor", "ModelBundle", "ModelCapabilities", diff --git a/source/ml/controls.py b/source/ml/controls.py index 3e99443..7a688c6 100644 --- a/source/ml/controls.py +++ b/source/ml/controls.py @@ -33,6 +33,7 @@ CONTROL_EVIDENCE = ( "materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026" ) +ACTION_HORIZONS_MINUTES = (15, 30, 60, 120, 180) @dataclass(frozen=True) @@ -98,6 +99,11 @@ class ActionEffectEvidence: best_lag_minutes: int baseline_mae: float action_model_mae: float + per_control_episode_counts: dict[str, int] | None = None + conservative_coverage: float | None = None + sign_stable_folds: int | None = None + shadow_replay_passed: bool = False + pilot_approved: bool = False @dataclass(frozen=True) @@ -108,6 +114,88 @@ class ActionCapabilityReport: evidence: ActionEffectEvidence | None +def extract_change_episodes( + telemetry: pd.DataFrame, + change_thresholds: dict[str, float], + *, + target_signal: str = "ht:2:Mg.Sulfur", + horizons_minutes: tuple[int, ...] = ACTION_HORIZONS_MINUTES, + stable_window_minutes: int = 60, +) -> pd.DataFrame: + """Extract isolated natural control changes for offline action research. + + The result is observational evidence only. It cannot enable controls by + itself because operator feedback and unobserved feed changes may confound it. + """ + required = {"timestamp", target_signal, *CONTROL_IDS} + missing = sorted(required.difference(telemetry.columns)) + if missing: + raise ValueError(f"telemetry is missing action-research columns: {missing}") + if set(change_thresholds) != set(CONTROL_IDS) or any( + not np.isfinite(value) or value <= 0 for value in change_thresholds.values() + ): + raise ValueError("positive finite change thresholds are required for every control") + if ( + stable_window_minutes <= 0 + or not horizons_minutes + or any(horizon <= 0 for horizon in horizons_minutes) + ): + raise ValueError("stable window and action horizons must be positive") + + frame = telemetry.loc[:, ["timestamp", *CONTROL_IDS, target_signal]].copy() + frame["timestamp"] = pd.to_datetime(frame["timestamp"], utc=True, errors="raise") + frame = frame.sort_values("timestamp").drop_duplicates("timestamp", keep=False) + frame = frame.set_index("timestamp") + numeric = frame.apply(pd.to_numeric, errors="coerce") + changes = numeric.loc[:, list(CONTROL_IDS)].diff() + rows: list[dict[str, Any]] = [] + for timestamp, deltas in changes.iterrows(): + active = [ + signal_id + for signal_id in CONTROL_IDS + if pd.notna(deltas[signal_id]) + and abs(float(deltas[signal_id])) >= change_thresholds[signal_id] + ] + if len(active) != 1: + continue + signal_id = active[0] + start = timestamp - pd.Timedelta(minutes=stable_window_minutes) + before = numeric.loc[(numeric.index >= start) & (numeric.index < timestamp)] + if len(before) < 3 or before[[*CONTROL_IDS, target_signal]].isna().any().any(): + continue + if any( + before[control].diff().abs().max() >= change_thresholds[control] + for control in CONTROL_IDS + ): + continue + baseline = float(before[target_signal].iloc[-1]) + row: dict[str, Any] = { + "changed_at": timestamp, + "control_id": signal_id, + "control_delta": float(deltas[signal_id]), + "baseline_quality": baseline, + } + complete = True + for horizon in horizons_minutes: + target_at = timestamp + pd.Timedelta(minutes=horizon) + position = numeric.index.searchsorted(target_at) + if position >= len(numeric.index): + complete = False + break + observed_at = numeric.index[position] + if observed_at - target_at > pd.Timedelta(minutes=10): + complete = False + break + value = numeric.iloc[position][target_signal] + if pd.isna(value): + complete = False + break + row[f"delta_quality_{horizon}m"] = float(value) - baseline + if complete: + rows.append(row) + return pd.DataFrame(rows) + + def unconfirmed_real_controls() -> tuple[ControlSpec, ...]: """Return confirmed meanings while keeping unsupported real limits disabled.""" return tuple( @@ -188,7 +276,7 @@ def assess_action_capability( controls: tuple[ControlSpec, ...], evidence: ActionEffectEvidence | None, *, - minimum_episodes: int = 20, + minimum_episodes: int = 100, ) -> ActionCapabilityReport: """Gate action support independently from ordinary forecast performance.""" reasons: list[str] = [] @@ -205,6 +293,11 @@ def assess_action_capability( else: if evidence.change_episode_count < minimum_episodes: reasons.append("INSUFFICIENT_CHANGE_EPISODES") + counts = evidence.per_control_episode_counts + if counts is None or any( + counts.get(control.signal_id, 0) < minimum_episodes for control in controls + ): + reasons.append("INSUFFICIENT_PER_CONTROL_EPISODES") if not np.isfinite(evidence.change_episode_count) or evidence.change_episode_count < 0: reasons.append("ACTION_EPISODE_COUNT_INVALID") if not all( @@ -226,6 +319,16 @@ def assess_action_capability( and evidence.action_model_mae >= evidence.baseline_mae ): reasons.append("ACTION_MODEL_NO_TEMPORAL_GAIN") + elif evidence.action_model_mae > 0.9 * evidence.baseline_mae: + reasons.append("ACTION_MODEL_GAIN_BELOW_10_PERCENT") + if evidence.conservative_coverage is None or evidence.conservative_coverage < 0.95: + reasons.append("ACTION_INTERVAL_COVERAGE_INSUFFICIENT") + if evidence.sign_stable_folds != 3: + reasons.append("ACTION_EFFECT_SIGN_UNSTABLE") + if not evidence.shadow_replay_passed: + reasons.append("SHADOW_REPLAY_MISSING") + if not evidence.pilot_approved: + reasons.append("TECHNOLOGIST_PILOT_MISSING") return ActionCapabilityReport( supports_actions=not reasons, reason_codes=tuple(reasons), @@ -319,6 +422,7 @@ def generate_setpoint_candidates( __all__ = [ "ActionCapabilityReport", + "ACTION_HORIZONS_MINUTES", "ActionEffectEvidence", "CONTROL_EVIDENCE", "CONTROL_IDS", @@ -326,6 +430,7 @@ def generate_setpoint_candidates( "JointControlDomain", "ObservedControlStats", "assess_action_capability", + "extract_change_episodes", "fit_joint_control_domain", "generate_setpoint_candidates", "summarize_observed_controls", diff --git a/source/ml/diagnostics.py b/source/ml/diagnostics.py new file mode 100644 index 0000000..856aad7 --- /dev/null +++ b/source/ml/diagnostics.py @@ -0,0 +1,325 @@ +"""Reproducible diagnostics for sulfur alignment, drift and feature usefulness.""" + +from __future__ import annotations + +from typing import Any, Iterable + +import numpy as np +import pandas as pd + +from source.contracts import SourceKind +from source.data.prepare import PreparedData +from source.ml.evaluate import make_outer_split +from source.ml.features import SupervisedDataset + +SULFUR_SIGNAL = "ht:2:Mg.Sulfur" +SULFUR_LIMIT = 10.0 +CONFIRMED_LIMS_OUTLIER_IDS = frozenset( + { + "c8a61777-9c04-5999-9a7c-ad46bc5e5d60", + "8e60da42-5108-5e92-a1aa-8608455aa850", + "26a758a9-3257-5e19-91da-efc9d04fc57d", + "41e64ee3-d1c7-5553-b630-ed28bfc5f4dc", + "74afa5b5-95e6-592c-80d2-ab41edffb64e", + "4b4cb099-2147-5c9f-b632-c07bd0604cf8", + } +) + + +def _source_value(value: object) -> str: + return str(getattr(value, "value", value)) + + +def _sulfur_rows(data: PreparedData, source: SourceKind) -> pd.DataFrame: + quality = data.quality + rows = quality[ + quality["signal_id"].astype(str).eq(SULFUR_SIGNAL) + & quality["source"].map(_source_value).eq(source.value) + ].loc[:, ["observation_id", "measured_at", "value"]] + rows = rows.copy() + rows["measured_at"] = pd.to_datetime(rows["measured_at"], utc=True) + rows["value"] = pd.to_numeric(rows["value"], errors="coerce") + return rows.dropna().sort_values("measured_at", kind="stable") + + +def exclude_confirmed_lims_outliers(frame: pd.DataFrame) -> tuple[pd.DataFrame, tuple[str, ...]]: + """Exclude only user-confirmed observation IDs while retaining raw provenance.""" + if "observation_id" not in frame.columns: + raise ValueError("LIMS outlier policy requires observation_id") + ids = frame["observation_id"].astype(str) + excluded = tuple(sorted(set(ids).intersection(CONFIRMED_LIMS_OUTLIER_IDS))) + return frame.loc[~ids.isin(CONFIRMED_LIMS_OUTLIER_IDS)].copy(), excluded + + +def pak_lims_alignment( + data: PreparedData, + *, + offsets_minutes: Iterable[int] = range(-180, 181, 30), +) -> dict[str, Any]: + """Compare timestamp shifts before and after confirmed observation exclusions.""" + pak = _sulfur_rows(data, SourceKind.PAK) + lims = _sulfur_rows(data, SourceKind.LIMS) + eligible_lims, excluded_ids = exclude_confirmed_lims_outliers(lims) + + def evaluate(selected: pd.DataFrame) -> list[dict[str, float | int | None]]: + result: list[dict[str, float | int | None]] = [] + for offset in offsets_minutes: + queries = selected.assign( + query=selected["measured_at"] + pd.Timedelta(minutes=int(offset)) + ) + matched = pd.merge_asof( + queries.sort_values("query", kind="stable"), + pak, + left_on="query", + right_on="measured_at", + direction="nearest", + tolerance=pd.Timedelta(minutes=20), + suffixes=("_lims", "_pak"), + ).dropna(subset=["value_lims", "value_pak"]) + correlation = ( + matched["value_lims"].corr(matched["value_pak"]) + if len(matched) >= 2 + and matched["value_lims"].std() > 0.0 + and matched["value_pak"].std() > 0.0 + else np.nan + ) + result.append( + { + "offset_minutes": int(offset), + "n": int(len(matched)), + "mae": float(np.mean(np.abs(matched["value_lims"] - matched["value_pak"]))) + if len(matched) + else None, + "bias_lims_minus_pak": float( + np.mean(matched["value_lims"] - matched["value_pak"]) + ) + if len(matched) + else None, + "correlation": float(correlation) if pd.notna(correlation) else None, + } + ) + return result + + return { + "raw": evaluate(lims), + "training_policy": { + "interpretation": "confirmed outliers stay in prepared data but are excluded from ML", + "excluded_count": len(excluded_ids), + "excluded_observation_ids": excluded_ids, + "excluded_values": sorted( + float(value) + for value in lims.loc[ + lims["observation_id"].astype(str).isin(excluded_ids), "value" + ] + ), + "results": evaluate(eligible_lims), + }, + } + + +def temporal_drift(dataset: SupervisedDataset) -> dict[str, Any]: + """Describe target drift and persistence failure on immutable temporal periods.""" + frame = dataset.frame.reset_index(drop=True) + split = make_outer_split(frame) + periods = {"train": split.train, "validation": split.validation, "test": split.test} + result: dict[str, Any] = {} + for name, indices in periods.items(): + selected = frame.iloc[indices] + actual = pd.to_numeric(selected["y"], errors="coerce").to_numpy(dtype=float) + baseline = pd.to_numeric(selected[dataset.baseline_feature], errors="coerce").to_numpy( + dtype=float + ) + valid = np.isfinite(actual) & np.isfinite(baseline) + actual = actual[valid] + baseline = baseline[valid] + transition = (baseline <= SULFUR_LIMIT) & (actual > SULFUR_LIMIT) + correlation = ( + float(np.corrcoef(baseline, actual)[0, 1]) + if baseline.std() > 0.0 and actual.std() > 0.0 + else None + ) + result[name] = { + "n": int(len(actual)), + "target_mean": float(actual.mean()), + "target_std": float(actual.std()), + "exceedance_rate": float(np.mean(actual > SULFUR_LIMIT)), + "transition_rate": float(transition.mean()), + "transition_count": int(transition.sum()), + "persistence_mae": float(np.mean(np.abs(actual - baseline))), + "current_future_correlation": correlation, + } + return result + + +def regime_diagnostics(dataset: SupervisedDataset) -> dict[str, Any]: + """Locate persistence errors in operationally meaningful current-sulfur bands.""" + frame = dataset.frame.reset_index(drop=True) + split = make_outer_split(frame) + periods = {"train": split.train, "validation": split.validation, "test": split.test} + bins = (-np.inf, 8.0, 10.0, 12.0, np.inf) + labels = ("below_8", "8_to_10", "10_to_12", "above_12") + result: dict[str, Any] = {} + for period, indices in periods.items(): + selected = frame.iloc[indices] + actual = pd.to_numeric(selected["y"], errors="coerce") + baseline = pd.to_numeric(selected[dataset.baseline_feature], errors="coerce") + band = pd.cut(baseline, bins=bins, labels=labels, right=False) + period_rows: dict[str, Any] = {} + for label in labels: + mask = band.eq(label) & actual.notna() & baseline.notna() + y = actual[mask].to_numpy(dtype=float) + current = baseline[mask].to_numpy(dtype=float) + period_rows[label] = { + "n": int(mask.sum()), + "persistence_mae": float(np.mean(np.abs(y - current))) if len(y) else None, + "future_exceedance_rate": float(np.mean(y > SULFUR_LIMIT)) if len(y) else None, + "transition_count": int(((current <= SULFUR_LIMIT) & (y > SULFUR_LIMIT)).sum()), + } + result[period] = period_rows + return result + + +def telemetry_residual_screen( + data: PreparedData, + dataset: SupervisedDataset, + *, + top_n: int = 20, +) -> list[dict[str, Any]]: + """Rank train-only associations and expose whether their sign survives later periods.""" + frame = dataset.frame.reset_index(drop=True) + split = make_outer_split(frame) + queries = frame.loc[:, ["as_of", "y", dataset.baseline_feature]].copy() + queries["as_of"] = pd.to_datetime(queries["as_of"], utc=True) + telemetry = data.telemetry.copy() + telemetry["timestamp"] = pd.to_datetime(telemetry["timestamp"], utc=True) + signal_names = tuple( + name for name in data.feature_order if name in telemetry.columns and name != "timestamp" + ) + matched = pd.merge_asof( + queries.sort_values("as_of", kind="stable"), + telemetry.loc[:, ["timestamp", *signal_names]].sort_values("timestamp", kind="stable"), + left_on="as_of", + right_on="timestamp", + direction="backward", + tolerance=pd.Timedelta(minutes=20), + ).sort_index(kind="stable") + residual = pd.to_numeric(matched["y"], errors="coerce") - pd.to_numeric( + matched[dataset.baseline_feature], errors="coerce" + ) + + def correlation(signal: str, indices: np.ndarray) -> tuple[int, float | None]: + x = pd.to_numeric(matched.iloc[indices][signal], errors="coerce") + y = residual.iloc[indices] + valid = x.notna() & y.notna() + value = ( + x[valid].corr(y[valid]) + if valid.sum() >= 100 and x[valid].std() > 0.0 and y[valid].std() > 0.0 + else np.nan + ) + return int(valid.sum()), float(value) if pd.notna(value) else None + + rows: list[dict[str, Any]] = [] + for signal in signal_names: + train_n, train_corr = correlation(signal, split.train) + if train_corr is None: + continue + validation_n, validation_corr = correlation(signal, split.validation) + test_n, test_corr = correlation(signal, split.test) + rows.append( + { + "signal": signal, + "train_n": train_n, + "train_correlation": train_corr, + "validation_n": validation_n, + "validation_correlation": validation_corr, + "test_n": test_n, + "test_correlation": test_corr, + "stable_sign": bool( + validation_corr is not None + and test_corr is not None + and np.sign(train_corr) == np.sign(validation_corr) == np.sign(test_corr) + ), + } + ) + rows.sort(key=lambda row: abs(float(row["train_correlation"])), reverse=True) + return rows[:top_n] + + +def engineered_feature_screen( + dataset: SupervisedDataset, *, top_n: int = 20, min_samples: int = 100 +) -> list[dict[str, Any]]: + """Check whether existing causal lags explain change beyond persistence.""" + frame = dataset.frame.reset_index(drop=True) + split = make_outer_split(frame) + residual = pd.to_numeric(frame["y"], errors="coerce") - pd.to_numeric( + frame[dataset.baseline_feature], errors="coerce" + ) + + def correlation(feature: str, indices: np.ndarray) -> tuple[int, float | None]: + x = pd.to_numeric(frame.iloc[indices][feature], errors="coerce") + y = residual.iloc[indices] + valid = x.notna() & y.notna() + value = ( + x[valid].corr(y[valid]) + if valid.sum() >= min_samples and x[valid].std() > 0.0 and y[valid].std() > 0.0 + else np.nan + ) + return int(valid.sum()), float(value) if pd.notna(value) else None + + result: list[dict[str, Any]] = [] + for feature in dataset.feature_names: + train_n, train_corr = correlation(feature, split.train) + if train_corr is None: + continue + validation_n, validation_corr = correlation(feature, split.validation) + test_n, test_corr = correlation(feature, split.test) + result.append( + { + "feature": feature, + "train_n": train_n, + "train_correlation": train_corr, + "validation_n": validation_n, + "validation_correlation": validation_corr, + "test_n": test_n, + "test_correlation": test_corr, + "stable_sign": bool( + validation_corr is not None + and test_corr is not None + and np.sign(train_corr) == np.sign(validation_corr) == np.sign(test_corr) + ), + } + ) + result.sort(key=lambda row: abs(float(row["train_correlation"])), reverse=True) + return result[:top_n] + + +def build_diagnostic_report( + data: PreparedData, dataset: SupervisedDataset, *, top_n: int = 20 +) -> dict[str, Any]: + """Build one JSON-serializable research report without changing model capability.""" + return { + "dataset_id": data.manifest.dataset_id, + "target": SULFUR_SIGNAL, + "pak_lims_alignment": pak_lims_alignment(data), + "temporal_drift": temporal_drift(dataset), + "regime_diagnostics": regime_diagnostics(dataset), + "engineered_feature_residual_screen": engineered_feature_screen(dataset, top_n=top_n), + "telemetry_residual_screen": telemetry_residual_screen(data, dataset, top_n=top_n), + "limitations": [ + "confirmed LIMS outliers remain in prepared data and provenance", + "univariate association is not causality and cannot enable actions", + "test correlations are diagnostic only and are not model-selection inputs", + ], + } + + +__all__ = [ + "build_diagnostic_report", + "CONFIRMED_LIMS_OUTLIER_IDS", + "engineered_feature_screen", + "exclude_confirmed_lims_outliers", + "pak_lims_alignment", + "regime_diagnostics", + "telemetry_residual_screen", + "temporal_drift", +] diff --git a/source/ml/safety.py b/source/ml/safety.py new file mode 100644 index 0000000..8e6e9e8 --- /dev/null +++ b/source/ml/safety.py @@ -0,0 +1,762 @@ +"""Safety-first sulfur forecasting helpers. + +The point forecast, conservative upper estimate and exceedance alarm are kept +separate deliberately: none of them is allowed to masquerade as an action +effect model. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping, Sequence, cast + +import numpy as np +import pandas as pd +from joblib import Parallel, delayed +from lightgbm import LGBMClassifier +from sklearn.base import BaseEstimator +from sklearn.decomposition import PCA +from sklearn.ensemble import GradientBoostingRegressor, HistGradientBoostingClassifier +from sklearn.impute import SimpleImputer +from sklearn.linear_model import HuberRegressor, LogisticRegression, Ridge +from sklearn.metrics import average_precision_score, brier_score_loss +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import FunctionTransformer, StandardScaler + +from source.data.prepare import PreparedData +from source.ml.artifacts import ModelBundle, save_model +from source.ml.diagnostics import exclude_confirmed_lims_outliers +from source.ml.evaluate import expanding_purged_folds, make_outer_split +from source.ml.features import SupervisedDataset +from source.ml.uncertainty import ApplicabilityResult + +SULFUR_LIMIT = 10.0 +POSITIVE_WEIGHTS = (1.0, 2.0, 5.0, 10.0, 20.0) +FALSE_ALARM_BUDGET = 0.20 +TARGET_FALSE_NEGATIVE_RATE = 0.10 + + +@dataclass(frozen=True) +class AlarmPolicy: + """Validation-selected operating point for a calibrated risk score.""" + + threshold: float + false_alarm_budget: float + target_false_negative_rate: float + + +@dataclass(frozen=True) +class JointApplicabilityModel: + """Train-only joint feature domain in whitened PCA space.""" + + feature_names: tuple[str, ...] + required_features: tuple[str, ...] + imputer: SimpleImputer + scaler: StandardScaler + pca: PCA + max_distance_squared: float + + def availability_mask(self, features: pd.DataFrame) -> np.ndarray: + """Vectorized applicability result for offline evaluation.""" + if tuple(str(name) for name in features.columns) != self.feature_names: + raise ValueError("applicability feature order mismatch") + required_available = features.loc[:, list(self.required_features)].notna().all(axis=1) + transformed = self.pca.transform(self.scaler.transform(self.imputer.transform(features))) + distances = np.square(transformed).sum(axis=1) + return cast( + np.ndarray, + required_available.to_numpy() + & np.isfinite(distances) + & (distances <= self.max_distance_squared), + ) + + def assess(self, features: pd.DataFrame) -> ApplicabilityResult: + """Reject missing required inputs and flag joint distribution drift.""" + if tuple(str(name) for name in features.columns) != self.feature_names: + raise ValueError("applicability feature order mismatch") + if len(features) != 1: + raise ValueError("applicability check requires exactly one feature row") + missing = tuple(name for name in self.required_features if pd.isna(features.iloc[0][name])) + if missing: + return ApplicabilityResult(False, "FEATURES_UNAVAILABLE", missing) + if not self.availability_mask(features)[0]: + return ApplicabilityResult(False, "OUT_OF_DOMAIN", ("joint_pca_distance",)) + return ApplicabilityResult(True, None) + + +@dataclass(frozen=True) +class PlattCalibrator: + """Small serializable sigmoid calibration layer.""" + + estimator: LogisticRegression + + def predict(self, probabilities: np.ndarray) -> np.ndarray: + clipped = np.clip(np.asarray(probabilities, dtype=float), 1e-6, 1 - 1e-6) + logits = np.log(clipped / (1.0 - clipped)).reshape(-1, 1) + return cast(np.ndarray, self.estimator.predict_proba(logits)[:, 1]) + + +@dataclass(frozen=True) +class CompositeSafetyPredictor: + """One persisted predictor exposing point, upper, risk and applicability.""" + + point_predictor: Any + upper_predictor: Any + risk_predictor: Any + risk_calibrator: PlattCalibrator + alarm_policy: AlarmPolicy + applicability: JointApplicabilityModel + + def predict(self, features: pd.DataFrame) -> np.ndarray: + return np.asarray(self.point_predictor.predict(features), dtype=float) + + def predict_upper(self, features: pd.DataFrame) -> np.ndarray: + point = self.predict(features) + upper = np.asarray(self.upper_predictor.predict_upper(features), dtype=float) + return np.maximum(point, upper) + + def predict_exceedance_probability(self, features: pd.DataFrame) -> np.ndarray: + raw = np.asarray(self.risk_predictor.predict_proba(features)[:, 1], dtype=float) + return self.risk_calibrator.predict(raw) + + def predict_alarm(self, features: pd.DataFrame) -> np.ndarray: + return self.predict_exceedance_probability(features) >= self.alarm_policy.threshold + + def check_applicability(self, features: pd.DataFrame) -> ApplicabilityResult: + return self.applicability.assess(features) + + +@dataclass(frozen=True) +class SafetyFitResult: + predictor: CompositeSafetyPredictor + report: dict[str, Any] + + +@dataclass(frozen=True) +class LimsCorrectionResult: + """Sparse PAK-to-LIMS correction kept separate from the operational target.""" + + model_name: str + point_estimator: Any + upper_estimator: Any + report: dict[str, Any] + + +def published_lims_features( + data: PreparedData, as_of: pd.Series, *, depth: int = 3 +) -> pd.DataFrame: + """Return only LIMS values whose publication time is not later than each decision.""" + if depth < 1: + raise ValueError("LIMS history depth must be positive") + quality = data.quality + values = pd.to_numeric(quality["value"], errors="coerce") + source = quality["source"].map(lambda value: str(getattr(value, "value", value))) + validity = quality["validity"].map(lambda value: str(getattr(value, "value", value))) + rows = quality[ + quality["signal_id"].astype(str).eq("ht:2:Mg.Sulfur") + & source.eq("lims") + & validity.eq("valid") + & quality["unit"].astype(str).str.casefold().eq("mg/kg") + & values.notna() + & np.isfinite(values) + ].copy() + rows["value"] = values.loc[rows.index].astype(float) + rows, _ = exclude_confirmed_lims_outliers(rows) + rows["available_at"] = pd.to_datetime(rows["available_at"], utc=True) + rows["measured_at"] = pd.to_datetime(rows["measured_at"], utc=True) + rows.sort_values(["available_at", "measured_at", "observation_id"], inplace=True) + history = rows.loc[:, ["available_at"]].copy() + for position in range(depth): + history[f"lims_value_{position}"] = rows["value"].shift(position) + history[f"lims_measured_at_{position}"] = rows["measured_at"].shift(position) + queries = pd.DataFrame( + {"position": np.arange(len(as_of)), "as_of": pd.to_datetime(as_of, utc=True)} + ).sort_values("as_of", kind="stable") + merged = pd.merge_asof( + queries, + history, + left_on="as_of", + right_on="available_at", + direction="backward", + allow_exact_matches=True, + ).sort_values("position", kind="stable") + result: dict[str, np.ndarray] = {} + for position in range(depth): + result[f"lims_published_lag_{position}"] = merged[f"lims_value_{position}"].to_numpy( + dtype=float + ) + result[f"lims_age_minutes_{position}"] = ( + (merged["as_of"] - merged[f"lims_measured_at_{position}"]) + .dt.total_seconds() + .div(60) + .to_numpy(dtype=float) + ) + return pd.DataFrame(result) + + +def binary_alarm_metrics( + y_true: Sequence[bool] | np.ndarray, + probabilities: Sequence[float] | np.ndarray, + threshold: float, +) -> dict[str, float | int | None]: + """Return explicit safety and calibration metrics for one alarm threshold.""" + actual = np.asarray(y_true, dtype=bool) + probability = np.asarray(probabilities, dtype=float) + if actual.ndim != 1 or probability.shape != actual.shape or not np.isfinite(probability).all(): + raise ValueError("alarm inputs must be equal-length finite vectors") + if not 0.0 <= threshold <= 1.0: + raise ValueError("alarm threshold must be between zero and one") + alarm = probability >= threshold + positives = int(actual.sum()) + negatives = int((~actual).sum()) + false_negatives = int((actual & ~alarm).sum()) + false_positives = int((~actual & alarm).sum()) + true_positives = int((actual & alarm).sum()) + return { + "n": int(len(actual)), + "positives": positives, + "false_negatives": false_negatives, + "false_negative_rate": false_negatives / positives if positives else None, + "false_positives": false_positives, + "false_positive_rate": false_positives / negatives if negatives else None, + "precision": true_positives / (true_positives + false_positives) + if true_positives + false_positives + else None, + "average_precision": float(average_precision_score(actual, probability)) + if positives and negatives + else None, + "brier": float(brier_score_loss(actual, probability)), + } + + +def select_alarm_threshold( + y_true: Sequence[bool] | np.ndarray, + probabilities: Sequence[float] | np.ndarray, + *, + false_alarm_budget: float = FALSE_ALARM_BUDGET, +) -> tuple[AlarmPolicy, dict[str, float | int | None]]: + """Minimize misses subject to the configured false-alarm budget.""" + if not 0.0 <= false_alarm_budget < 1.0: + raise ValueError("false_alarm_budget must be in [0, 1)") + actual = np.asarray(y_true, dtype=bool) + probability = np.asarray(probabilities, dtype=float) + if actual.ndim != 1 or probability.shape != actual.shape or not np.isfinite(probability).all(): + raise ValueError("alarm inputs must be equal-length finite vectors") + positives = int(actual.sum()) + negatives = int((~actual).sum()) + if not positives or not negatives: + raise ValueError("threshold selection needs both sulfur classes") + order = np.argsort(-probability, kind="stable") + sorted_probability = probability[order] + sorted_actual = actual[order] + cumulative_true = np.cumsum(sorted_actual) + cumulative_false = np.cumsum(~sorted_actual) + group_ends = np.r_[np.flatnonzero(np.diff(sorted_probability) != 0), len(actual) - 1] + brier = float(brier_score_loss(actual, probability)) + average_precision = float(average_precision_score(actual, probability)) + feasible: list[tuple[float, dict[str, float | int | None]]] = [] + for index in group_ends: + true_positives = int(cumulative_true[index]) + false_positives = int(cumulative_false[index]) + false_positive_rate = false_positives / negatives + if false_positive_rate <= false_alarm_budget + 1e-12: + feasible.append( + ( + float(sorted_probability[index]), + { + "n": int(len(actual)), + "positives": positives, + "false_negatives": positives - true_positives, + "false_negative_rate": (positives - true_positives) / positives, + "false_positives": false_positives, + "false_positive_rate": false_positive_rate, + "precision": true_positives / (true_positives + false_positives), + "average_precision": average_precision, + "brier": brier, + }, + ) + ) + if not feasible: + raise ValueError("no alarm threshold satisfies the false-alarm budget") + + def ordering(item: tuple[float, Mapping[str, float | int | None]]) -> tuple[float, float]: + threshold, metrics = item + fnr = metrics["false_negative_rate"] + return (float("inf") if fnr is None else float(fnr), -threshold) + + threshold, metrics = min(feasible, key=ordering) + return AlarmPolicy(threshold, false_alarm_budget, TARGET_FALSE_NEGATIVE_RATE), metrics + + +def fit_joint_applicability( + features: pd.DataFrame, + *, + required_features: Sequence[str], + coverage: float = 0.99, +) -> JointApplicabilityModel: + """Fit a compact joint domain without compounding 54 marginal cutoffs.""" + if not 0.5 < coverage < 1.0: + raise ValueError("coverage must be between 0.5 and 1") + names = tuple(str(name) for name in features.columns) + required = tuple(str(name) for name in required_features) + if not set(required).issubset(names): + raise ValueError("required applicability features are absent") + imputer = SimpleImputer(strategy="median") + scaler = StandardScaler() + imputed = imputer.fit_transform(features) + scaled = scaler.fit_transform(imputed) + pca = PCA(n_components=0.99, whiten=True, svd_solver="full").fit(scaled) + transformed = pca.transform(scaled) + distances = np.square(transformed).sum(axis=1) + return JointApplicabilityModel( + names, + required, + imputer, + scaler, + pca, + float(np.quantile(distances, coverage)), + ) + + +def _risk_estimator(family: str, seed: int) -> BaseEstimator: + if family == "logistic": + return Pipeline( + ( + ("imputer", SimpleImputer(strategy="median")), + ("scaler", StandardScaler()), + ( + "model", + LogisticRegression(max_iter=200, random_state=seed, solver="liblinear"), + ), + ) + ) + if family == "hist_gradient_boosting": + return HistGradientBoostingClassifier( + learning_rate=0.05, + max_iter=100, + max_leaf_nodes=15, + l2_regularization=1.0, + early_stopping=False, + random_state=seed, + ) + if family == "lightgbm": + return Pipeline( + ( + ("to_array", FunctionTransformer(np.asarray, validate=False)), + ( + "model", + LGBMClassifier( + objective="binary", + learning_rate=0.05, + n_estimators=200, + num_leaves=15, + reg_lambda=1.0, + random_state=seed, + n_jobs=1, + verbosity=-1, + ), + ), + ) + ) + raise ValueError(f"unknown risk family: {family}") + + +def _fit_risk( + family: str, + frame: pd.DataFrame, + feature_names: tuple[str, ...], + positive_weight: float, + seed: int, +) -> BaseEstimator: + target = pd.to_numeric(frame["y"], errors="coerce").to_numpy(dtype=float) + valid = np.isfinite(target) & frame.loc[:, list(feature_names)].notna().any(axis=1).to_numpy() + labels = target[valid] > SULFUR_LIMIT + if len(np.unique(labels)) != 2: + raise ValueError("risk training needs both sulfur classes") + weights = np.where(labels, positive_weight, 1.0) + if family == "lightgbm": + weights *= np.where((target[valid] >= 8.0) & (target[valid] <= 12.0), 2.0, 1.0) + estimator = _risk_estimator(family, seed) + estimator.fit( + frame.loc[valid, list(feature_names)], labels, **_sample_weight_argument(family, weights) + ) + return estimator + + +def _sample_weight_argument(family: str, weights: np.ndarray) -> dict[str, np.ndarray]: + if family in {"logistic", "lightgbm"}: + return {"model__sample_weight": weights} + return {"sample_weight": weights} + + +def _fit_calibrator(probabilities: np.ndarray, labels: np.ndarray, seed: int) -> PlattCalibrator: + clipped = np.clip(probabilities, 1e-6, 1 - 1e-6) + logits = np.log(clipped / (1.0 - clipped)).reshape(-1, 1) + estimator = LogisticRegression(random_state=seed).fit(logits, labels) + return PlattCalibrator(estimator) + + +def fit_safety_model( + dataset: SupervisedDataset, + point_upper_model: ModelBundle, + *, + source_timezone: str = "Europe/Moscow", + train_end: str = "2025-01-01", + validation_end: str = "2026-01-01", + false_alarm_budget: float = FALSE_ALARM_BUDGET, + seed: int = 42, + include_lightgbm: bool = False, + candidate_families: Sequence[str] | None = None, +) -> SafetyFitResult: + """Fit risk/OOD heads without reading test labels during selection.""" + frame = dataset.frame.reset_index(drop=True) + feature_names = tuple(dataset.feature_names) + split = make_outer_split( + frame, + source_timezone=source_timezone, + train_end=train_end, + validation_end=validation_end, + ) + train = frame.iloc[split.train].reset_index(drop=True) + validation = frame.iloc[split.validation].reset_index(drop=True) + test = frame.iloc[split.test].reset_index(drop=True) + if min(len(train), len(validation), len(test)) == 0: + raise ValueError("safety fitting needs non-empty train, validation and test") + + folds = expanding_purged_folds(train, n_splits=3) + + def score_candidate(family: str, positive_weight: float) -> dict[str, Any]: + actual_parts: list[np.ndarray] = [] + probability_parts: list[np.ndarray] = [] + for fit_indices, score_indices in folds: + estimator = _fit_risk( + family, + train.iloc[fit_indices], + feature_names, + positive_weight, + seed, + ) + score = train.iloc[score_indices] + actual_parts.append( + pd.to_numeric(score["y"], errors="coerce").to_numpy(dtype=float) > SULFUR_LIMIT + ) + probability_parts.append( + np.asarray(estimator.predict_proba(score.loc[:, list(feature_names)])[:, 1]) + ) + actual = np.concatenate(actual_parts) + probability = np.concatenate(probability_parts) + policy, metrics = select_alarm_threshold( + actual, probability, false_alarm_budget=false_alarm_budget + ) + return { + "family": family, + "positive_weight": positive_weight, + "threshold": policy.threshold, + "metrics": metrics, + } + + families = tuple(candidate_families or ("logistic", "hist_gradient_boosting")) + if include_lightgbm and "lightgbm" not in families: + families += ("lightgbm",) + unknown = set(families) - { + "logistic", + "hist_gradient_boosting", + "lightgbm", + } + if unknown: + raise ValueError(f"unknown risk families: {sorted(unknown)}") + candidates = Parallel(n_jobs=2, prefer="threads")( + delayed(score_candidate)(family, positive_weight) + for family in families + for positive_weight in POSITIVE_WEIGHTS + ) + + complexity = { + "logistic": 0, + "hist_gradient_boosting": 1, + "lightgbm": 2, + } + selected = min( + candidates, + key=lambda item: ( + float(item["metrics"]["false_negative_rate"]), + float(item["metrics"]["brier"]), + complexity[item["family"]], + item["positive_weight"], + ), + ) + risk = _fit_risk( + selected["family"], + train, + feature_names, + selected["positive_weight"], + seed, + ) + middle = len(validation) // 2 + calibration = validation.iloc[:middle] + policy_frame = validation.iloc[middle:] + calibration_labels = ( + pd.to_numeric(calibration["y"], errors="coerce").to_numpy(dtype=float) > SULFUR_LIMIT + ) + raw_calibration = np.asarray( + risk.predict_proba(calibration.loc[:, list(feature_names)])[:, 1], dtype=float + ) + calibrator = _fit_calibrator(raw_calibration, calibration_labels, seed) + policy_labels = ( + pd.to_numeric(policy_frame["y"], errors="coerce").to_numpy(dtype=float) > SULFUR_LIMIT + ) + raw_policy = np.asarray( + risk.predict_proba(policy_frame.loc[:, list(feature_names)])[:, 1], dtype=float + ) + policy, validation_metrics = select_alarm_threshold( + policy_labels, + calibrator.predict(raw_policy), + false_alarm_budget=false_alarm_budget, + ) + required = (str(dataset.baseline_feature),) + applicability = fit_joint_applicability( + train.loc[:, list(feature_names)], required_features=required + ) + predictor = CompositeSafetyPredictor( + point_predictor=point_upper_model.predictor, + upper_predictor=point_upper_model.predictor, + risk_predictor=risk, + risk_calibrator=calibrator, + alarm_policy=policy, + applicability=applicability, + ) + + test_features = test.loc[:, list(feature_names)] + test_labels = pd.to_numeric(test["y"], errors="coerce").to_numpy(dtype=float) > SULFUR_LIMIT + test_probability = predictor.predict_exceedance_probability(test_features) + test_metrics = binary_alarm_metrics(test_labels, test_probability, policy.threshold) + baseline = pd.to_numeric(test[required[0]], errors="coerce").to_numpy(dtype=float) + transition = (baseline <= SULFUR_LIMIT) & test_labels + transition_recall = ( + float(np.mean(predictor.predict_alarm(test_features)[transition])) + if transition.any() + else None + ) + in_domain = applicability.availability_mask(test_features) + promoted = ( + validation_metrics["false_negative_rate"] is not None + and float(validation_metrics["false_negative_rate"]) <= TARGET_FALSE_NEGATIVE_RATE + and validation_metrics["false_positive_rate"] is not None + and float(validation_metrics["false_positive_rate"]) <= false_alarm_budget + ) + report = { + "schema_version": "1.1", + "selected_family": selected["family"], + "positive_weight": selected["positive_weight"], + "near_threshold_weight": 2.0 if selected["family"] == "lightgbm" else 1.0, + "alarm_threshold": policy.threshold, + "false_alarm_budget": false_alarm_budget, + "target_false_negative_rate": TARGET_FALSE_NEGATIVE_RATE, + "validation_policy": validation_metrics, + "test": test_metrics, + "test_transition_recall": transition_recall, + "test_applicability_rate": float(in_domain.mean()), + "promotion_eligible": promoted, + "test_used_for_selection": False, + "candidates": candidates, + } + return SafetyFitResult(predictor, report) + + +def fit_lims_correction( + dataset: SupervisedDataset, + pak_model: ModelBundle, + *, + data: PreparedData | None = None, + source_timezone: str = "Europe/Moscow", + train_end: str = "2025-01-01", + validation_end: str = "2026-01-01", + seed: int = 42, +) -> LimsCorrectionResult: + """Fit a sparse, explicitly LIMS-targeted residual correction model.""" + frame = dataset.frame.reset_index(drop=True).copy() + frame, excluded_outliers = exclude_confirmed_lims_outliers(frame) + feature_names = tuple(dataset.feature_names) + original_features = frame.loc[:, list(feature_names)] + base = np.asarray(pak_model.predict(original_features), dtype=float) + frame["pak_point"] = base + pak_feature_names = ["pak_point"] + if pak_model.metadata.capabilities.supports_uncertainty: + frame["pak_upper"] = pak_model.predict_upper(original_features) + pak_feature_names.append("pak_upper") + if pak_model.metadata.capabilities.supports_exceedance_probability: + frame["pak_exceedance_probability"] = pak_model.predict_exceedance_probability( + original_features + ) + pak_feature_names.append("pak_exceedance_probability") + lims_feature_names: tuple[str, ...] = () + if data is not None: + lims_features = published_lims_features(data, frame["as_of"]) + frame = pd.concat([frame.reset_index(drop=True), lims_features], axis="columns") + lims_feature_names = tuple(lims_features.columns) + frame["residual_target"] = pd.to_numeric(frame["y"], errors="coerce") - base + split = make_outer_split( + frame, + source_timezone=source_timezone, + train_end=train_end, + validation_end=validation_end, + ) + names = ( + *feature_names, + *pak_feature_names, + *lims_feature_names, + ) + train = frame.iloc[split.train] + validation = frame.iloc[split.validation] + test = frame.iloc[split.test] + candidates: dict[str, Any] = { + "ridge": Pipeline( + ( + ("imputer", SimpleImputer(strategy="median")), + ("scaler", StandardScaler()), + ("model", Ridge(alpha=10.0)), + ) + ), + "huber": Pipeline( + ( + ("imputer", SimpleImputer(strategy="median")), + ("scaler", StandardScaler()), + ("model", HuberRegressor(max_iter=2000)), + ) + ), + } + y_train = train["residual_target"].to_numpy(dtype=float) + y_validation = validation["residual_target"].to_numpy(dtype=float) + fitted: dict[str, Any] = {} + validation_mae: dict[str, float] = {} + for name, estimator in candidates.items(): + estimator.fit(train.loc[:, list(names)], y_train) + fitted[name] = estimator + prediction = estimator.predict(validation.loc[:, list(names)]) + validation_mae[name] = float(np.mean(np.abs(y_validation - prediction))) + selected_name = min(validation_mae, key=lambda name: (validation_mae[name], name)) + point = fitted[selected_name] + upper = GradientBoostingRegressor( + loss="quantile", alpha=0.95, n_estimators=100, max_depth=2, random_state=seed + ).fit(train.loc[:, list(names)].fillna(train.loc[:, list(names)].median()), y_train) + x_test = test.loc[:, list(names)] + median = train.loc[:, list(names)].median() + corrected = test["pak_point"].to_numpy(dtype=float) + point.predict(x_test) + upper_prediction = test["pak_point"].to_numpy(dtype=float) + upper.predict( + x_test.fillna(median) + ) + actual = pd.to_numeric(test["y"], errors="coerce").to_numpy(dtype=float) + report: dict[str, Any] = { + "schema_version": "1.1", + "target_source": "lims", + "confirmed_outlier_policy": "exclude_by_observation_id", + "excluded_outlier_count": len(excluded_outliers), + "excluded_outlier_ids": excluded_outliers, + "selected_model": selected_name, + "published_lims_feature_count": len(lims_feature_names), + "validation_mae": validation_mae, + "test_rows": int(len(test)), + "pak_point_mae": float(np.mean(np.abs(actual - test["pak_point"].to_numpy(dtype=float)))), + "corrected_mae": float(np.mean(np.abs(actual - corrected))), + "upper_coverage": float(np.mean(actual <= np.maximum(corrected, upper_prediction))), + "test_used_for_selection": False, + } + report["promotion_eligible"] = ( + report["corrected_mae"] <= 0.95 * report["pak_point_mae"] + and report["upper_coverage"] >= 0.95 + ) + return LimsCorrectionResult(selected_name, point, upper, report) + + +def save_safety_model( + directory: Path, + dataset: SupervisedDataset, + point_upper_model: ModelBundle, + *, + source_timezone: str = "Europe/Moscow", + train_end: str = "2025-01-01", + validation_end: str = "2026-01-01", + false_alarm_budget: float = FALSE_ALARM_BUDGET, + seed: int = 42, + allow_unpromoted: bool = False, + include_lightgbm: bool = False, +) -> tuple[ModelBundle, SafetyFitResult]: + """Persist schema-1.1 safety heads after the validation promotion gate.""" + if not point_upper_model.metadata.capabilities.supports_uncertainty: + raise ValueError("safety training requires an uncertainty-capable point artifact") + fitted = fit_safety_model( + dataset, + point_upper_model, + source_timezone=source_timezone, + train_end=train_end, + validation_end=validation_end, + false_alarm_budget=false_alarm_budget, + seed=seed, + include_lightgbm=include_lightgbm, + ) + if not fitted.report["promotion_eligible"] and not allow_unpromoted: + raise ValueError("safety model failed the validation promotion gate") + metadata = point_upper_model.metadata.model_dump(mode="python") + metadata.update( + { + "model_id": directory.name, + "schema_version": "1.1", + "model_type": f"{point_upper_model.metadata.model_type}+safety_classifier", + "capabilities": { + "supports_forecast": True, + "supports_actions": False, + "supports_uncertainty": True, + "supports_exceedance_probability": True, + }, + "processing": { + **point_upper_model.metadata.processing, + "safety_policy": { + "risk_family": fitted.report["selected_family"], + "positive_weight": fitted.report["positive_weight"], + "calibration": "platt_first_validation_half", + "threshold_selection": "minimum_fnr_subject_to_fpr_budget", + "threshold_validation_half": "second", + "alarm_threshold": fitted.report["alarm_threshold"], + "false_alarm_budget": fitted.report["false_alarm_budget"], + "target_false_negative_rate": fitted.report["target_false_negative_rate"], + "pak_metrics": { + "validation": fitted.report["validation_policy"], + "test": fitted.report["test"], + "transition_recall": fitted.report["test_transition_recall"], + }, + "lims_metrics": "separate_target_not_promoted", + }, + }, + "applicability": { + "method": "joint_pca_mahalanobis", + "required_features": list(fitted.predictor.applicability.required_features), + "coverage": 0.99, + "max_distance_squared": fitted.predictor.applicability.max_distance_squared, + "out_of_domain_behavior": "unavailable", + "purpose": "60-minute sulfur point, upper and exceedance-risk forecast", + "target_source": point_upper_model.metadata.target_source, + "action_comparison": "forbidden", + }, + "reports": tuple(point_upper_model.metadata.reports) + ("metrics.json",), + } + ) + bundle = save_model(directory, fitted.predictor, metadata, fitted.report) + return bundle, fitted + + +__all__ = [ + "AlarmPolicy", + "CompositeSafetyPredictor", + "FALSE_ALARM_BUDGET", + "JointApplicabilityModel", + "LimsCorrectionResult", + "SafetyFitResult", + "binary_alarm_metrics", + "fit_joint_applicability", + "fit_lims_correction", + "published_lims_features", + "fit_safety_model", + "save_safety_model", + "select_alarm_threshold", +] diff --git a/source/ml/v2.py b/source/ml/v2.py new file mode 100644 index 0000000..0817b46 --- /dev/null +++ b/source/ml/v2.py @@ -0,0 +1,873 @@ +"""Episode-aware multi-horizon sulfur forecasting without action claims.""" + +from __future__ import annotations + +import platform +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping, cast + +import numpy as np +import pandas as pd +import sklearn +from lightgbm import LGBMClassifier, LGBMRegressor +from sklearn.ensemble import HistGradientBoostingClassifier, HistGradientBoostingRegressor +from sklearn.metrics import average_precision_score, brier_score_loss +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import FunctionTransformer + +from source.contracts import SourceKind, Validity +from source.data.prepare import PreparedData +from source.ml.artifacts import ModelBundle, feature_schema_hash, save_model +from source.ml.features import SupervisedDataset +from source.ml.safety import ( + FALSE_ALARM_BUDGET, + SULFUR_LIMIT, + AlarmPolicy, + JointApplicabilityModel, + PlattCalibrator, + _fit_calibrator, + fit_joint_applicability, +) + +HORIZONS = (10, 20, 30, 60) +WINDOWS = (30, 60, 180) +REGIME_LABELS = ("below_8", "8_to_10", "above_10") + + +@dataclass(frozen=True) +class EpisodeDataset: + """Leakage-safe v2 frame and its exact serving feature order.""" + + frame: pd.DataFrame + feature_names: tuple[str, ...] + baseline_feature: str + + +@dataclass(frozen=True) +class EpisodeSafetyPredictor: + """Serializable point, upper and multi-horizon risk predictor.""" + + baseline_feature: str + delta_estimator: Any + upper_delta_estimator: Any + risk_estimators: Mapping[int, Any] + calibrators: Mapping[int, PlattCalibrator] + alarm_policy: AlarmPolicy + applicability: JointApplicabilityModel + + def predict_delta(self, features: pd.DataFrame) -> np.ndarray: + return np.asarray(self.delta_estimator.predict(features), dtype=float) + + def predict(self, features: pd.DataFrame) -> np.ndarray: + current = pd.to_numeric(features[self.baseline_feature], errors="coerce").to_numpy( + dtype=float + ) + return cast(np.ndarray, current + self.predict_delta(features)) + + def predict_upper(self, features: pd.DataFrame) -> np.ndarray: + current = pd.to_numeric(features[self.baseline_feature], errors="coerce").to_numpy( + dtype=float + ) + upper = current + np.asarray(self.upper_delta_estimator.predict(features), dtype=float) + return np.maximum(self.predict(features), upper) + + def predict_horizon_probabilities(self, features: pd.DataFrame) -> dict[int, np.ndarray]: + result: dict[int, np.ndarray] = {} + previous = np.zeros(len(features), dtype=float) + for horizon in HORIZONS: + raw = np.asarray( + self.risk_estimators[horizon].predict_proba(features)[:, 1], dtype=float + ) + calibrated = self.calibrators[horizon].predict(raw) + previous = np.maximum(previous, calibrated) + result[horizon] = previous.copy() + return result + + def predict_exceedance_probability(self, features: pd.DataFrame) -> np.ndarray: + return self.predict_horizon_probabilities(features)[60] + + def predict_alarm(self, features: pd.DataFrame) -> np.ndarray: + current = pd.to_numeric(features[self.baseline_feature], errors="coerce").to_numpy( + dtype=float + ) + return (current > SULFUR_LIMIT) | ( + self.predict_exceedance_probability(features) >= self.alarm_policy.threshold + ) + + def predict_v2(self, features: pd.DataFrame) -> list[dict[str, object]]: + point = self.predict(features) + upper = self.predict_upper(features) + delta = self.predict_delta(features) + horizons = self.predict_horizon_probabilities(features) + alarm = self.predict_alarm(features) + rows: list[dict[str, object]] = [] + for position in range(len(features)): + applicable = self.applicability.assess(features.iloc[[position]]) + reason = applicable.reason_code + rows.append( + { + "point": float(point[position]), + "upper": float(upper[position]), + "exceedance_probability": float(horizons[60][position]), + "alarm": bool(alarm[position]), + "horizon_probabilities": { + str(horizon): float(values[position]) + for horizon, values in horizons.items() + }, + "crossing_probability_60m": float(horizons[60][position]), + "event_alarm": bool(alarm[position]), + "predicted_delta": float(delta[position]), + "point_60m": float(point[position]), + "upper_60m": float(upper[position]), + "applicable": applicable.available, + "reason_codes": () if reason is None else (reason,), + } + ) + return rows + + def check_applicability(self, features: pd.DataFrame) -> object: + return self.applicability.assess(features) + + +@dataclass(frozen=True) +class EpisodeFitResult: + predictor: EpisodeSafetyPredictor + report: dict[str, Any] + + +def _pak_rows(data: PreparedData, signal_id: str) -> pd.DataFrame: + quality = data.quality + values = pd.to_numeric(quality["value"], errors="coerce") + selected = quality[ + quality["signal_id"].astype(str).eq(signal_id) + & quality["source"].map(lambda value: str(getattr(value, "value", value))).eq("pak") + & quality["validity"] + .map(lambda value: str(getattr(value, "value", value))) + .eq(Validity.VALID.value) + & values.notna() + & np.isfinite(values) + ].copy() + selected["value"] = values.loc[selected.index].astype(float) + selected["measured_at"] = pd.to_datetime(selected["measured_at"], utc=True) + return cast( + pd.DataFrame, selected.sort_values("measured_at", kind="stable").reset_index(drop=True) + ) + + +def _exact_future_values( + queries: pd.Series, pak: pd.DataFrame, horizon: int +) -> tuple[np.ndarray, np.ndarray]: + left = pd.DataFrame( + { + "position": np.arange(len(queries)), + "query": pd.to_datetime(queries, utc=True) + pd.Timedelta(minutes=horizon), + } + ).sort_values("query", kind="stable") + right = pak.loc[:, ["measured_at", "value", "episode_id"]] + merged = pd.merge_asof( + left, + right, + left_on="query", + right_on="measured_at", + direction="nearest", + tolerance=pd.Timedelta(seconds=1), + ).sort_values("position", kind="stable") + return merged["value"].to_numpy(dtype=float), merged["episode_id"].to_numpy(dtype=float) + + +def _rolling_features(queries: pd.Series, pak: pd.DataFrame) -> pd.DataFrame: + query_index = pd.DatetimeIndex(pd.to_datetime(queries, utc=True)) + series = pd.Series(pak["value"].to_numpy(dtype=float), index=pak["measured_at"]) + timeline = series.index.union(query_index).sort_values() + expanded = series.reindex(timeline) + columns: dict[str, np.ndarray] = {} + for window in WINDOWS: + rolling = expanded.rolling(f"{window}min", closed="both", min_periods=1) + minimum = rolling.min().reindex(query_index).to_numpy(dtype=float) + maximum = rolling.max().reindex(query_index).to_numpy(dtype=float) + columns[f"pak_min_{window}m"] = minimum + columns[f"pak_max_{window}m"] = maximum + columns[f"pak_range_{window}m"] = maximum - minimum + return pd.DataFrame(columns) + + +def _state_age_features(queries: pd.Series, pak: pd.DataFrame) -> pd.DataFrame: + query = pd.DatetimeIndex(pd.to_datetime(queries, utc=True)) + times = pd.DatetimeIndex(pak["measured_at"]) + values = pak["value"].to_numpy(dtype=float) + result: dict[str, np.ndarray] = {} + for threshold in (8.0, 9.0, 10.0): + state = values >= threshold + crossing = np.r_[True, state[1:] != state[:-1]] + events = pd.DataFrame({"event_at": times[crossing]}).sort_values("event_at") + merged = pd.merge_asof( + pd.DataFrame({"position": np.arange(len(query)), "query": query}).sort_values("query"), + events, + left_on="query", + right_on="event_at", + direction="backward", + ).sort_values("position") + result[f"pak_minutes_since_crossing_{int(threshold)}"] = ( + (merged["query"] - merged["event_at"]).dt.total_seconds().div(60).to_numpy(dtype=float) + ) + bands = pd.cut(values, [-np.inf, 8.0, 10.0, np.inf], labels=False, right=False) + changed = np.r_[True, np.asarray(bands[1:]) != np.asarray(bands[:-1])] + events = pd.DataFrame({"event_at": times[changed]}).sort_values("event_at") + merged = pd.merge_asof( + pd.DataFrame({"position": np.arange(len(query)), "query": query}).sort_values("query"), + events, + left_on="query", + right_on="event_at", + direction="backward", + ).sort_values("position") + result["pak_regime_duration_minutes"] = ( + (merged["query"] - merged["event_at"]).dt.total_seconds().div(60).to_numpy(dtype=float) + ) + return pd.DataFrame(result) + + +def build_episode_dataset(data: PreparedData, base: SupervisedDataset) -> EpisodeDataset: + """Add causal trajectory features and future labels to the 60-minute PAK table.""" + if base.target_source is not SourceKind.PAK or base.feature_source is not SourceKind.PAK: + raise ValueError("episode dataset requires PAK targets and PAK history") + frame = base.frame.reset_index(drop=True).copy() + pak = _pak_rows(data, base.target_signal_id) + exceedance = pak["value"].to_numpy(dtype=float) > SULFUR_LIMIT + starts = exceedance & np.r_[True, ~exceedance[:-1]] + pak["episode_id"] = np.where(exceedance, np.cumsum(starts), np.nan) + + exact_crossings: dict[int, np.ndarray] = {} + future_episodes: dict[int, np.ndarray] = {} + for horizon in HORIZONS: + values, episode_ids = _exact_future_values(frame["as_of"], pak, horizon) + frame[f"y_{horizon}m"] = values + exact_crossings[horizon] = values > SULFUR_LIMIT + future_episodes[horizon] = episode_ids + for horizon in HORIZONS: + frame[f"crossing_{horizon}m"] = np.logical_or.reduce( + [exact_crossings[step] for step in HORIZONS if step <= horizon] + ) + frame["crossing_60m"] = frame["crossing_60m"].fillna(False) + episode_id = np.full(len(frame), np.nan) + for horizon in HORIZONS: + use = np.isnan(episode_id) & frame[f"crossing_{horizon}m"].to_numpy(dtype=bool) + episode_id[use] = future_episodes[horizon][use] + frame["event_id"] = episode_id + frame["delta_60m"] = pd.to_numeric(frame["y_60m"], errors="coerce") - pd.to_numeric( + frame[base.baseline_feature], errors="coerce" + ) + + derived = _rolling_features(frame["as_of"], pak) + state_ages = _state_age_features(frame["as_of"], pak) + current = pd.to_numeric(frame[base.baseline_feature], errors="coerce") + for window in WINDOWS: + lag_name = f"{base.target_signal_id}__pak_lag_{window}m" + if lag_name in frame: + frame[f"pak_slope_{window}m"] = (current - frame[lag_name]) / float(window) + if "pak_slope_30m" in frame and "pak_slope_60m" in frame: + frame["pak_acceleration_30_60m"] = frame["pak_slope_30m"] - frame["pak_slope_60m"] + frame = pd.concat([frame, derived, state_ages], axis="columns") + original_features = list(base.feature_names) + frame["feature_missing_fraction"] = frame[original_features].isna().mean(axis=1) + added = [ + name + for name in frame.columns + if name.startswith("pak_") or name == "feature_missing_fraction" + ] + feature_names = tuple(dict.fromkeys([*original_features, *added])) + return EpisodeDataset(frame, feature_names, base.baseline_feature) + + +def episode_sample_weights(frame: pd.DataFrame) -> np.ndarray: + """Give every positive episode and calendar month comparable training mass.""" + positive = frame["crossing_60m"].fillna(False).to_numpy(dtype=bool) + weights = np.ones(len(frame), dtype=float) + episode_counts = frame.loc[positive, "event_id"].value_counts() + if not episode_counts.empty: + weights[positive] = frame.loc[positive, "event_id"].map(1.0 / episode_counts).to_numpy() + weights[positive] *= positive.sum() / weights[positive].sum() + months = pd.to_datetime(frame["as_of"], utc=True).dt.strftime("%Y-%m") + month_mass = ( + pd.Series(weights).groupby(months.to_numpy()).transform("sum").to_numpy(dtype=float) + ) + weights /= month_mass + weights *= len(weights) / weights.sum() + return weights + + +def rolling_month_folds( + frame: pd.DataFrame, validation_start: str, validation_end: str +) -> tuple[tuple[np.ndarray, np.ndarray], ...]: + """Return monthly expanding folds purged by target publication time.""" + as_of = pd.to_datetime(frame["as_of"], utc=True) + available = pd.to_datetime(frame["target_available_at"], utc=True) + starts = pd.date_range(validation_start, validation_end, freq="MS", inclusive="left", tz="UTC") + folds: list[tuple[np.ndarray, np.ndarray]] = [] + for start in starts: + end = start + pd.offsets.MonthBegin(1) + train = (as_of < start) & (available < start) + validation = (as_of >= start) & (as_of < end) + if train.any() and validation.any(): + folds.append((np.flatnonzero(train), np.flatnonzero(validation))) + return tuple(folds) + + +def event_metrics(frame: pd.DataFrame, probability: np.ndarray, threshold: float) -> dict[str, Any]: + """Evaluate unique exceedance episodes and ordinary false-alarm opportunities.""" + actual = frame["crossing_60m"].fillna(False).to_numpy(dtype=bool) + current = pd.to_numeric(frame["baseline"], errors="coerce").to_numpy(dtype=float) + alarm = (probability >= threshold) | (current > SULFUR_LIMIT) + event_ids = frame.loc[actual, "event_id"].dropna().unique() + detected = sum( + bool(alarm[frame["event_id"].eq(event_id).to_numpy()].any()) for event_id in event_ids + ) + eligible_negative = (~actual) & (current <= SULFUR_LIMIT) & np.isfinite(current) + false_positive_rate = ( + float(alarm[eligible_negative].mean()) if eligible_negative.any() else None + ) + opportunity_hour = pd.to_datetime(frame["as_of"], utc=True).dt.floor("60min") + negative_opportunities = pd.DataFrame( + { + "opportunity": opportunity_hour[eligible_negative].to_numpy(), + "alarm": alarm[eligible_negative], + } + ) + opportunity_alarm = ( + negative_opportunities.groupby("opportunity", sort=False)["alarm"].any() + if len(negative_opportunities) + else pd.Series(dtype=bool) + ) + return { + "event_count": int(len(event_ids)), + "detected_events": int(detected), + "event_false_negative_rate": 1.0 - detected / len(event_ids) if len(event_ids) else None, + "event_false_positive_rate": float(opportunity_alarm.mean()) + if len(opportunity_alarm) + else None, + "false_alarm_opportunities": int(opportunity_alarm.sum()), + "negative_opportunities": int(len(opportunity_alarm)), + "row_false_positive_rate": false_positive_rate, + "row_false_negatives": int((actual & ~alarm).sum()), + "row_false_negative_rate": float((actual & ~alarm).sum() / actual.sum()) + if actual.any() + else None, + "brier": float(brier_score_loss(actual, probability)), + "average_precision": float(average_precision_score(actual, probability)) + if actual.any() and (~actual).any() + else None, + } + + +def select_event_threshold( + frame: pd.DataFrame, + probability: np.ndarray, + false_alarm_budget: float = FALSE_ALARM_BUDGET, +) -> tuple[AlarmPolicy, dict[str, Any]]: + """Minimize missed episodes under the row-level false-alarm budget.""" + actual = frame["crossing_60m"].fillna(False).to_numpy(dtype=bool) + current = pd.to_numeric(frame["baseline"], errors="coerce").to_numpy(dtype=float) + negatives = probability[(~actual) & (current <= SULFUR_LIMIT) & np.isfinite(current)] + if not len(negatives): + raise ValueError("threshold selection needs eligible negative rows") + allowed = int(np.floor(false_alarm_budget * len(negatives))) + if allowed >= len(negatives): + threshold = 0.0 + else: + boundary = np.sort(negatives)[len(negatives) - allowed - 1] + threshold = float(np.nextafter(boundary, np.inf)) + if threshold > 1.0: + raise ValueError("no event threshold satisfies false-alarm budget") + metrics = event_metrics(frame, probability, threshold) + if ( + metrics["event_false_positive_rate"] is not None + and metrics["event_false_positive_rate"] > false_alarm_budget + ): + candidates = np.unique(probability[np.isfinite(probability)]) + low = int(np.searchsorted(candidates, threshold, side="left")) + high = len(candidates) + while low < high: + middle = (low + high) // 2 + candidate = float(candidates[middle]) + candidate_metrics = event_metrics(frame, probability, candidate) + event_fpr = candidate_metrics["event_false_positive_rate"] + if event_fpr is not None and event_fpr <= false_alarm_budget: + high = middle + else: + low = middle + 1 + if low == len(candidates): + threshold = 1.0 + else: + threshold = float(candidates[low]) + metrics = event_metrics(frame, probability, threshold) + return AlarmPolicy(threshold, false_alarm_budget, 0.10), metrics + + +def _estimator(family: str, task: str, seed: int) -> Any: + if family == "hgb": + if task == "classifier": + return HistGradientBoostingClassifier( + learning_rate=0.05, + max_iter=100, + max_leaf_nodes=15, + l2_regularization=1.0, + early_stopping=False, + random_state=seed, + ) + loss = "quantile" if task == "upper" else "squared_error" + kwargs = {"quantile": 0.95} if task == "upper" else {} + return HistGradientBoostingRegressor( + loss=loss, max_iter=100, max_leaf_nodes=15, random_state=seed, **kwargs + ) + if family == "lightgbm": + if task == "classifier": + model: Any = LGBMClassifier( + n_estimators=200, learning_rate=0.05, num_leaves=15, random_state=seed, verbosity=-1 + ) + else: + objective = "quantile" if task == "upper" else "regression_l1" + model = LGBMRegressor( + objective=objective, + alpha=0.95 if task == "upper" else 0.9, + n_estimators=200, + learning_rate=0.05, + num_leaves=15, + random_state=seed, + verbosity=-1, + ) + return Pipeline( + (("to_array", FunctionTransformer(np.asarray, validate=False)), ("model", model)) + ) + raise ValueError(f"unknown v2 family: {family}") + + +def _fit(estimator: Any, x: pd.DataFrame, y: np.ndarray, weights: np.ndarray, family: str) -> Any: + valid = np.isfinite(y) + if not valid.any(): + raise ValueError("v2 fit period has no finite targets") + argument = ( + {"model__sample_weight": weights[valid]} + if family == "lightgbm" + else {"sample_weight": weights[valid]} + ) + return estimator.fit(x.loc[valid], y[valid], **argument) + + +def _rolling_predictions( + dataset: EpisodeDataset, + family: str, + start: str, + end: str, + seed: int, + *, + include_regression: bool = True, + include_upper: bool = True, +) -> tuple[pd.DataFrame, dict[int, np.ndarray], np.ndarray, np.ndarray]: + frame = dataset.frame + pieces: list[pd.DataFrame] = [] + risk_parts: dict[int, list[np.ndarray]] = {horizon: [] for horizon in HORIZONS} + point_parts: list[np.ndarray] = [] + upper_parts: list[np.ndarray] = [] + for train_indices, validation_indices in rolling_month_folds(frame, start, end): + train = frame.iloc[train_indices] + validation = frame.iloc[validation_indices] + features = list(dataset.feature_names) + weights = episode_sample_weights(train) + for horizon in HORIZONS: + estimator = _fit( + _estimator(family, "classifier", seed), + train.loc[:, features], + train[f"crossing_{horizon}m"].to_numpy(dtype=bool), + weights, + family, + ) + risk_parts[horizon].append( + np.asarray(estimator.predict_proba(validation.loc[:, features])[:, 1], dtype=float) + ) + if include_regression: + delta = _fit( + _estimator(family, "delta", seed), + train.loc[:, features], + train["delta_60m"].to_numpy(dtype=float), + weights, + family, + ) + point_parts.append(np.asarray(delta.predict(validation.loc[:, features]), dtype=float)) + if include_upper: + upper = _fit( + _estimator(family, "upper", seed), + train.loc[:, features], + train["delta_60m"].to_numpy(dtype=float), + weights, + family, + ) + upper_parts.append(np.asarray(upper.predict(validation.loc[:, features]), dtype=float)) + pieces.append(validation) + if not pieces: + raise ValueError("rolling backtest produced no folds") + return ( + pd.concat(pieces, ignore_index=True), + {horizon: np.concatenate(parts) for horizon, parts in risk_parts.items()}, + np.concatenate(point_parts) if point_parts else np.asarray([], dtype=float), + np.concatenate(upper_parts) if upper_parts else np.asarray([], dtype=float), + ) + + +def _monotone_probabilities(probabilities: Mapping[int, np.ndarray]) -> dict[int, np.ndarray]: + result: dict[int, np.ndarray] = {} + previous = np.zeros_like(next(iter(probabilities.values()))) + for horizon in HORIZONS: + previous = np.maximum(previous, probabilities[horizon]) + result[horizon] = previous.copy() + return result + + +def _regime_report( + frame: pd.DataFrame, probability: np.ndarray, threshold: float +) -> dict[str, Any]: + current = pd.to_numeric(frame["baseline"], errors="coerce") + bands = pd.cut(current, [-np.inf, 8.0, 10.0, np.inf], labels=REGIME_LABELS, right=False) + return { + label: event_metrics( + frame.loc[bands.eq(label)].reset_index(drop=True), + probability[bands.eq(label)], + threshold, + ) + for label in REGIME_LABELS + if bands.eq(label).any() + } + + +def _monthly_report( + frame: pd.DataFrame, + probability: np.ndarray, + threshold: float, + *, + actual: np.ndarray | None = None, + upper: np.ndarray | None = None, +) -> dict[str, Any]: + month = pd.to_datetime(frame["as_of"], utc=True).dt.strftime("%Y-%m") + result: dict[str, Any] = {} + for value in sorted(month.unique()): + selected = month.eq(value).to_numpy() + metrics = event_metrics( + frame.loc[month.eq(value)].reset_index(drop=True), + probability[month.eq(value)], + threshold, + ) + if actual is not None and upper is not None: + finite = np.isfinite(actual[selected]) & np.isfinite(upper[selected]) + metrics["upper_coverage"] = ( + float(np.mean(actual[selected][finite] <= upper[selected][finite])) + if finite.any() + else None + ) + result[value] = metrics + return result + + +def _lead_time_report( + frame: pd.DataFrame, probabilities: Mapping[int, np.ndarray], threshold: float +) -> dict[str, Any]: + result: dict[str, Any] = {} + for horizon in HORIZONS: + view = frame.copy() + view["crossing_60m"] = view[f"crossing_{horizon}m"] + result[f"{horizon}m"] = event_metrics(view, probabilities[horizon], threshold) + return result + + +def _bootstrap_event_fnr( + frame: pd.DataFrame, probability: np.ndarray, threshold: float, seed: int +) -> dict[str, float] | None: + event_ids = frame.loc[frame["crossing_60m"], "event_id"].dropna().unique() + if not len(event_ids): + return None + current = pd.to_numeric(frame["baseline"], errors="coerce").to_numpy(dtype=float) + alarm = (probability >= threshold) | (current > SULFUR_LIMIT) + detected = np.asarray( + [alarm[frame["event_id"].eq(event_id).to_numpy()].any() for event_id in event_ids], + dtype=bool, + ) + rng = np.random.default_rng(seed) + samples = np.asarray( + [1.0 - rng.choice(detected, size=len(detected), replace=True).mean() for _ in range(1000)] + ) + return { + "lower": float(np.quantile(samples, 0.025)), + "upper": float(np.quantile(samples, 0.975)), + } + + +def fit_episode_safety_model(dataset: EpisodeDataset, *, seed: int = 42) -> EpisodeFitResult: + """Select on 2024, calibrate/threshold in 2025, and audit 2026 once fixed.""" + family_reports: dict[str, Any] = {} + for family in ("hgb", "lightgbm"): + frame_2024, raw, delta, _ = _rolling_predictions( + dataset, family, "2024-01-01", "2025-01-01", seed, include_upper=False + ) + probabilities = _monotone_probabilities(raw)[60] + policy, metrics = select_event_threshold(frame_2024, probabilities) + point = pd.to_numeric(frame_2024["baseline"], errors="coerce").to_numpy(dtype=float) + delta + actual = frame_2024["y_60m"].to_numpy(dtype=float) + family_reports[family] = { + "policy": policy, + "metrics": metrics, + "mae": float(np.mean(np.abs(actual - point))), + "brier": float(brier_score_loss(frame_2024["crossing_60m"], probabilities)), + } + selected_family = min( + family_reports, + key=lambda family: ( + float(family_reports[family]["metrics"]["event_false_negative_rate"]), + float(family_reports[family]["brier"]), + float(family_reports[family]["mae"]), + 0 if family == "hgb" else 1, + ), + ) + + calibration_frame, calibration_raw, _, _ = _rolling_predictions( + dataset, + selected_family, + "2025-01-01", + "2025-07-01", + seed, + include_regression=False, + include_upper=False, + ) + calibrators: dict[int, PlattCalibrator] = {} + for horizon in HORIZONS: + labels = calibration_frame[f"crossing_{horizon}m"].to_numpy(dtype=bool) + calibrators[horizon] = _fit_calibrator(calibration_raw[horizon], labels, seed) + + policy_frame, policy_raw, _, _ = _rolling_predictions( + dataset, + selected_family, + "2025-07-01", + "2026-01-01", + seed, + include_regression=False, + include_upper=False, + ) + calibrated = _monotone_probabilities( + {horizon: calibrators[horizon].predict(policy_raw[horizon]) for horizon in HORIZONS} + ) + policy, policy_metrics = select_event_threshold(policy_frame, calibrated[60]) + + frame = dataset.frame + as_of = pd.to_datetime(frame["as_of"], utc=True) + available = pd.to_datetime(frame["target_available_at"], utc=True) + fit_mask = (as_of < pd.Timestamp("2026-01-01", tz="UTC")) & ( + available < pd.Timestamp("2026-01-01", tz="UTC") + ) + fit_frame = frame.loc[fit_mask] + features = list(dataset.feature_names) + weights = episode_sample_weights(fit_frame) + risk_estimators: dict[int, Any] = {} + for horizon in HORIZONS: + risk_estimators[horizon] = _fit( + _estimator(selected_family, "classifier", seed), + fit_frame.loc[:, features], + fit_frame[f"crossing_{horizon}m"].to_numpy(dtype=bool), + weights, + selected_family, + ) + delta_estimator = _fit( + _estimator(selected_family, "delta", seed), + fit_frame.loc[:, features], + fit_frame["delta_60m"].to_numpy(dtype=float), + weights, + selected_family, + ) + upper_estimator = _fit( + _estimator(selected_family, "upper", seed), + fit_frame.loc[:, features], + fit_frame["delta_60m"].to_numpy(dtype=float), + weights, + selected_family, + ) + applicability = fit_joint_applicability( + fit_frame.loc[:, features], required_features=(dataset.baseline_feature,) + ) + predictor = EpisodeSafetyPredictor( + dataset.baseline_feature, + delta_estimator, + upper_estimator, + risk_estimators, + calibrators, + policy, + applicability, + ) + + audit = frame.loc[as_of >= pd.Timestamp("2026-01-01", tz="UTC")].reset_index(drop=True) + audit_features = audit.loc[:, features] + audit_probabilities = predictor.predict_horizon_probabilities(audit_features) + audit_probability = audit_probabilities[60] + audit_point = predictor.predict(audit_features) + audit_upper = predictor.predict_upper(audit_features) + actual = audit["y_60m"].to_numpy(dtype=float) + finite_forecast = np.isfinite(actual) & np.isfinite(audit_point) & np.isfinite(audit_upper) + audit_metrics = event_metrics(audit, audit_probability, policy.threshold) + audit_metrics.update( + { + "forecast_rows": int(finite_forecast.sum()), + "mae": float(np.mean(np.abs(actual[finite_forecast] - audit_point[finite_forecast]))) + if finite_forecast.any() + else None, + "upper_coverage": float( + np.mean(actual[finite_forecast] <= audit_upper[finite_forecast]) + ) + if finite_forecast.any() + else None, + "event_fnr_bootstrap_95": _bootstrap_event_fnr( + audit, audit_probability, policy.threshold, seed + ), + } + ) + monthly = _monthly_report( + audit, + audit_probability, + policy.threshold, + actual=actual, + upper=audit_upper, + ) + valid_months = [ + metrics for metrics in monthly.values() if metrics["event_false_negative_rate"] is not None + ] + report = { + "schema_version": "1.2", + "selected_family": selected_family, + "selection_2024": { + family: {key: value for key, value in values.items() if key != "policy"} + for family, values in family_reports.items() + }, + "calibration_period": "2025-01-01/2025-07-01", + "threshold_period": "2025-07-01/2026-01-01", + "threshold_metrics": policy_metrics, + "alarm_threshold": policy.threshold, + "audit_2026": audit_metrics, + "audit_monthly": monthly, + "audit_worst_month_event_fnr": max( + float(metrics["event_false_negative_rate"]) for metrics in valid_months + ) + if valid_months + else None, + "audit_regimes": _regime_report(audit, audit_probability, policy.threshold), + "audit_lead_times": _lead_time_report(audit, audit_probabilities, policy.threshold), + "statistical_gate_passed": bool( + policy_metrics["event_false_negative_rate"] is not None + and policy_metrics["event_false_negative_rate"] <= 0.10 + and policy_metrics["event_false_positive_rate"] is not None + and policy_metrics["event_false_positive_rate"] <= FALSE_ALARM_BUDGET + and policy_metrics["row_false_positive_rate"] is not None + and policy_metrics["row_false_positive_rate"] <= FALSE_ALARM_BUDGET + ), + "audit_gate_passed": bool( + audit_metrics["event_false_negative_rate"] is not None + and audit_metrics["event_false_negative_rate"] <= 0.10 + and audit_metrics["event_false_positive_rate"] is not None + and audit_metrics["event_false_positive_rate"] <= FALSE_ALARM_BUDGET + and audit_metrics["row_false_positive_rate"] is not None + and audit_metrics["row_false_positive_rate"] <= FALSE_ALARM_BUDGET + and audit_metrics["upper_coverage"] is not None + and audit_metrics["upper_coverage"] >= 0.95 + ), + "test_used_for_selection": False, + "shadow_required": True, + "shadow_minimum": "100 independent episodes or 3 complete months", + "pak_only_ablation_completed": False, + "promotion_blockers": [ + "new shadow period is required", + "PAK-only ablation is required for disputed P8/T11/F19 semantics", + ], + "promotion_eligible": False, + "supports_actions": False, + } + return EpisodeFitResult(predictor, report) + + +def save_episode_safety_model( + directory: Path, + data: PreparedData, + base: SupervisedDataset, + *, + git_commit: str, + seed: int = 42, +) -> tuple[ModelBundle, EpisodeFitResult]: + """Persist a schema-1.2 shadow artifact; production eligibility stays false.""" + dataset = build_episode_dataset(data, base) + fitted = fit_episode_safety_model(dataset, seed=seed) + definition = { + "horizon_minutes": 60, + "horizons_minutes": list(HORIZONS), + "windows_minutes": list(WINDOWS), + "target": "delta_60m and any crossing within horizon", + "episode_weighting": "inverse episode length and equal month mass", + "telemetry_signals": ["ht:P8", "ht:T11", "ht:F19"], + } + metadata = { + "model_id": directory.name, + "schema_version": "1.2", + "model_type": f"{fitted.report['selected_family']}+episode_multi_horizon", + "training_dataset_id": data.manifest.dataset_id, + "git_commit": git_commit, + "python_version": platform.python_version(), + "sklearn_version": sklearn.__version__, + "target_signal": base.target_signal_id, + "target_source": "pak", + "target_unit": base.target_unit, + "horizon_minutes": 60, + "feature_names": dataset.feature_names, + "baseline_feature": dataset.baseline_feature, + "tag_dictionary_sha256": data.manifest.tag_dictionary_sha256, + "feature_schema_hash": feature_schema_hash(dataset.feature_names, definition), + "processing": { + "feature_definition": definition, + "selection_period": "2024", + "calibration_period": fitted.report["calibration_period"], + "threshold_period": fitted.report["threshold_period"], + "alarm_threshold": fitted.report["alarm_threshold"], + "production_status": "shadow_only", + "telemetry_semantics_status": "disputed_by_qa_2026_09_11", + "pak_only_ablation_required": True, + }, + "time_boundaries": { + "train_end_local": "2026-01-01", + "validation_end_local": "2026-01-01", + "source_timezone": data.manifest.source_timezone, + "calibration_start": "2025-01-01", + "calibration_end": "2025-07-01", + }, + "seed": seed, + "capabilities": { + "supports_forecast": True, + "supports_actions": False, + "supports_uncertainty": True, + "supports_exceedance_probability": True, + "supports_multi_horizon": True, + }, + "applicability": { + "method": "joint_pca_mahalanobis", + "required_features": [dataset.baseline_feature], + "coverage": 0.99, + "purpose": "shadow-only PAK episode forecast", + "action_comparison": "forbidden", + }, + "reports": ("metrics.json",), + } + return save_model(directory, fitted.predictor, metadata, fitted.report), fitted + + +__all__ = [ + "EpisodeDataset", + "EpisodeFitResult", + "EpisodeSafetyPredictor", + "HORIZONS", + "build_episode_dataset", + "episode_sample_weights", + "event_metrics", + "fit_episode_safety_model", + "rolling_month_folds", + "save_episode_safety_model", + "select_event_threshold", +] From 5c7f17a3b86ee57bc3e4829ff018810299a3b1eb Mon Sep 17 00:00:00 2001 From: bug00n Date: Mon, 21 Sep 2026 19:49:57 +0300 Subject: [PATCH 07/10] fix --- DESIGN.md | 1 + PHYSICAL_CHEMISTRY_REPORT.md | 149 +++++++ README.md | 7 + STAGE10.md | 27 ++ .../test_historical_action_effects.py | 123 ++++++ source/main.py | 35 ++ source/ml/__init__.py | 10 + source/ml/action_effects.py | 392 +++++++++++++++++- 8 files changed, 743 insertions(+), 1 deletion(-) create mode 100644 PHYSICAL_CHEMISTRY_REPORT.md create mode 100644 STAGE10.md create mode 100644 global_tests/test_historical_action_effects.py diff --git a/DESIGN.md b/DESIGN.md index 89a486d..cdeff43 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -919,6 +919,7 @@ Backend → ML: подготовленные таблицы, manifest, отчё | 7 | Safety-first alarm, joint applicability и усиленный action capability gate | | 8 | Read-only диагностика временного drift, режимов, ПАК--ЛИМС и устойчивости признаков | | 9 | Эпизодный multi-horizon shadow-прогноз schema 1.2 и отложенный LIMS-контроль | +| 10 | Matched-episode P8/F19 action study с UI-дисклеймером и `supports_actions=false` | Незавершённая возможность обозначается через capabilities и понятный отказ, не скрывается условной константой. diff --git a/PHYSICAL_CHEMISTRY_REPORT.md b/PHYSICAL_CHEMISTRY_REPORT.md new file mode 100644 index 0000000..9cb0865 --- /dev/null +++ b/PHYSICAL_CHEMISTRY_REPORT.md @@ -0,0 +1,149 @@ +# Физико-химический смысл Нефтекода + +## Вывод + +Проект моделирует не абстрактные таблицы, а цепочку превращения нефти в дизельное топливо: АВТ выделяет дизельную фракцию по летучести, гидроочистка удаляет сероорганические соединения и изменяет состав топлива, после чего блендинг собирает товарную партию. Главная технологическая цель в выданном ТЗ - массовая сера в дизельном топливе не выше 10 мг/кг. Модель полезна прежде всего как раннее предупреждение: она может заметить, что через час качество окажется у границы, раньше лабораторного подтверждения. + +Важно различать три уровня достоверности. + +1. Измерения ПАК, ЛИМС и телеметрия описывают реальное наблюдаемое состояние. +2. Прогноз серы на 60 минут - статистическая оценка будущего измерения, а не доказательство того, что изменение уставки даст нужный эффект. +3. Расчёт S/T95/CN и дозы присадки - синтетическая модель демонстратора. Его физические формулы полезны, но его коэффициенты, запасы и пределы не являются паспортом реального завода. + +## Что происходит в установке + +```text +сырая нефть + -> обессоливание и нагрев + -> АВТ, ректификация по температурам кипения + -> дизельная фракция примерно 240-350 C + -> гидроочистка с H2 на катализаторе + -> гидроочищенный компонент дизеля + -> резервуарное смешение компонентов и присадки + -> товарное дизельное топливо +``` + +### АВТ + +В атмосферной колонне К-2 нагретая нефть частично испаряется. Внизу остаются наиболее тяжёлые молекулы, выше конденсируются более лёгкие. Температурный профиль колонны, давление, орошение и отпарной пар сдвигают границы отсечки: сколько молекул попадёт в каждую фракцию. На выданной схеме дизельный поток подписан как фракция 240-350 C; рядом показаны расход, лабораторные свойства и точки T95/плотности/серы. + +Физический смысл ключевых сигналов АВТ: + +| Сигнал/свойство | Что означает физически | Почему может быть важно | +| --- | --- | --- | +| Температуры К-2, печи, боковых погонов | Где лежит граница испарения и конденсации | Меняют состав дизельной отсечки и её кривую кипения | +| Давление верха/низа колонны | Равновесие жидкость-пар | Сдвигает температуры кипения и разделение фракций | +| Орошение и циркуляционное орошение | Теплоотвод и внутренний рефлюкс | Делает разделение резче, но влияет на тепловой баланс | +| Расход фракции 240-350 C | Количество дизельного компонента | Нужен для материального баланса и времени прохождения | +| T95, плотность, сера | Паспорт конкретной отсечки | Это не уставки, а свойства результата разделения | + +АВТ почти не уничтожает серу: она перераспределяет серосодержащие молекулы между фракциями. Поэтому тяжёлая, более высококипящая отсечка часто несёт иной серный профиль, чем лёгкая. Но из одного факта «стало тяжелее по T95» нельзя количественно восстановить серу без подтверждённого состава и данных. + +### Гидроочистка 24-2000 + +Центральная химическая реакция - гидрообессеривание (HDS). Сероорганические соединения дизельной фракции на сульфидном CoMo/NiMo-катализаторе реагируют с водородом; C-S связь разрушается, а сера уходит как H2S. Упрощённо: + +```text +R-S-R' + H2 -> углеводороды + H2S +``` + +В действительности в сырье есть тиофены, бензотиофены и дибензотиофены. Простые сернистые соединения удаляются легче; стерически экранированные дибензотиофены требуют гидрирования ароматического кольца либо труднее доступного прямого пути десульфуризации. Поэтому последние миллиграммы серы к пределу особенно «дороги» по водороду, температуре и ресурсу катализатора. + +Для HDS одновременно существенны температура, парциальное давление H2, отношение H2/сырьё, объёмная скорость сырья и состояние катализатора. Умеренно более высокая температура, высокое давление водорода и большее время контакта обычно повышают глубину HDS, но это не означает правило «поднять температуру всегда хорошо»: растут энергозатраты, побочные реакции, риск закоксовывания/дезактивации и меняется весь тепловой режим. [Weng et al., 2020](https://pubs.acs.org/doi/10.1021/acs.iecr.0c04049) + +В проекте подтверждены только смысл трёх потенциально управляемых параметров Р-202: `ht:P8` - температура газосырьевой смеси на входе, `ht:F26` - объёмный расход сырья, `ht:F19` - давление на входе. Но нет подтверждённых единиц, рабочих диапазонов, скорости допустимого изменения, задержек и причинной модели. Следовательно, их можно включать в диагностический контекст и исследование прогноза, но нельзя превращать в реальные рекомендации. + +### Что меняется во времени + +У причинной цепочки есть инерция: + +```text +смена состава/режима +-> смешение в аппаратах и трубопроводах +-> реактор и катализатор +-> сепарация H2/H2S/жидкости +-> резервуар/линия продукта +-> ПАК или лабораторная проба +-> публикация результата +``` + +Поэтому «значение серы сейчас» и «результат действия сейчас» - разные вещи. ПАК может обновляться быстро, ЛИМС даёт более надёжный контрольный результат позднее. В проекте эта физическая задержка отражена через `measured_at` и `available_at`: лабораторная проба не должна попадать в признаки до того, как её реально могли узнать. + +## Зачем нужны S, T95 и цетановое число + +### Сера S + +Сера здесь измеряется массово в мг/кг (для массового ppm число то же). Это ключевой жёсткий лимит ТЗ. Низкая сера снижает образование SOx/сульфатной составляющей выбросов и нужна для совместимости с современными системами очистки выхлопа; для ориентира, стандарт ULSD США задаёт максимум 15 ppm, а в задании принят более строгий лимит 10 мг/кг. [EPA](https://www.epa.gov/diesel-fuel-standards/diesel-fuel-standards-and-rulemakings) + +Для решения важна не только точка прогноза, но и консервативная верхняя граница. Если точка равна 9.5, а верхняя оценка выше 10, безопасный контур должен не разрешать смесь как доказанно проходящую. Именно так работает `unknown`, а не ложный `pass`. + +### T95 + +T95 - температура, при которой в стандартной перегонке отогнано 95% объёма топлива. Это компактное описание тяжёлого хвоста: чем выше T95, тем больше тяжёлых высококипящих молекул. Он связан с испаряемостью, склонностью к неполному сгоранию и совместимостью с требованиями к фракционному составу. В демонстраторе граница 360 C - сценарное допущение; нельзя называть её подтверждённой спецификацией конкретного продукта без ГОСТ/паспорта предприятия. + +### Цетановое число CN + +Цетановое число характеризует склонность топлива к самовоспламенению в дизельном двигателе: больше CN - короче задержка воспламенения. Это влияет на фазу тепловыделения, холодный пуск и работу двигателя. [NREL](https://afdc.energy.gov/files/u/publication/mixing_controlled_compression_ignition.pdf?743aa3e783=) + +CN не является прямой функцией одной только плотности или T95. Формульный cetane index можно оценивать из плотности и фракционного состава, но он не заменяет измеренное цетановое число, особенно для нестандартных компонентов и при наличии цетаноповышающей присадки. [NREL](https://www.nrel.gov/docs/fy04osti/36240.pdf) + +## Блендинг: где физика, а где приближение + +Для массовой концентрации серы физически корректен материальный баланс при известной массе каждого компонента: + +```text +S_mix = sum(w_i * S_i), sum(w_i) = 1 +``` + +Это верно потому, что масса серы сохраняется при простом смешении без химической реакции. Поэтому лучший практический модуль проекта - проверка масс, запасов и верхней границы серы для партии. + +Линейное усреднение T95 и CN в текущей реализации - не универсальный физический закон. Кривая кипения смеси и особенно фактическое CN могут вести себя нелинейно; эффект присадки зависит от химии топлива, дозы и метода измерения. Поэтому сценарная таблица «доза -> прирост CN» допустима только как калиброванный паспорт конкретной присадки и конкретной базы. Пока такой паспорт не получен, результат должен оставаться `model_demo`. + +Также глубокая гидроочистка может ухудшать смазывающую способность; это отдельное качество, которого нынешние S/T95/CN не покрывают. Например, спецификации ULSD включают отдельное требование к смазывающей способности, а малые доли biodiesel иногда применяются для её улучшения. [NREL/AFDC](https://afdc.energy.gov/fuels/biodiesel-benefits) + +## Как физика улучшает именно этот проект + +### Уже правильные решения + +- Разделение ПАК и ЛИМС защищает от ложного ощущения точности: ПАК - оперативная траектория, ЛИМС - отложенный контрольный факт. +- Прогноз и модель эффекта действия разделены. Историческая корреляция «после роста серы подняли температуру» показывает реакцию оператора, а не эффект температуры. +- Верхняя граница серы применяется раньше ранжирования. Производительность и цена не могут компенсировать нарушение S. +- `abstain` при неподтверждённом теге, единице, запасе или интервале - технологически честнее произвольной рекомендации. + +### Следующие полезные улучшения + +| Приоритет | Что добавить | Физический смысл | Практический эффект | +| --- | --- | --- | --- | +| 1 | Карта материальных потоков с точками отбора | Подтвердить, какая именно дизельная фракция АВТ приходит на 24-2000 и в какой резервуар уходит продукт | Убирает риск учить модель на несвязанных потоках | +| 2 | Время пребывания и transport lag по участкам | Сигнал должен попасть в будущую пробу после физически возможной задержки | Более осмысленный горизонт, меньше ложных причинных связей | +| 3 | Баланс H2 и индикаторы реактора | H2/сырьё, давление, температура и расход определяют тяжесть HDS | Нужные признаки для прогноза и будущей модели последствий | +| 4 | Деактивация катализатора | Один и тот же режим с течением кампании даёт другое качество | Добавляет time-on-stream/суррогат кампании и снижает drift | +| 5 | Реальный паспорт компонентов и присадки | Нелинейность T95/CN и смазывающая способность нельзя заменить общим коэффициентом | Позволяет перенести блендинг из demo в ограниченный промышленный advisory | +| 6 | Event-based causal study изменений | Сравнить похожие эпизоды до/после разрешённых изменений с учётом трендов и задержек | Единственный путь от прогноза к безопасной рекомендации уставки | + +## Минимальный физический пилот + +1. Технолог вместе с аналитиком рисует потоковую карту: источник, аппарат, точка ПАК, точка ЛИМС, резервуар и товарный выпуск для каждого сигнала. +2. Для каждого перехода фиксируются объём/масса, нормальный расход и диапазон времени прохождения. Это задаёт разрешённые лаги, а не поиск максимальной корреляции по всем сдвигам. +3. На истории сравниваются: persistence последней серы, прогноз только по прошлой сере и прогноз с подтверждёнными технологическими признаками. Побеждает не модель с красивым средним MAE, а модель с меньшим числом пропущенных переходов через 10 мг/кг. +4. Проводится shadow mode: система только предупреждает и журналирует, оператор продолжает работать по регламенту. ЛИМС проверяет, насколько хорошо предупреждения попадали в реальные нарушения. +5. Только после накопления задокументированных эпизодов изменений можно отдельно проверять эффект конкретной уставки. До этого UI должен говорить «наблюдаем риск», а не «подними температуру». + +## Открытые вопросы, которые нельзя закрыть данными проекта в одиночку + +- Точный маршрут между АВТ и 24-2000, точки смешения и резервуарная логистика. +- Единицы, нормальные диапазоны и допустимые скорости изменения `ht:P8`, `ht:F26`, `ht:F19`. +- Тип и возраст катализатора, фактический H2/сырьё, давление водорода, состав сырья и H2S. +- Реальные товарные ограничения сверх серы: T95, CN, плотность, вспышка, низкотемпературные свойства, смазывающая способность. +- Паспорт присадки и результаты стендовых/лабораторных смешений. + +Пока на эти вопросы нет утверждённого ответа, правильное применение системы - раннее предупреждение, воспроизводимая проверка качества и безопасный расчёт сценарных смесей, а не выдача команд установке. + +## Источники + +1. Техническое задание проекта, `materials/ТЗ_нефтекод.docx`; технологические схемы АВТ, `materials/АВТ_схемы.pdf`; словарь точек, `materials/Теги_хакатон.xlsx`. +2. [Weng, X. et al. Ultradeep Hydrodesulfurization of Diesel](https://pubs.acs.org/doi/10.1021/acs.iecr.0c04049), Industrial & Engineering Chemistry Research, 2020. +3. [US EPA Diesel Fuel Standards and Rulemakings](https://www.epa.gov/diesel-fuel-standards/diesel-fuel-standards-and-rulemakings). +4. [NREL Measurement or Prediction of Cetane Number](https://afdc.energy.gov/files/u/publication/mixing_controlled_compression_ignition.pdf?743aa3e783=). +5. [NREL Diesel Fuel Properties and Cetane Index](https://www.nrel.gov/docs/fy04osti/36240.pdf). +6. [NREL Alternative Fuels Data Center Biodiesel Benefits and Considerations](https://afdc.energy.gov/fuels/biodiesel-benefits). diff --git a/README.md b/README.md index 77fd6b4..265c0b0 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,11 @@ - [Stage 7](STAGE7.md) — safety-first alarm, joint applicability и усиленный action gate. - [Stage 8](STAGE8.md) — диагностика drift, режимов, ПАК–ЛИМС и устойчивости признаков. - [Stage 9](STAGE9.md) — эпизодный multi-horizon shadow-прогноз и отложенный LIMS-контроль. +<<<<<<< HEAD >>>>>>> f0ad14f (Complete ML stages and safety diagnostics) +======= +- [Stage 10](STAGE10.md) — исторический модельный эффект P8/F19 без совета оператору. +>>>>>>> f3eefd7 (Add historical action effects and stage 10 report) - [Code walkthrough](CODE_WALKTHROUGH.md) — папки, файлы и хронология вызовов почти построчно. - [ML system design](DESIGN.md#8-ml-неопределённость-и-модель-последствий) — обучение, метрики, анализ ошибок и жизненный цикл модели; общие контракты и данные описаны в том же документе. - [Материалы задания](materials/README.md) — ТЗ, схемы и исходные данные. @@ -202,6 +206,9 @@ python -m source.main train --dataset data/processed/ --with-uncerta # Эпизодный прогноз 10/20/30/60 минут; создаёт только shadow-артефакт schema 1.2. python -m source.main train-v2-shadow --dataset data/processed/ + +# Оценить исторический модельный эффект P8/F19; рекомендации не включает. +python -m source.main evaluate-action-shadow --dataset data/processed/ ``` Контракт и ограничения ML v2 описаны в [STAGE9.md](STAGE9.md). Test 2026 служит diff --git a/STAGE10.md b/STAGE10.md new file mode 100644 index 0000000..d470573 --- /dev/null +++ b/STAGE10.md @@ -0,0 +1,27 @@ +# Stage 10 — исторический модельный эффект P8/F19 + +Команда исследования: + +```bash +python -m source.main evaluate-action-shadow \ + --dataset data/processed/aacc7c1ab3d9 +``` + +Модуль выделяет изолированные заметные изменения только `ht:P8` и `ht:F19`, +сопоставляет их с ближайшими спокойными состояниями того же календарного года и +строит Ridge/q95-прогноз серы на 60/120/180 минут. `ht:F26` используется только +как контекст состояния и никогда не становится изменяемым кандидатом. + +UI-контракт возвращает обычное изменение серы, point/upper для каждого горизонта, +применимость и причины отказа. Заголовок фиксирован как «Модельный эффект по +историческим эпизодам», поле `advisory=false`, `supports_actions=false`. + +На dataset `aacc7c1ab3d9` найдено 1 212 matched-пар: 546 для `P8`, 666 для +`F19`. Validation-2025 не прошёл evidence gate: модель хуже hold на всех трёх +горизонтах, хотя calibration coverage выше 95%. Audit-2026 также хуже hold; q95 +coverage равна 95.70% / 93.71% / 93.71% для 60/120/180 минут. + +Поэтому модуль реализован как честный исследовательский экран, но текущая модель +возвращает `ACTION_EFFECT_VALIDATION_FAILED` и не может выдавать совет. Следующий +эксперимент — temporal residualization/cross-fitting и проверка устойчивости знака; +audit-2026 нельзя использовать для выбора варианта. diff --git a/global_tests/test_historical_action_effects.py b/global_tests/test_historical_action_effects.py new file mode 100644 index 0000000..5f8bbb9 --- /dev/null +++ b/global_tests/test_historical_action_effects.py @@ -0,0 +1,123 @@ +"""Tests for shadow-only matched historical action effects.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest + +from source.ml.action_effects import ( + ACTION_HORIZONS, + HistoricalActionEffectModel, + build_historical_action_dataset, +) +from source.ml.uncertainty import ApplicabilityResult + + +class _Estimator: + def __init__(self, action_coefficient: float = 0.0) -> None: + self.action_coefficient = action_coefficient + + def predict(self, frame: pd.DataFrame) -> np.ndarray: + return frame["baseline_sulfur"].to_numpy(dtype=float) + self.action_coefficient * frame[ + "delta_ht:P8" + ].to_numpy(dtype=float) + + +class _Applicable: + def assess(self, _: pd.DataFrame) -> ApplicabilityResult: + return ApplicabilityResult(True, None, ()) + + +def _prepared_data() -> SimpleNamespace: + timestamp = pd.date_range("2023-01-01", periods=240, freq="10min", tz="UTC") + p8 = np.full(len(timestamp), 0.15) + f19 = np.full(len(timestamp), 200.0) + p8[30:60] = 0.17 + f19[100:130] = 215.0 + sulfur = 8.0 + np.linspace(0.0, 0.5, len(timestamp)) + telemetry = pd.DataFrame( + { + "timestamp": timestamp, + "ht:P8": p8, + "ht:F19": f19, + "ht:F26": np.full(len(timestamp), 350.0), + } + ) + quality = pd.DataFrame( + { + "signal_id": ["ht:2:Mg.Sulfur"] * len(timestamp), + "source": ["pak"] * len(timestamp), + "validity": ["valid"] * len(timestamp), + "unit": ["mg/kg"] * len(timestamp), + "measured_at": timestamp, + "value": sulfur, + } + ) + return SimpleNamespace(telemetry=telemetry, quality=quality) + + +def test_historical_dataset_uses_only_p8_f19_actions_and_f26_as_context() -> None: + dataset = build_historical_action_dataset( + _prepared_data(), # type: ignore[arg-type] + train_end="2024-01-01", + threshold_quantile=0.9, + ) + + assert set(dataset.thresholds) == {"ht:P8", "ht:F19"} + assert "delta_ht:F26" not in dataset.frame + assert dataset.frame["pair_id"].value_counts().eq(2).all() + assert dataset.frame.loc[dataset.frame["is_action_episode"], "delta_ht:P8"].ne(0).any() + assert dataset.frame.loc[dataset.frame["is_action_episode"], "delta_ht:F19"].ne(0).any() + + +def test_historical_estimate_is_ui_ready_but_never_advisory() -> None: + model = HistoricalActionEffectModel( + {horizon: _Estimator(-10.0) for horizon in ACTION_HORIZONS}, + {horizon: _Estimator(-10.0) for horizon in ACTION_HORIZONS}, + {horizon: 0.5 for horizon in ACTION_HORIZONS}, + _Applicable(), # type: ignore[arg-type] + {"ht:P8": (-0.02, 0.02), "ht:F19": (-20.0, 20.0)}, + {}, + ) + state = { + "baseline_sulfur": 9.0, + "sulfur_slope_60m": 0.0, + "ht:P8": 0.15, + "ht:F19": 200.0, + "ht:F26": 350.0, + } + + estimate = model.estimate(state, "ht:P8", 0.01) + payload = estimate.as_ui_payload() + + assert estimate.effect_by_horizon[60] == pytest.approx(-0.1) + assert payload["advisory"] is False + assert payload["title"] == "Модельный эффект по историческим эпизодам" + assert model.supports_actions is False + + +def test_unseen_delta_or_unsafe_upper_abstains() -> None: + model = HistoricalActionEffectModel( + {horizon: _Estimator() for horizon in ACTION_HORIZONS}, + {horizon: _Estimator() for horizon in ACTION_HORIZONS}, + {horizon: 2.0 for horizon in ACTION_HORIZONS}, + _Applicable(), # type: ignore[arg-type] + {"ht:P8": (-0.02, 0.02), "ht:F19": (-20.0, 20.0)}, + {}, + ) + state = { + "baseline_sulfur": 9.0, + "sulfur_slope_60m": 0.0, + "ht:P8": 0.15, + "ht:F19": 200.0, + "ht:F26": 350.0, + } + + estimate = model.estimate(state, "ht:P8", 0.5) + + assert estimate.applicable is False + assert "ACTION_DELTA_OUT_OF_OBSERVED_RANGE" in estimate.reason_codes + assert "SULFUR_UPPER_LIMIT_60M" in estimate.reason_codes diff --git a/source/main.py b/source/main.py index 67597a9..609c824 100644 --- a/source/main.py +++ b/source/main.py @@ -367,6 +367,30 @@ def train_v2_shadow_command( } +def train_action_shadow_command( + dataset: str | Path, + *, + config_path: str | Path = "config/runtime.toml", + root: Path = PROJECT_ROOT, +) -> dict[str, object]: + """Fit the matched historical action study without enabling recommendations.""" + from source.ml.action_effects import ( + build_historical_action_dataset, + fit_historical_action_model, + ) + + config = load_runtime_config(_resolve_path(config_path, root)) + data = load_prepared_dataset(_resolve_path(dataset, root)) + research = build_historical_action_dataset(data) + model = fit_historical_action_model(research, seed=config.seed) + return { + "dataset_id": data.manifest.dataset_id, + "supports_actions": False, + "evidence_gate_passed": model.report["evidence_gate_passed"], + "report": dict(model.report), + } + + def _load_trusted_model(model_path: str | Path, data: PreparedData, root: Path) -> ModelBundle: from source.ml.artifacts import load_model @@ -581,6 +605,13 @@ def main(argv: Sequence[str] | None = None) -> int: v2.add_argument("--dataset", required=True, help="prepared dataset directory") v2.add_argument("--output", default=None, help="model artifacts root") v2.add_argument("--config", default="config/runtime.toml", help="runtime config path") + action_shadow = subparsers.add_parser( + "evaluate-action-shadow", help="evaluate matched P8/F19 sulfur effects" + ) + action_shadow.add_argument("--dataset", required=True, help="prepared dataset directory") + action_shadow.add_argument( + "--config", default="config/runtime.toml", help="runtime config path" + ) evaluate = subparsers.add_parser( "evaluate", help="compare a frozen model with persistence on one temporal split" ) @@ -669,6 +700,10 @@ def main(argv: Sequence[str] | None = None) -> int: v2_result = train_v2_shadow_command(args.dataset, args.output, config_path=args.config) print(json.dumps(v2_result, ensure_ascii=False, indent=2)) return 0 + if args.command == "evaluate-action-shadow": + action_result = train_action_shadow_command(args.dataset, config_path=args.config) + print(json.dumps(action_result, ensure_ascii=False, indent=2)) + return 0 if args.command == "evaluate": evaluation_result = evaluate_command( args.dataset, diff --git a/source/ml/__init__.py b/source/ml/__init__.py index 5fc996a..faf53ca 100644 --- a/source/ml/__init__.py +++ b/source/ml/__init__.py @@ -19,6 +19,9 @@ "EpisodeSafetyPredictor": "source.ml.v2", "FeatureFrame": "source.ml.features", "GasContextSignal": "source.ml.blending", + "HistoricalActionDataset": "source.ml.action_effects", + "HistoricalActionEffectModel": "source.ml.action_effects", + "HistoricalActionEstimate": "source.ml.action_effects", "HybridComponentForecast": "source.ml.blending", "ModelBundle": "source.ml.artifacts", "SafetyFitResult": "source.ml.safety", @@ -37,6 +40,7 @@ "apply_hydrotreater_forecast": "source.ml.blending", "assess_change_policy": "source.ml.policy", "build_features": "source.ml.features", + "build_historical_action_dataset": "source.ml.action_effects", "build_episode_dataset": "source.ml.v2", "build_supervised_dataset": "source.ml.features", "calculate_mass_blend": "source.ml.blending", @@ -48,6 +52,7 @@ "evaluate_upper_bounds": "source.ml.uncertainty", "fit_stage5_uncertainty": "source.ml.uncertainty", "fit_joint_applicability": "source.ml.safety", + "fit_historical_action_model": "source.ml.action_effects", "fit_lims_correction": "source.ml.safety", "fit_safety_model": "source.ml.safety", "fit_episode_safety_model": "source.ml.v2", @@ -91,6 +96,9 @@ def __getattr__(name: str) -> object: "EpisodeSafetyPredictor", "FeatureFrame", "GasContextSignal", + "HistoricalActionDataset", + "HistoricalActionEffectModel", + "HistoricalActionEstimate", "HybridComponentForecast", "ModelBundle", "SafetyFitResult", @@ -109,6 +117,7 @@ def __getattr__(name: str) -> object: "apply_hydrotreater_forecast", "assess_change_policy", "build_features", + "build_historical_action_dataset", "build_episode_dataset", "build_supervised_dataset", "calculate_mass_blend", @@ -120,6 +129,7 @@ def __getattr__(name: str) -> object: "evaluate_upper_bounds", "fit_stage5_uncertainty", "fit_joint_applicability", + "fit_historical_action_model", "fit_lims_correction", "fit_safety_model", "fit_episode_safety_model", diff --git a/source/ml/action_effects.py b/source/ml/action_effects.py index f3f6168..fde4b73 100644 --- a/source/ml/action_effects.py +++ b/source/ml/action_effects.py @@ -4,9 +4,34 @@ from dataclasses import dataclass from math import isfinite +from typing import Any, Mapping, cast -from source.contracts import CandidateAction, CandidateKind, ControlSpec +import numpy as np +import pandas as pd +from sklearn.ensemble import GradientBoostingRegressor +from sklearn.impute import SimpleImputer +from sklearn.linear_model import Ridge +from sklearn.metrics import mean_absolute_error +from sklearn.neighbors import NearestNeighbors +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler + +from source.contracts import CandidateAction, CandidateKind, ControlSpec, Validity +from source.data.prepare import PreparedData from source.ml.controls import ActionEffectEvidence, JointControlDomain +from source.ml.safety import JointApplicabilityModel, fit_joint_applicability + +HISTORICAL_ACTION_CONTROLS = ("ht:P8", "ht:F19") +HISTORICAL_CONTEXT_SIGNALS = ("ht:F26",) +ACTION_HORIZONS = (60, 120, 180) +ACTION_STATE_FEATURES = ( + "baseline_sulfur", + "sulfur_slope_60m", + "ht:P8", + "ht:F19", + "ht:F26", +) +ACTION_MODEL_FEATURES = (*ACTION_STATE_FEATURES, "delta_ht:P8", "delta_ht:F19") @dataclass(frozen=True) @@ -30,6 +55,362 @@ def __post_init__(self) -> None: raise ValueError("action capability requires shadow replay and technologist pilot") +@dataclass(frozen=True) +class HistoricalActionDataset: + """Matched observational action episodes; never an authorization to act.""" + + frame: pd.DataFrame + thresholds: dict[str, float] + observed_delta_bounds: dict[str, tuple[float, float]] + match_distance_limit: float + + +@dataclass(frozen=True) +class HistoricalActionEstimate: + """UI-ready historical effect estimate explicitly separated from advice.""" + + control_id: str + proposed_delta: float + sulfur_by_horizon: dict[int, float] + sulfur_upper_by_horizon: dict[int, float] + effect_by_horizon: dict[int, float] + applicable: bool + reason_codes: tuple[str, ...] + + def as_ui_payload(self) -> dict[str, object]: + return { + "title": "Модельный эффект по историческим эпизодам", + "disclaimer": "Оценка не является советом по изменению уставки.", + "control_id": self.control_id, + "proposed_delta": self.proposed_delta, + "sulfur_change": { + str(horizon): self.effect_by_horizon[horizon] for horizon in ACTION_HORIZONS + }, + "predicted_sulfur": { + str(horizon): self.sulfur_by_horizon[horizon] for horizon in ACTION_HORIZONS + }, + "sulfur_upper": { + str(horizon): self.sulfur_upper_by_horizon[horizon] for horizon in ACTION_HORIZONS + }, + "applicable": self.applicable, + "advisory": False, + "reason_codes": self.reason_codes, + } + + +@dataclass(frozen=True) +class HistoricalActionEffectModel: + """Matched-episode sulfur model for shadow display, not control advice.""" + + point_estimators: Mapping[int, Any] + upper_estimators: Mapping[int, Any] + upper_shifts: Mapping[int, float] + applicability: JointApplicabilityModel + observed_delta_bounds: Mapping[str, tuple[float, float]] + report: Mapping[str, Any] + supports_actions: bool = False + + def estimate( + self, + state: Mapping[str, float], + control_id: str, + proposed_delta: float, + *, + sulfur_limit: float = 10.0, + ) -> HistoricalActionEstimate: + if control_id not in HISTORICAL_ACTION_CONTROLS: + raise ValueError("historical action model supports only ht:P8 or ht:F19") + if not np.isfinite(proposed_delta): + raise ValueError("proposed action delta must be finite") + missing = set(ACTION_STATE_FEATURES).difference(state) + if missing: + raise ValueError(f"historical action state is missing: {sorted(missing)}") + action = {"delta_ht:P8": 0.0, "delta_ht:F19": 0.0} + action[f"delta_{control_id}"] = float(proposed_delta) + row = pd.DataFrame([{**state, **action}], columns=ACTION_MODEL_FEATURES) + reasons: list[str] = [] + if not bool(self.report.get("evidence_gate_passed", False)): + reasons.append("ACTION_EFFECT_VALIDATION_FAILED") + lower, upper = self.observed_delta_bounds[control_id] + if not lower <= proposed_delta <= upper: + reasons.append("ACTION_DELTA_OUT_OF_OBSERVED_RANGE") + applicability = self.applicability.assess(row.loc[:, list(ACTION_STATE_FEATURES)]) + if not applicability.available: + reasons.append(applicability.reason_code or "OUT_OF_DOMAIN") + hold = row.copy() + hold.loc[:, ["delta_ht:P8", "delta_ht:F19"]] = 0.0 + sulfur: dict[int, float] = {} + sulfur_upper: dict[int, float] = {} + effect: dict[int, float] = {} + for horizon in ACTION_HORIZONS: + point = float(self.point_estimators[horizon].predict(row)[0]) + hold_point = float(self.point_estimators[horizon].predict(hold)[0]) + upper_point = float(self.upper_estimators[horizon].predict(row)[0]) + float( + self.upper_shifts[horizon] + ) + sulfur[horizon] = point + sulfur_upper[horizon] = max(point, upper_point) + effect[horizon] = point - hold_point + if sulfur_upper[horizon] > sulfur_limit: + reasons.append(f"SULFUR_UPPER_LIMIT_{horizon}M") + return HistoricalActionEstimate( + control_id, + proposed_delta, + sulfur, + sulfur_upper, + effect, + not reasons, + tuple(dict.fromkeys(reasons)), + ) + + +def _pak_sulfur(data: PreparedData) -> pd.DataFrame: + quality = data.quality + value = pd.to_numeric(quality["value"], errors="coerce") + selected = quality[ + quality["signal_id"].astype(str).eq("ht:2:Mg.Sulfur") + & quality["source"].map(lambda item: str(getattr(item, "value", item))).eq("pak") + & quality["validity"] + .map(lambda item: str(getattr(item, "value", item))) + .eq(Validity.VALID.value) + & quality["unit"].astype(str).eq("mg/kg") + & value.notna() + & np.isfinite(value) + ].copy() + selected["baseline_sulfur"] = value.loc[selected.index].astype(float) + selected["timestamp"] = pd.to_datetime(selected["measured_at"], utc=True) + return cast( + pd.DataFrame, + selected.loc[:, ["timestamp", "baseline_sulfur"]].sort_values("timestamp"), + ) + + +def _action_timeline(data: PreparedData) -> pd.DataFrame: + signals = (*HISTORICAL_ACTION_CONTROLS, *HISTORICAL_CONTEXT_SIGNALS) + missing = set(signals).difference(data.telemetry.columns) + if missing: + raise ValueError(f"telemetry is missing historical action signals: {sorted(missing)}") + telemetry = data.telemetry.loc[:, ["timestamp", *signals]].copy() + telemetry["timestamp"] = pd.to_datetime(telemetry["timestamp"], utc=True) + for signal in signals: + telemetry[signal] = pd.to_numeric(telemetry[signal], errors="coerce") + timeline = pd.merge_asof( + telemetry.sort_values("timestamp"), + _pak_sulfur(data), + on="timestamp", + direction="backward", + tolerance=pd.Timedelta(minutes=10), + ) + timeline["sulfur_slope_60m"] = ( + timeline["baseline_sulfur"] - timeline["baseline_sulfur"].shift(6) + ) / 60.0 + indexed = timeline.set_index("timestamp") + for horizon in ACTION_HORIZONS: + future = indexed["baseline_sulfur"].reindex(indexed.index + pd.Timedelta(minutes=horizon)) + timeline[f"sulfur_{horizon}m"] = future.to_numpy(dtype=float) + return timeline + + +def build_historical_action_dataset( + data: PreparedData, + *, + train_end: str = "2025-01-01", + threshold_quantile: float = 0.95, +) -> HistoricalActionDataset: + """Extract isolated P8/F19 changes and pair them with similar calm states.""" + if not 0.5 < threshold_quantile < 1.0: + raise ValueError("action threshold quantile must be between 0.5 and 1") + frame = _action_timeline(data) + timestamp = pd.to_datetime(frame["timestamp"], utc=True) + changes = frame.loc[:, list(HISTORICAL_ACTION_CONTROLS)].diff() + train = timestamp < pd.Timestamp(train_end, tz="UTC") + thresholds: dict[str, float] = {} + for signal in HISTORICAL_ACTION_CONTROLS: + positive_steps = changes.loc[train, signal].abs() + positive_steps = positive_steps[positive_steps > 0].dropna() + if positive_steps.empty: + raise ValueError(f"historical action signal {signal} has no observed changes") + thresholds[signal] = float(positive_steps.quantile(threshold_quantile)) + notable = pd.DataFrame( + {signal: changes[signal].abs().ge(threshold) for signal, threshold in thresholds.items()} + ) + stable_before = ( + changes.abs().rolling(6, min_periods=6).max().shift(1).lt(pd.Series(thresholds)).all(axis=1) + ) + quiet_after = ( + notable.iloc[::-1].rolling(18, min_periods=18).sum().iloc[::-1].shift(-1).fillna(1).eq(0) + ).all(axis=1) + outcomes = [f"sulfur_{horizon}m" for horizon in ACTION_HORIZONS] + complete = frame[[*ACTION_STATE_FEATURES, *outcomes]].notna().all(axis=1) + treated_mask = notable.sum(axis=1).eq(1) & stable_before & quiet_after & complete + calm_mask = notable.rolling(18, min_periods=18).sum().eq(0).all(axis=1) & complete + treated = frame.loc[treated_mask].copy() + calm = frame.loc[calm_mask].copy() + if treated.empty or calm.empty: + raise ValueError("no complete isolated action/control episodes were found") + + scaler = StandardScaler().fit(frame.loc[train & complete, list(ACTION_STATE_FEATURES)]) + treated_parts: list[pd.DataFrame] = [] + matched_parts: list[pd.DataFrame] = [] + distance_parts: list[np.ndarray] = [] + for year in sorted(pd.to_datetime(treated["timestamp"], utc=True).dt.year.unique()): + treated_year = treated.loc[pd.to_datetime(treated["timestamp"], utc=True).dt.year.eq(year)] + calm_year = calm.loc[pd.to_datetime(calm["timestamp"], utc=True).dt.year.eq(year)] + if calm_year.empty: + continue + treated_state = scaler.transform(treated_year.loc[:, list(ACTION_STATE_FEATURES)]) + calm_state = scaler.transform(calm_year.loc[:, list(ACTION_STATE_FEATURES)]) + distances, indices = ( + NearestNeighbors(n_neighbors=1).fit(calm_state).kneighbors(treated_state) + ) + treated_parts.append(treated_year) + matched_parts.append(calm_year.iloc[indices[:, 0]].copy()) + distance_parts.append(distances[:, 0]) + if not treated_parts: + raise ValueError("no same-year matched action/control episodes were found") + treated = pd.concat(treated_parts).copy() + matched = pd.concat(matched_parts).copy() + matched_distances = np.concatenate(distance_parts) + treated["pair_id"] = np.arange(len(treated)) + matched["pair_id"] = np.arange(len(treated)) + treated["is_action_episode"] = True + matched["is_action_episode"] = False + treated["control_id"] = np.where( + notable.loc[treated.index, "ht:P8"].to_numpy(), "ht:P8", "ht:F19" + ) + matched["control_id"] = treated["control_id"].to_numpy() + for signal in HISTORICAL_ACTION_CONTROLS: + treated[f"delta_{signal}"] = np.where( + treated["control_id"].eq(signal), + changes.loc[treated.index, signal].to_numpy(dtype=float), + 0.0, + ) + matched[f"delta_{signal}"] = 0.0 + treated["match_distance"] = matched_distances + matched["match_distance"] = matched_distances + result = pd.concat([treated, matched], ignore_index=True).sort_values( + ["timestamp", "pair_id"], kind="stable" + ) + bounds = { + signal: ( + float(treated.loc[treated[f"delta_{signal}"].ne(0), f"delta_{signal}"].min()), + float(treated.loc[treated[f"delta_{signal}"].ne(0), f"delta_{signal}"].max()), + ) + for signal in HISTORICAL_ACTION_CONTROLS + } + return HistoricalActionDataset( + result.reset_index(drop=True), + thresholds, + bounds, + float(np.quantile(matched_distances, 0.99)), + ) + + +def fit_historical_action_model( + dataset: HistoricalActionDataset, + *, + train_end: str = "2025-01-01", + validation_end: str = "2026-01-01", + seed: int = 42, +) -> HistoricalActionEffectModel: + """Fit shadow-only matched action models and evaluate 2026 without selection.""" + frame = dataset.frame + timestamp = pd.to_datetime(frame["timestamp"], utc=True) + train = timestamp < pd.Timestamp(train_end, tz="UTC") + validation = (timestamp >= pd.Timestamp(train_end, tz="UTC")) & ( + timestamp < pd.Timestamp(validation_end, tz="UTC") + ) + audit = timestamp >= pd.Timestamp(validation_end, tz="UTC") + if min(int(train.sum()), int(validation.sum()), int(audit.sum())) == 0: + raise ValueError("historical action model needs train, validation and audit episodes") + features = list(ACTION_MODEL_FEATURES) + point_estimators: dict[int, Any] = {} + upper_estimators: dict[int, Any] = {} + upper_shifts: dict[int, float] = {} + validation_metrics: dict[str, Any] = {} + audit_metrics: dict[str, Any] = {} + for horizon in ACTION_HORIZONS: + target = f"sulfur_{horizon}m" + point = Pipeline( + ( + ("imputer", SimpleImputer(strategy="median")), + ("scaler", StandardScaler()), + ("model", Ridge(alpha=10.0)), + ) + ).fit(frame.loc[train, features], frame.loc[train, target]) + upper = Pipeline( + ( + ("imputer", SimpleImputer(strategy="median")), + ( + "model", + GradientBoostingRegressor( + loss="quantile", alpha=0.95, max_depth=2, random_state=seed + ), + ), + ) + ).fit(frame.loc[train, features], frame.loc[train, target]) + validation_upper = upper.predict(frame.loc[validation, features]) + shift = max( + 0.0, + float(np.quantile(frame.loc[validation, target] - validation_upper, 0.95)), + ) + point_estimators[horizon] = point + upper_estimators[horizon] = upper + upper_shifts[horizon] = shift + for label, selected, destination in ( + ("validation", validation, validation_metrics), + ("audit", audit, audit_metrics), + ): + predicted = point.predict(frame.loc[selected, features]) + conservative = np.maximum( + predicted, upper.predict(frame.loc[selected, features]) + shift + ) + actual = frame.loc[selected, target].to_numpy(dtype=float) + destination[str(horizon)] = { + "period": label, + "rows": int(selected.sum()), + "mae": float(mean_absolute_error(actual, predicted)), + "hold_mae": float( + mean_absolute_error(actual, frame.loc[selected, "baseline_sulfur"]) + ), + "upper_coverage": float(np.mean(actual <= conservative)), + } + applicability = fit_joint_applicability( + frame.loc[train, list(ACTION_STATE_FEATURES)], + required_features=ACTION_STATE_FEATURES, + ) + evidence_gate_passed = all( + values["mae"] <= 0.90 * values["hold_mae"] and values["upper_coverage"] >= 0.95 + for values in validation_metrics.values() + ) + report = { + "basis": "matched_historical_episodes_not_causal_guarantee", + "controls": list(HISTORICAL_ACTION_CONTROLS), + "context_only": list(HISTORICAL_CONTEXT_SIGNALS), + "thresholds": dataset.thresholds, + "episode_rows": int(len(frame)), + "independent_pairs": int(frame["pair_id"].nunique()), + "per_control_pairs": { + signal: int(frame.loc[frame["is_action_episode"], "control_id"].eq(signal).sum()) + for signal in HISTORICAL_ACTION_CONTROLS + }, + "validation_2025": validation_metrics, + "audit_2026": audit_metrics, + "evidence_gate_passed": evidence_gate_passed, + "supports_actions": False, + "ui_label": "Модельный эффект по историческим эпизодам", + "disclaimer": "Не является советом по изменению уставки.", + } + return HistoricalActionEffectModel( + point_estimators, + upper_estimators, + upper_shifts, + applicability, + dataset.observed_delta_bounds, + report, + ) + + @dataclass(frozen=True) class LinearActionEffectModel: """Small scenario model; coefficients are explicit assumptions, not causality proof.""" @@ -190,9 +571,18 @@ def rank_linear_actions( __all__ = [ + "ACTION_HORIZONS", + "ACTION_MODEL_FEATURES", "ActionModelBundle", "ActionOutcome", + "HISTORICAL_ACTION_CONTROLS", + "HISTORICAL_CONTEXT_SIGNALS", + "HistoricalActionDataset", + "HistoricalActionEffectModel", + "HistoricalActionEstimate", "LinearActionEffectModel", + "build_historical_action_dataset", "evaluate_linear_action", + "fit_historical_action_model", "rank_linear_actions", ] From 2fc7a3fbbf68f691f96ce033465ef84bdc993679 Mon Sep 17 00:00:00 2001 From: bug00n Date: Mon, 21 Sep 2026 19:50:32 +0300 Subject: [PATCH 08/10] fix --- DESIGN.md | 159 ++++++- PHYSICAL_CHEMISTRY_REPORT.md | 31 +- QA_CLARIFICATIONS.md | 4 +- README.md | 54 ++- SCHEME_ANALYSIS_2026_09_14.md | 154 +++++++ STAGE10.md | 48 ++- STAGE2.md | 4 +- STAGE9.md | 132 +++++- .../test_historical_action_effects.py | 91 +++- global_tests/test_ml_v2.py | 90 +++- global_tests/test_ui.py | 53 +++ materials/README.md | 5 +- materials/schemes/README.md | 14 + ...76\321\200\320\276\320\262_14.09.2026.pdf" | Bin 0 -> 579088 bytes ...76\321\200\320\276\320\262_14.09.2026.pdf" | Bin 0 -> 513966 bytes ...76\321\200\320\276\320\262_14.09.2026.pdf" | Bin 0 -> 579775 bytes source/main.py | 399 +++++++++++++++++- source/ml/action_effects.py | 326 +++++++++++++- source/ml/artifacts.py | 40 ++ source/ml/safety.py | 46 +- source/ml/v2.py | 353 ++++++++++++++-- source/ui.py | 390 ++++++++++++++++- 22 files changed, 2308 insertions(+), 85 deletions(-) create mode 100644 SCHEME_ANALYSIS_2026_09_14.md create mode 100644 materials/schemes/README.md create mode 100644 "materials/schemes/\320\220\320\222\320\242_\320\232-10_\321\202\320\265\320\263\320\270_\320\276\321\200\320\263\320\260\320\275\320\270\320\267\320\260\321\202\320\276\321\200\320\276\320\262_14.09.2026.pdf" create mode 100644 "materials/schemes/\320\220\320\222\320\242_\320\232-1_\321\202\320\265\320\263\320\270_\320\276\321\200\320\263\320\260\320\275\320\270\320\267\320\260\321\202\320\276\321\200\320\276\320\262_14.09.2026.pdf" create mode 100644 "materials/schemes/\320\220\320\222\320\242_\320\232-2_\321\202\320\265\320\263\320\270_\320\276\321\200\320\263\320\260\320\275\320\270\320\267\320\260\321\202\320\276\321\200\320\276\320\262_14.09.2026.pdf" diff --git a/DESIGN.md b/DESIGN.md index cdeff43..30b841f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -6,6 +6,7 @@ Документ содержит целевую архитектуру. Актуальный runtime: Stages 0–6, полный ======= +<<<<<<< HEAD Документ содержит целевую архитектуру. Актуальный runtime: Stages 0–8, полный >>>>>>> f0ad14f (Complete ML stages and safety diagnostics) синтетический паспорт S/T95/CN с присадкой в Tkinter UI и CLI для frozen @@ -18,6 +19,21 @@ training/evaluation/replay. Исторический ML-артефакт ещё | Вопрос | Решение для версии 1 | | --- | --- | | Команда и ресурсы | Два исполнителя (человека или агента): backend и ML; 1–2 недели; CPU; без обязательных платных API | +======= +Документ содержит целевую архитектуру. Актуальный runtime: Stages 0–10, полный +синтетический паспорт S/T95/CN с присадкой в Tkinter UI и CLI для frozen +training/evaluation/replay. UI также умеет выполнить history-replay доверенного +forecast artifact, исследовательский P8/F19 scenario и отдельный ПАК--ЛИМС +контроль; schema 1.2 и action-effect остаются shadow/read-only. + +Основания: [техническое задание](materials/ТЗ_нефтекод.docx), [материалы](materials/README.md), [поэтапный план](IMPLEMENTATION_PLAN.md). ТЗ определяет обязательные требования, этот документ — технические контракты, план — порядок реализации. При изменении контракта документ и тестовые примеры обновляются в том же PR. + +## 1. Зафиксированные решения + +| Вопрос | Решение для версии 1 | +| --- | --- | +| Команда и ресурсы | Два исполнителя (человека или агента): backend и ML; 1–2 недели; CPU; без обязательных платных API | +>>>>>>> 1527107 (Extend UI and ML analysis materials) | Развёртывание | Одно локальное Python-приложение; toolchain нацелен на Python 3.11, локальный Stage-5 artifact собран в Python 3.12.3 | | Мультиагентность | Агент качества, агент надёжности, агент оптимизации и оркестратор с явными входами и выходами | | Взаимодействие | Синхронные вызовы Python-функций в одном процессе; структурированные результаты | @@ -197,6 +213,7 @@ Backend не дублирует признаки в UI; ML не пишет ал Точки ЛИМС АВТ — разные физические места/стадии, не варианты времени одного анализа (Q&A 11.09, 00:09:33). Общая подпись «Дизельное топливо» не разрешает объединять точки. +<<<<<<< HEAD Схема АВТ не подтверждает теги `ht:*`; точная карта потоков и имя `Pipeline` остаются открытыми вопросами [Q1–Q3](QA_CLARIFICATIONS.md#незакрытые-вопросы-и-границы-использования). @@ -238,6 +255,52 @@ Backend не дублирует признаки в UI; ML не пишет ал | Тип | Поля | | --- | --- | +======= +Новые схемы организаторов от 14.09 локализуют короткие `avt:*` на К-1, К-2 и К-10 +и подтверждают внутренний маршрут АВТ до продуктовой ветви ДТ 240-350 °C. Они не +привязывают ЛИМС 1/2/2.1/3 к стрелкам, не показывают маршрут/резервуар до 24-2000 +и не подтверждают теги `ht:*`; имя `Pipeline` также остаётся открытым вопросом. +Подробная карта и границы: [разбор схем](SCHEME_ANALYSIS_2026_09_14.md). + +Внутри приложения — timezone-aware UTC. Исходные даты без timezone интерпретируются согласно `source_timezone`. `Europe/Moscow` по умолчанию — **экспериментальное допущение**, не установленное свойство пакета; записывается в manifest. UI показывает часовой пояс рядом со временем. + +- `measured_at` — время измерения/отбора пробы. +- `available_at` — время, с которого результат мог быть известен системе. +- Для телеметрии и ПАК начальное допущение: `available_at = measured_at`. +- Для ЛИМС при отсутствии точного времени публикации: `available_at = measured_at + lims_delay_hours`. По ответу эксперта от 10.09.2026 публикация занимает до 4 часов; консервативное значение по умолчанию — 4 часа. Чувствительность к меньшей задержке оценивается без подбора на финальном test. +- В состоянии на `t` разрешены только записи с `measured_at <= t` и `available_at <= t`. +- Возраст всегда считается от `measured_at`, не от времени загрузки файла. +- Для каждого сигнала выбирается последнее доступное измерение по времени. +- Начальные пределы свежести: телеметрия 20 минут, ПАК 30 минут, ЛИМС 48 часов. Это модельные настройки, проверяемые на валидации, а не технологические нормативы. +- Пригодный свежий ЛИМС имеет приоритет над ПАК; затем идёт проверенный ВАК. Остальные источники сохраняются для сравнения. +- Устаревший ЛИМС показывается отдельно. Свежий пригодный ПАК может стать оперативным источником с предупреждением; устаревший анализ не становится текущей истиной. +- Расхождение источников не проверяется на произвольных несинхронных значениях: ПАК сопоставляется с временем отбора лабораторной пробы, но сам факт конфликта появляется только после `available_at` ЛИМС. Допуск расхождения обязателен в настройках активного показателя; неподтверждённый допуск не придумывается в коде. +- Неразрешённый конфликт обязательного показателя блокирует рекомендацию. Лабораторный результат остаётся контрольным фактом. + +Отсутствие необязательного сигнала даёт предупреждение. Отсутствие обязательного входа модели или ограничения блокирует соответствующий расчёт. Условия определяются явно в scenario/model metadata. + +## 5. Типы обмена между компонентами + +Типы реализуются в `contracts.py` через Pydantic. Числа конечны; отсутствие — `None`. Во внешнем JSON — `null`, никогда `NaN`. Неизвестные поля отклоняются. Строковые перечисления сериализуются своими значениями. Агентам передаются новые результаты; они не меняют входной `ProcessState` и конфигурацию. + +Обозначения: `Timestamp` — timezone-aware `datetime`; `SignalId`, `CandidateId` и `RunId` — строки; `Unit` — каноническая строка единицы. Поля таблиц обязательны; `| None` разрешает отсутствие значения, но поле остаётся в JSON. + +### Состояние и измерения + +| Тип | Поля | +| --- | --- | +| `Issue` | `code: str`, `severity: Literal['warning','blocking']`, `signal_id: str \| None`, `detail: str`, `source_ref: str \| None` | +| `Observation` | `id: str`, `signal_id: str`, `stage: Literal['avt','ht','blend']`, `source: Literal['telemetry','lims','pak','vak','scenario']`, `measured_at: Timestamp`, `available_at: Timestamp`, `value: float \| None`, `unit: str`, `validity: Literal['valid','missing','invalid','conflict']`, `source_ref: str` | +| `SignalSnapshot` | `selected: Observation \| None`, `alternatives: list[Observation]`, `age_seconds: float \| None`, `fresh: bool`, `issues: list[Issue]` | +| `ProcessState` | `schema_version: Literal['1.0']`, `state_id: str`, `as_of: Timestamp`, `dataset_id: str`, `mode: Literal['history','model_demo','hybrid']`, `signals: dict[str, SignalSnapshot]`, `issues: list[Issue]` | + +`signals` содержит все ожидаемые сценарием сигналы, в том числе отсутствующие. Исторические окна для модели не вкладываются целиком в состояние: признаки строятся через ограниченный моментом `as_of` доступ к подготовленным данным и отдельно сохраняются в журнале. + +### Действия, оценки и проверки + +| Тип | Поля | +| --- | --- | +>>>>>>> 1527107 (Extend UI and ML analysis materials) | `CandidateAction` | `id: str`, `kind: Literal['hold','setpoints','blend']`, `setpoints: dict[str,float]`, `blend_mass_fractions: dict[str,float]`, `additive_mass_fraction: float`, `horizon_minutes: int`, `is_model_scenario: bool` | | `MetricEstimate` | `value: float \| None`, `lower: float \| None`, `upper: float \| None`, `unit: str`, `basis: Literal['measured','forecast','formula','proxy']`, `interval_kind: Literal['none','empirical','scenario_bound']`, `interval_level: float \| None`, `reference: str`, `assumptions: list[str]` | | `AgentAssessment` | `agent: Literal['quality','reliability','optimizer']`, `state_id: str`, `candidate_id: str`, `evaluated_for: Timestamp`, `status: Literal['ok','degraded','unavailable']`, `metrics: dict[str,MetricEstimate]`, `issues: list[Issue]` | @@ -517,7 +580,14 @@ sequenceDiagram - Начальное разделение: train до 2025-01-01; validation до 2026-01-01; test с 2026-01-01 по конец доступной телеметрии. Границы задаются в исходном часовом поясе и преобразуются в UTC. - Внутри train — три последовательных временных блока для подбора. Цели, пересекающие границу следующего блока, исключаются. Внутреннее early stopping у HGB отключается (`early_stopping=False`); число итераций выбирается временной валидацией, без автоматической внутренней проверочной выборки ([описание параметров](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.HistGradientBoostingRegressor.html)). - Метрики: MAE, MAE в области 8–12 мг/кг, пропущенные превышения 10 мг/кг, ложные тревоги; для каждого показателя число примеров. Если превышений нет, соответствующая доля — `null` с пояснением. -- Выбор модели выполняется до test: минимизировать MAE при числе пропущенных превышений не больше baseline; при равенстве оставить более простую модель. Если подходящих моделей нет — baseline сохраняется, ограничение качества указывается в отчёте. +- Выбор модели выполняется до test: минимизировать MAE при числе пропущенных превышений не больше baseline; при равенстве оставить более простую модель. Если подходящих моделей нет — baseline сохраняется, ограничение качества указывается в отчёте. + +Схемы АВТ от 14.09 разрешают исследовать не все `avt:*` сразу, а три заранее +определённые группы К-2: состояние/нагрузка, циркуляционные орошения и дизельная +ветвь. Это только отдельная train-only ablation после PAK-only baseline. Группа +не попадает в production feature list без устойчивого выигрыша event-FNR при +FPR не выше 20%, проверки missingness/OOD и подтверждения межустановочного +маршрута. Audit-2026 не используется для её выбора. ### Неопределённость @@ -606,6 +676,7 @@ T95 и базовое цетановое число считаются лине Партия — 100 т; горизонт — 60 минут. Генерируются доли B от 0 до 1 с шагом 0.05 и дозы присадки 0–3% с шагом 1%; доли A/B масштабируются на оставшуюся массу. Всего не более 84 кандидатов с `hold`. Сначала проверяются паспорт и запасы, затем допустимые варианты ранжируются по риску, выпуску, стоимости и размеру изменения. `blend_normal` возвращает `hold`; `blend_risk`, `blend_t95_risk` и `blend_cetane_risk` возвращают изменяемые рекомендации; `blend_missing` возвращает `abstain`. Каждый положительный результат проходит все три обязательные границы и явно подписан как результат синтетической модели. +<<<<<<< HEAD Для уставок после их подтверждения — до трёх параметров, до пяти значений каждого вокруг текущего режима, не больше 125 комбинаций плюс `hold`. При превышении бюджета запуск останавливается с понятной ошибкой, не обрезает пространство скрыто. Реальные шаги и границы фиксируются после анализа источников, без выдуманных температур и давлений в этом документе. @@ -664,12 +735,75 @@ Metadata обязательно содержит: `model_id`, `schema_version`, ## 12. Интерфейс и команды запуска +======= + +Для уставок после их подтверждения — до трёх параметров, до пяти значений каждого вокруг текущего режима, не больше 125 комбинаций плюс `hold`. При превышении бюджета запуск останавливается с понятной ошибкой, не обрезает пространство скрыто. Реальные шаги и границы фиксируются после анализа источников, без выдуманных температур и давлений в этом документе. + +В `hybrid` качество компонента A заменяется прогнозом гидроочистки с его доверительной границей; остальные свойства компонентов и рецепт остаются модельными. История АВТ используется как входной контекст модели гидроочистки только при подтверждённом соответствии потоков и задержек. Если связь не установлена, UI показывает доступный контекст АВТ отдельно и не рисует доказанный перенос влияния. + +## 10. Ошибки, отказ и журнал + +### Коды причин + +| Код | Поведение | +| --- | --- | +| `MISSING_REQUIRED_SIGNAL`, `STALE_REQUIRED_SIGNAL` | `abstain`, список входов и их возраст | +| `SOURCE_CONFLICT`, `UNIT_UNCONFIRMED`, `TAG_UNCONFIRMED` | Блокирование зависящего расчёта; при обязательности — `abstain` | +| `UNIT_MISMATCH`, `UNASSESSED_REQUIRED_PROPERTY` | `unknown`/`abstain`; численное сравнение или операторское действие запрещено | +| `ACTION_MODEL_UNAVAILABLE`, `OUT_OF_DOMAIN` | Прогноз состояния можно показать, рекомендация изменения блокируется | +| `NON_FINITE_ACTION_FORECAST`, `ACTION_EFFECT_METRICS_NON_FINITE` | Action-кандидат или evidence отклоняется fail-closed | +| `QUALITY_LIMIT`, `CONTROL_LIMIT`, `BLEND_SUM`, `COMPONENT_STOCK` | Кандидат отклоняется с actual/limit | +| `UNCERTAINTY_UNAVAILABLE`, `RELIABILITY_UNAVAILABLE` | `unknown` по обязательной проверке | +| `NO_FEASIBLE_CANDIDATE` | `abstain` и сводка причин отбраковки | +| `NO_MATERIAL_IMPROVEMENT`, `ACTION_COOLDOWN` | `hold` только если исходный вариант прошёл ограничения | +| `MODEL_INCOMPATIBLE`, `CONFIG_INVALID`, `INPUT_CORRUPT` | Технический `RunFailure`, CLI exit code 1 | +| `AGENT_ERROR`, `JOURNAL_WRITE_FAILED` | Технический `RunFailure`; решение не отображается как успешно завершённое | + +Необязательные предупреждения не останавливают весь запуск. При отказе одного кандидата остальные проверяются; при неожиданном исключении агента цикл останавливается. + +### Содержимое запуска + +```text +runs// +├── metadata.json # git SHA, dataset/model/scenario/config hashes, версии +├── input.json # ProcessState, ScenarioConfig, DecisionContext +├── features.json # фактические значения и порядок признаков +├── trace.jsonl # вызовы ролей, результаты, причины, длительности +├── candidates.jsonl # все кандидаты, оценки и проверки +└── result.json # Recommendation либо RunFailure +``` + +`run_id` — UUID; `state_id` — хеш нормализованного состояния. Время и UUID исключены из критерия численной воспроизводимости. На одинаковых входах, версиях и `DecisionContext` совпадают оценки, выбранный кандидат, статусы и причины. + +Итоговые JSON записываются через временный файл и переименование в пределах каталога запуска. Если журнал не записался, UI сообщает об ошибке сохранения; успешный завершённый запуск не заявляется. Логи не содержат credentials и не отправляются во внешние сервисы. + +## 11. Артефакты модели + +```text +artifacts/models// +├── model.joblib # predictor и preprocessing +├── metadata.json # версии и совместимость +└── metrics.json # baseline, validation и ограничения +``` + +Joblib используется из стека scikit-learn. Загружаются только локально созданные доверенные артефакты; UI не принимает произвольные пользовательские файлы моделей. + +Metadata обязательно содержит: `model_id`, `schema_version`, `model_sha256`, `training_dataset_id`, `git_commit`, версии Python/sklearn, `target_signal`, `target_source`, `target_unit`, `horizon_minutes`, упорядоченные `feature_names`, хеш словаря, `feature_schema_hash`, параметры обработки/времени, границы train/validation/calibration, `seed`, capabilities, ограничения применимости и ссылки на отчёты. Для safety/v2-артефактов отдельно сохраняются `alarm_threshold`, `false_alarm_budget`, схема калибровки, раздельные метрики PAK/ЛИМС и описание OOD-порога. + +Режим применения проверяет версии сериализации, схему признаков, единицы, горизонт, словарь и параметры подготовки. `training_dataset_id` сохраняется для происхождения, но не обязан совпадать с набором применения: оценка на другом периоде допустима при совместимой схеме. Нельзя переобучать модель автоматически при несовместимости. Если baseline нужен без обученной модели, он выбирается явно в сценарии и подписывается как baseline. + +## 12. Интерфейс и команды запуска + +>>>>>>> 1527107 (Extend UI and ML analysis materials) ### Текущий desktop UI и целевой экран -Сейчас `source/ui.py` — локальное Tkinter-приложение. Оно запускает только -`model_demo`, отображает S/T95/CN, дозу присадки, checks и журнал, а также вызывает -`prepare` и `build-state` как диагностические операции. Оно не загружает `ModelBundle` -и не выполняет historical forecast. +Сейчас `source/ui.py` — локальное Tkinter-приложение. Оно запускает `model_demo`, +отображает S/T95/CN, дозу присадки, checks и журнал, а также вызывает `prepare`, +`build-state` и historical replay доверенного `ModelBundle`. На вкладке инструментов также +доступен schema-1.2 multi-horizon shadow replay с вероятностями пересечения лимита, +point/upper и reason codes. В отдельных экранах доступны исследовательский P8/F19 +action-shadow и контроль ПАК--ЛИМС; ни один из этих результатов не разрешает изменение +реальных уставок. Целевой экран должен поддерживать: @@ -939,6 +1073,7 @@ Backend → ML: подготовленные таблицы, manifest, отчё переставлять по диапазонам значений или допускать к реальному управлению без контрольного примера с единицами; зависимые физические интерпретации требуют проверки. +<<<<<<< HEAD - Короткие имена колонок `avt_tags.csv` и `242000_tags.csv` напрямую соответствуют листу «КИП» в `Теги_хакатон.xlsx`; дополнительное масштабирование значений не требуется. Это подтверждает идентичность тегов, но не создаёт отсутствующие в материалах технологические пределы. - Строка единиц в ЛИМС содержит ошибки. Каноническая единица определяется подтверждённым смыслом показателя: температуры кипения — `degC`, `D15` — `kg/m3`, `I250/I350` — `vol%`. Исходный ошибочный заголовок сохраняется в provenance. - Время ЛИМС — момент отбора пробы; публикация занимает до 4 часов. Все источники используют один общий часовой пояс. До получения точного IANA-идентификатора сохраняется настроенный `Europe/Moscow`, явно записываемый в manifest. @@ -946,6 +1081,20 @@ Backend → ML: подготовленные таблицы, manifest, отчё - Исправленные формулы ВАК фиксируются отдельно от исходного Excel: `T90` использует `59.57*(F15/2000)`; коэффициент `T6` в `T50` равен `0.471`; `CloudPoint` начинается с `0.0002*F22`; первый член `CFPP` равен `0.22088*T23`; коэффициент `T6` в `T95` равен `0.50`. В формуле `AVT6:240-350:CFPP` последняя скобка лишняя, член читается как `F65/F32 + F30`. - Подтверждённые доступные оператору переменные гидроочистки: `P8` — температура ГСС на входе Р-202, `T11` — массовый расход сырья, `F19` — давление на входе Р-202. Пределы скорости не предоставлены; задержку эффекта следует оценивать в диапазоне 0–3 часов. До определения единиц, диапазонов, совместной области и отдельной проверки модели действий реальные controls в сценариях остаются выключенными. - Допустимый шаг рекомендаций — 15–60 минут, горизонт — 0–3 часа. Для Stage 2 сохраняется заранее выбранная точка 60 минут. +======= +**Дополнение по схемам от 14.09:** три новых листа относятся только к АВТ и +добавляют рукописную привязку `avt:*` к К-1/К-2/К-10. Они частично закрывают Q1 +по аппаратам и внутренним потокам АВТ, но не закрывают карту ЛИМС-точек, маршрут +до 24-2000, спор Q2 по `ht:P8/T11/F19`, единицы или action limits. + +- Короткие имена колонок `avt_tags.csv` и `242000_tags.csv` напрямую соответствуют листу «КИП» в `Теги_хакатон.xlsx`; дополнительное масштабирование значений не требуется. Это подтверждает идентичность тегов, но не создаёт отсутствующие в материалах технологические пределы. +- Строка единиц в ЛИМС содержит ошибки. Каноническая единица определяется подтверждённым смыслом показателя: температуры кипения — `degC`, `D15` — `kg/m3`, `I250/I350` — `vol%`. Исходный ошибочный заголовок сохраняется в provenance. +- Время ЛИМС — момент отбора пробы; публикация занимает до 4 часов. Все источники используют один общий часовой пояс. До получения точного IANA-идентификатора сохраняется настроенный `Europe/Moscow`, явно записываемый в manifest. +- `24-2000:Mg.Sulfur` соответствует `Mg.Sulfur` точки 2 гидроочистки. Подтверждение корректности единиц принимается как массовый `ppm`, численно эквивалентный `mg/kg`; источники ПАК и ЛИМС всё равно остаются раздельными в обучении и отчёте. +- Исправленные формулы ВАК фиксируются отдельно от исходного Excel: `T90` использует `59.57*(F15/2000)`; коэффициент `T6` в `T50` равен `0.471`; `CloudPoint` начинается с `0.0002*F22`; первый член `CFPP` равен `0.22088*T23`; коэффициент `T6` в `T95` равен `0.50`. В формуле `AVT6:240-350:CFPP` последняя скобка лишняя, член читается как `F65/F32 + F30`. +- Подтверждённые доступные оператору переменные гидроочистки: `P8` — температура ГСС на входе Р-202, `T11` — массовый расход сырья, `F19` — давление на входе Р-202. Пределы скорости не предоставлены; задержку эффекта следует оценивать в диапазоне 0–3 часов. До определения единиц, диапазонов, совместной области и отдельной проверки модели действий реальные controls в сценариях остаются выключенными. +- Допустимый шаг рекомендаций — 15–60 минут, горизонт — 0–3 часа. Для Stage 2 сохраняется заранее выбранная точка 60 минут. +>>>>>>> 1527107 (Extend UI and ML analysis materials) - Для модельного блендинга обязательны сера, `T95` и цетановое число. Реализована явно модельная имитация резервуаров; присадка до 3% и её цена в 100 раз выше цены ДТ заданы в сценариях. Кривая эффективности остаётся синтетическим настраиваемым допущением и не подменяет отсутствующие реальные данные. - Отложенная историческая оценка прогноза и собственная модель альтернативных действий показываются раздельно; история прогноза сама по себе не доказывает эффект невыполненных воздействий. <<<<<<< HEAD diff --git a/PHYSICAL_CHEMISTRY_REPORT.md b/PHYSICAL_CHEMISTRY_REPORT.md index 9cb0865..e2d4aa8 100644 --- a/PHYSICAL_CHEMISTRY_REPORT.md +++ b/PHYSICAL_CHEMISTRY_REPORT.md @@ -37,6 +37,24 @@ | Расход фракции 240-350 C | Количество дизельного компонента | Нужен для материального баланса и времени прохождения | | T95, плотность, сера | Паспорт конкретной отсечки | Это не уставки, а свойства результата разделения | +Новые листы организаторов от 14.09.2026 делают эту картину конкретнее: + +- К-1 получает обессоленную нефть после ЭЛОУ; `F7/F8/F9` относятся к трём + параллельным ходам подачи, а `T1/P2/F3` описывают верх и орошение К-1; +- после печей П-1/2 и П-1/3 поток поступает в К-2; `F65` описывает загрузку, + `T20/T33` - температурный профиль, а группы `F14/F12/F64` - три + циркуляционных орошения; +- возле боковой К-9 и ветви ДТ 240-350 °C находятся `T66/F28/F32` и + `T71/F30/W70`; это физически осмысленная группа кандидатов для отдельной + ablation, но не доказанный вход гидроочистки; +- мазут из К-2 через П-3 поступает в вакуумную К-10. Теги К-10 полезны для + диагностики общего режима АВТ, но расположены после атмосферной дизельной + отсечки и не должны автоматически попадать в краткосрочный прогноз серы 24-2000. + +Полная постраничная карта находится в +[разборе схем](SCHEME_ANALYSIS_2026_09_14.md). Схемы не содержат привязки +ЛИМС-точек 1/2/2.1/3 и не показывают маршрут от ветви ДТ до 24-2000. + АВТ почти не уничтожает серу: она перераспределяет серосодержащие молекулы между фракциями. Поэтому тяжёлая, более высококипящая отсечка часто несёт иной серный профиль, чем лёгкая. Но из одного факта «стало тяжелее по T95» нельзя количественно восстановить серу без подтверждённого состава и данных. ### Гидроочистка 24-2000 @@ -51,7 +69,14 @@ R-S-R' + H2 -> углеводороды + H2S Для HDS одновременно существенны температура, парциальное давление H2, отношение H2/сырьё, объёмная скорость сырья и состояние катализатора. Умеренно более высокая температура, высокое давление водорода и большее время контакта обычно повышают глубину HDS, но это не означает правило «поднять температуру всегда хорошо»: растут энергозатраты, побочные реакции, риск закоксовывания/дезактивации и меняется весь тепловой режим. [Weng et al., 2020](https://pubs.acs.org/doi/10.1021/acs.iecr.0c04049) -В проекте подтверждены только смысл трёх потенциально управляемых параметров Р-202: `ht:P8` - температура газосырьевой смеси на входе, `ht:F26` - объёмный расход сырья, `ht:F19` - давление на входе. Но нет подтверждённых единиц, рабочих диапазонов, скорости допустимого изменения, задержек и причинной модели. Следовательно, их можно включать в диагностический контекст и исследование прогноза, но нельзя превращать в реальные рекомендации. +В Q&A 11.09 для Р-202 были описаны `ht:P8` (температура газосырьевой смеси), +`ht:T11` (массовый расход сырья) и `ht:F19` (давление на входе). Для action-effect +исследования только `ht:P8` и `ht:F19` считаются потенциальными controls; `ht:T11` +используется как контекст расхода при сопоставлении спокойных периодов. `ht:F26` +остаётся context-only: раннее описание называло его объёмным расходом, но +управляемость и единицы не подтверждены словарём. Для всех controls пока не +подтверждены единицы, рабочие диапазоны, скорость изменения, задержки и причинная +модель, поэтому реальные рекомендации уставок отключены. ### Что меняется во времени @@ -114,7 +139,7 @@ S_mix = sum(w_i * S_i), sum(w_i) = 1 | Приоритет | Что добавить | Физический смысл | Практический эффект | | --- | --- | --- | --- | -| 1 | Карта материальных потоков с точками отбора | Подтвердить, какая именно дизельная фракция АВТ приходит на 24-2000 и в какой резервуар уходит продукт | Убирает риск учить модель на несвязанных потоках | +| 1 | Достроить карту от ветви ДТ К-2 до 24-2000 и точек ЛИМС | Внутренняя топология АВТ теперь известна, но межустановочный маршрут, смешение и точки отбора всё ещё не показаны | Убирает риск учить модель на физически несвязанных потоках | | 2 | Время пребывания и transport lag по участкам | Сигнал должен попасть в будущую пробу после физически возможной задержки | Более осмысленный горизонт, меньше ложных причинных связей | | 3 | Баланс H2 и индикаторы реактора | H2/сырьё, давление, температура и расход определяют тяжесть HDS | Нужные признаки для прогноза и будущей модели последствий | | 4 | Деактивация катализатора | Один и тот же режим с течением кампании даёт другое качество | Добавляет time-on-stream/суррогат кампании и снижает drift | @@ -132,7 +157,7 @@ S_mix = sum(w_i * S_i), sum(w_i) = 1 ## Открытые вопросы, которые нельзя закрыть данными проекта в одиночку - Точный маршрут между АВТ и 24-2000, точки смешения и резервуарная логистика. -- Единицы, нормальные диапазоны и допустимые скорости изменения `ht:P8`, `ht:F26`, `ht:F19`. +- Единицы, нормальные диапазоны и допустимые скорости изменения `ht:P8`, `ht:T11`, `ht:F19`; управляемость `ht:F26`. - Тип и возраст катализатора, фактический H2/сырьё, давление водорода, состав сырья и H2S. - Реальные товарные ограничения сверх серы: T95, CN, плотность, вспышка, низкотемпературные свойства, смазывающая способность. - Паспорт присадки и результаты стендовых/лабораторных смешений. diff --git a/QA_CLARIFICATIONS.md b/QA_CLARIFICATIONS.md index f79344f..76706de 100644 --- a/QA_CLARIFICATIONS.md +++ b/QA_CLARIFICATIONS.md @@ -8,6 +8,7 @@ - **Т** — `Транскрипция_Запись_11_09_Q&A_Сессия_Нефтекод.txt`, заголовок датирует запись 11 сентября 2026 года; ссылки ниже — таймкоды записи. - **Ч** — `13.09 вопросы из чата.docx`; содержит вопросы участников с временем сообщений, **без письменных ответов экспертов**. Дата имени файла не доказывает отдельную встречу 13 сентября. Ссылки ниже — время сообщения и его тема. +- **С14** — три схемы АВТ, присланные организаторами 14.09.2026; отдельные листы К-1, К-2 и К-10 с рукописными короткими именами тегов. Схемы являются техническим свидетельством по АВТ, но не ответом по 24-2000. Исходники переданы отдельно от репозитория. SHA-256 для идентификации версии: @@ -34,6 +35,7 @@ | Теги 24-2000 не относятся к схеме АВТ | Т 00:11:16, Александр Шишкин | Пространства `avt:*` и `ht:*` остаются раздельными; по схеме АВТ нельзя подтверждать смысл тега гидроочистки. | | На АВТ и 24-2000 работают системы управления с обратной связью, быстро реагирующие на изменения качества/режима | Т 00:16:15, Александр Шишкин | **Вывод команды:** историческая корреляция может отражать реакцию управления. Обычный прогноз и доказательство эффекта вмешательства остаются разными задачами. Численная скорость реакции не подтверждена. | | Для гидроочистки описаны точки отбора до и после установки | Т 00:22:34, Александр Шишкин | Различаем сырьё и продукт. Общий ответ не доказывает точное соответствие имени `Pipeline` номеру точки. | +| Короткие `avt:*` локализованы на К-1, К-2 и К-10; на К-2 отдельно показана ветвь ДТ 240-350 °C, мазут уходит через П-3 на К-10 | С14; [постраничный разбор](SCHEME_ANALYSIS_2026_09_14.md) | Можно задавать топологические группы AVT-признаков и проверять внутреннюю согласованность датчиков. Межустановочный маршрут к 24-2000 и номера ЛИМС-точек не установлены. | Реплика Т 00:21:14, вероятно, предлагает расширять набор ВАК по лабораторным и технологическим данным, но термин распознан как «баг». Это направление для @@ -43,7 +45,7 @@ | ID | Вопрос и свидетельство | Что требуется; поведение до ответа | | --- | --- | --- | -| Q1 | АВТ: схема с позициями, точная карта ЛИМС 1/2/2.1/3, соответствия тегов аппаратам и потокам. Ч 16:08, вопросы 1–2; 16:09 | Нужна согласованная карта «установка → тег/точка → параметр → единица → аппарат/поток». Общее объяснение точек не подтверждает три предложенных участниками AVT-control. | +| Q1 | АВТ: схема с позициями, точная карта ЛИМС 1/2/2.1/3, соответствия тегов аппаратам и потокам. Ч 16:08, вопросы 1–2; 16:09; С14 | **Частично закрыт:** С14 связывает многие `avt:*` с аппаратами и линиями К-1/К-2/К-10. Остаются единицы, точная карта ЛИМС 1/2/2.1/3, маршрут дизеля до 24-2000 и подтверждение доступных оператору AVT-controls. | | Q2 | `ht:P8`, `ht:T11`, `ht:F19`: спор с прежним ответом. Участник сообщает диапазоны 0.12–0.22, 349–379, 147–248, не похожие на ранее названные величины. Ч 16:08, вопрос 3; 16:19 | Нужен контрольный timestamp со значениями, единицами и подтверждённым смыслом каждой колонки. Это сообщение о противоречии, не доказанная перестановка тегов. Реальное управление отключено; зависимые формулы и интерпретации требуют проверки. | | Q3 | Непроверенные ВАК, контрольный пример; `F25` с заявленной участником медианой около 13091; отсутствующее имя `Pipeline` в формуле T95. Ч 16:08, вопросы 4–5; 16:22 | Нужны проверенные формулы, единицы и пример входов/выхода, точка для `Pipeline`. Не исправлять масштаб `F25` или привязку `Pipeline` догадкой; для `F25` сначала установить пространство имён. Ранее принятые исправления не означают валидацию всего набора ВАК. | | Q4 | Температура → сера: участники приводят 0.03 мг/кг на 1 °C, сравнивают с 5–10%, предполагают отклик около часа и реакцию оператора за полчаса. Ч 16:11, 16:14; Т 00:13:20–00:16:15 | Нужны данные о динамике и воздействиях с учётом обратной связи. Ни один коэффициент или точная задержка не подтверждены записью; не переносить их в action model. | diff --git a/README.md b/README.md index 265c0b0..b7c978c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ - [План реализации](IMPLEMENTATION_PLAN.md) — этапы, сроки и разделение работы между backend и ML. - [Технический дизайн](DESIGN.md) — архитектура, структура файлов, типы данных, взаимодействие компонентов и проверки. - [Уточнения Q&A 11.09](QA_CLARIFICATIONS.md) — подтверждённые ответы, открытые вопросы и последствия для backend/ML. +- [Разбор схем АВТ 14.09](SCHEME_ANALYSIS_2026_09_14.md) — привязка `avt:*` к К-1/К-2/К-10, полезные ML-группы и границы доказательств. - [Гайд по проекту](PROJECT_GUIDE.md) — объяснение с нуля: суть ТЗ, роли backend/ML, объекты, этапы и рабочий процесс. - [Stage 1](STAGE1.md) — первый сквозной backend-цикл, demo-сценарии, журнал и ограничения этапа. - [Stage 2](STAGE2.md) — как оригинальные материалы превращаются в prepared dataset и `ProcessState`. @@ -30,7 +31,7 @@ ## Текущее состояние проекта -Реализация дошла до Stage 9 и содержит desktop UI, воспроизводимое обучение, +Реализация дошла до Stage 10 и содержит desktop UI, воспроизводимое обучение, историческую оценку, replay и приёмочную демонстрацию. Промышленная action model не заявлена. Stage 7 forecast-only history path сохранён как legacy `run-history`; актуальный исполняемый контракт описан ниже. @@ -44,6 +45,9 @@ python -m source.main diagnose-ml --dataset data/processed/aacc7c1ab3d9 Stage 9 добавляет schema-1.2 эпизодный multi-horizon прогноз только в shadow-режиме. Ответы Q&A 11.09 не подтверждают физический смысл/единицы `P8/T11/F19`, поэтому эти колонки не считаются разрешёнными действиями и требуют отдельной PAK-only ablation. +Схемы АВТ от 14.09 локализуют короткие `avt:*` на К-1/К-2/К-10, но не относятся +к 24-2000 и потому не снимают это ограничение. Они задают компактные группы для +будущей train-only ablation upstream-признаков; текущий feature list не изменён. Сейчас реализованы: @@ -67,10 +71,16 @@ Stage 9 добавляет schema-1.2 эпизодный multi-horizon прог неполном паспорте компонента. Полноценной промышленной ML-модели и управления реальными уставками пока нет. Доступен +<<<<<<< HEAD локальный desktop UI на Python: он запускает model-demo сценарии, показывает `hold`/`recommend`/`abstain`, проверки и журнал. Исторический forecast с доверенным локальным artifact доступен через CLI и отдельный экран UI, но не выдаёт промышленную рекомендацию изменения уставок. +======= +локальный desktop UI на Python: он запускает model-demo, показывает проверки и журнал, а +также открывает исследовательский сценарий P8/F19 по сохранённому historical artifact. +Этот экран показывает оценку эффекта и её ограничения, но не выдаёт рекомендацию уставки. +>>>>>>> 1527107 (Extend UI and ML analysis materials) Stage 4 показывает связанную цепочку и модельный блендинг. Газовые теги видны как технологический контекст, но не становятся action controls: нет подтверждённых единиц, @@ -164,7 +174,10 @@ python -m source.ui --scenario blend_missing Интерфейс сохраняет текущие возможности системы: модельный расчёт рецептуры, доступные ограничения, экспорт результата, журнал запусков, проверку конфигурации, подготовку данных -и сборку historical state. UI показывает рассчитанные верхние границы серы/T95, нижнюю +и сборку historical state. В блоке «Данные» кнопка «Прогноз серы» выполняет replay +доверенного локального forecast-artifact в выбранный момент истории; кнопка «Исследовать P8/F19» открывает +исторический сценарий с point/upper серы на 60/120/180 минут; он требует заранее созданный +локальный `action-shadow-*` artifact. UI показывает рассчитанные верхние границы серы/T95, нижнюю границу цетанового числа и долю присадки; модельный характер расчёта остаётся видимым. Для проверки без дисплея: @@ -196,7 +209,9 @@ python -m source.main build-state --dataset data/processed/ --scenar ## Stage 6: обучение, оценка и приёмка -Обучение запускается только из чистого Git worktree; test не участвует в выборе модели: +Production-freeze запускается только из чистого Git worktree; test не участвует в выборе +модели. Для хакатонного исследования можно явно создать воспроизводимо помеченный +`working-tree` shadow-artifact: ```bash python -m source.main train --dataset data/processed/ --with-uncertainty @@ -207,13 +222,46 @@ python -m source.main train --dataset data/processed/ --with-uncerta # Эпизодный прогноз 10/20/30/60 минут; создаёт только shadow-артефакт schema 1.2. python -m source.main train-v2-shadow --dataset data/processed/ +# То же на текущих незакоммиченных изменениях; только shadow, не production-freeze. +python -m source.main train-v2-shadow --dataset data/processed/ \ + --allow-dirty-shadow + +# Проверить один исторический timestamp по schema-1.2 shadow-артефакту. +python -m source.main replay-v2-shadow \ + --dataset data/processed/ \ + --model artifacts/models/sulfur-v2-shadow- \ + --at 2025-06-01T12:00:00+03:00 + # Оценить исторический модельный эффект P8/F19; рекомендации не включает. python -m source.main evaluate-action-shadow --dataset data/processed/ + +# Рассчитать один сценарий из сохранённого action-shadow artifact. +python -m source.main action-shadow-estimate --dataset data/processed/ \ + --model artifacts/models/action-shadow--v2 --control ht:P8 --delta 0.001 \ + --at 2025-06-01T12:00:00+03:00 + +# Проверить отдельную отложенную коррекцию ПАК→ЛИМС. +python -m source.main evaluate-lims-correction --dataset data/processed/ \ + --pak-model artifacts/models/ + +# Проверить temporal residualization для P8/F19. +python -m source.main evaluate-action-residualization \ + --dataset data/processed/ ``` +Обычное обучение требует чистого worktree, чтобы `git_commit` в metadata точно описывал +код. Флаг `--allow-dirty-shadow` не ослабляет этот production-контроль: в metadata +пишется метка `working-tree::`, а `production_status` остаётся +`shadow_only`. `replay-v2-shadow` доступен после обоих вариантов и остаётся read-only. + Контракт и ограничения ML v2 описаны в [STAGE9.md](STAGE9.md). Test 2026 служит только audit-набором; production требует нового shadow-периода. +В UI на вкладке «Инструменты данных» доступны отдельные «Прогноз серы» (legacy +replay) и «Эпизодный прогноз v2» (schema 1.2 shadow). Последний показывает +multi-horizon риск, upper-bound и причины неприменимости, но не является советом и +не подключён к изменению уставок. + Сравнить frozen point model с persistence baseline на одинаковых timestamp: ```bash diff --git a/SCHEME_ANALYSIS_2026_09_14.md b/SCHEME_ANALYSIS_2026_09_14.md new file mode 100644 index 0000000..a887071 --- /dev/null +++ b/SCHEME_ANALYSIS_2026_09_14.md @@ -0,0 +1,154 @@ +# Разбор новых схем АВТ от 14.09.2026 + +## Короткий вывод + +Три новых PDF уточняют размещение коротких тегов `avt:*` на технологической +схеме К-1, К-2 и К-10. Это полезное дополнение к листу `КИП`: таблица объясняет +название сигнала, а схема показывает аппарат, линию, направление потока и соседние +контуры. + +Схемы не относятся к гидроочистке 24-2000. Поэтому они не подтверждают смысл, +единицы или управляемость `ht:P8`, `ht:T11`, `ht:F19` и `ht:F26`, не доказывают +маршрут дизельной фракции от АВТ до Р-202 и не разрешают рекомендации уставок. + +## Проверенные источники + +Каждый новый файл - один сканированный лист A3 без текстового слоя. Все страницы +проверены визуально. Исходные байты сохранены без изменения в +`materials/schemes/`. + +| Лист | Файл | SHA-256 | Что изображено | +| --- | --- | --- | --- | +| К-1 | `АВТ_К-1_теги_организаторов_14.09.2026.pdf` | `3665a5d4ecce907be9f7b7fc5c1ce5a0edf7454da873e2396b43be448b918be5` | ЭЛОУ, теплообменники, колонна К-1, печь П-1/1, верхнее орошение и передача потока к печам П-1/2 и П-1/3 | +| К-2 | `АВТ_К-2_теги_организаторов_14.09.2026.pdf` | `abdaedfb3f61c951720ee46bbaf83073d61b810263fec90d09ceaca13fbac385` | Атмосферная колонна К-2, три циркуляционных орошения, боковые отпарные колонны К-6/К-7/К-9 и продуктовые потоки | +| К-10 | `АВТ_К-10_теги_организаторов_14.09.2026.pdf` | `169225416aa796d284f25a7cac4d5cef654a3c5b79f01e3f536a8f3c9b2290fd` | Вакуумная колонна К-10, печь П-3, вакуумсоздающая система, циркуляционные орошения и тяжёлые продукты | + +Ранее выданный `materials/АВТ_схемы.pdf` содержит те же три технологических +листа с обобщёнными англоязычными подписями признаков. Новые файлы добавляют +рукописные короткие имена из телеметрии. Они дополняют, а не заменяют старый PDF. + +## Что подтверждено + +### Лист К-1 + +Сырая нефть проходит теплообменники и ЭЛОУ, затем подаётся в К-1. Верх К-1 +связан с Е-1 и возвратом орошения; кубовый поток идёт через П-1/1 и далее к +П-1/2 и П-1/3 перед К-2. + +На листе читаются следующие привязки: + +- `avt:P2` - давление верха К-1; +- `avt:T1` - температура верха К-1; +- `avt:F3` - расход бензинового орошения К-1; +- `avt:T6` - температура нижней части К-1; +- `avt:P4` - давление нижней части К-1; +- `avt:F5` - расход пара в К-1; +- `avt:F7`, `avt:F8`, `avt:F9` - три расходных измерения обессоленной нефти + после ЭЛОУ по параллельным теплообменным ходам; +- `avt:D10` - показатель на линии обессоленной нефти; физический смысл плотности + берётся из листа `КИП`, потому что на схеме расшифровка единицы отсутствует. + +### Лист К-2 + +Поток после П-1/2 и П-1/3 поступает в К-2. Схема явно разделяет нафту, +бензиновую фракцию 120-180 °C, керосиновую 150-250 °C, дизельную ветвь +240-350 °C и мазут. Мазут далее подаётся в П-3 и К-10. + +Физически полезные группы тегов: + +| Группа | Теги, видимые на схеме | Смысл для анализа | +| --- | --- | --- | +| Верх К-2 | `avt:P21`, `avt:P22`, `avt:P23`, `avt:P67`, `avt:T20`, `avt:F19`, `avt:F16` | Давление/температура верха, острое орошение и поток нафты; несколько датчиков давления нельзя считать независимыми причинами без проверки дублирования | +| 1-е ЦО | `avt:F14`, `avt:T13`, `avt:T18` | Расход и температуры циркуляционного орошения верхней части колонны | +| 2-е ЦО | `avt:F12`, `avt:T17` | Среднее циркуляционное орошение | +| 3-е ЦО | `avt:F64`, `avt:T11`, `avt:T15` | Нижнее циркуляционное орошение | +| Загрузка и низ | `avt:F65`, `avt:T33`, `avt:F29` | Производительность К-2, температура низа и отпарной пар | +| Бензин/керосин | `avt:T24`, `avt:F26`, `avt:F25`, `avt:F27`, `avt:F34` | Боковые погоны и пар в К-6/К-7 | +| Дизельная ветвь | `avt:T66`, `avt:F28`, `avt:F32`, `avt:T71`, `avt:F30`, `avt:W70` | Отборы вокруг К-9 и два выходных диапазона, из которых формируется обозначенная на листе ветвь ДТ 240-350 °C | + +Схема подтверждает физическую близость последней группы к дизельной отсечке, но +не показывает, какая доля или резервуар затем поступает именно на 24-2000. + +### Лист К-10 + +Мазут от К-2 нагревается в П-3 и поступает в вакуумную К-10. Верх соединён с +Е-15 и барометрическим ящиком. На боковых отборах показаны фракции до 350 °C и +350-560 °C, внизу - гудрон. + +На схеме локализованы: + +- вакуум и давление: `avt:P50`, `avt:P51`, `avt:P52`, `avt:P44`; +- температурный профиль: `avt:T49`, `avt:T39`, `avt:T42`, `avt:T48`; +- печь и вход: `avt:T55`; +- верхнее/среднее/нижнее циркуляционное орошение: + `avt:F35`, `avt:F36`, `avt:F41`, `avt:F46`, `avt:F53`, `avt:F54`, + `avt:F62`, `avt:T37`, `avt:T38`, `avt:T40`, `avt:T47`, `avt:T58`, + `avt:T61`; +- продуктовые потоки: `avt:F56`, `avt:F57`, `avt:F59`, `avt:F60`, + `avt:F63`, `avt:F68`, `avt:F69`; +- уровень низа: `avt:L43`. + +Эта часть важна для общего режима АВТ и материального баланса тяжёлых продуктов, +но она находится после отбора атмосферного дизеля. Для краткосрочного прогноза +серы гидроочистки её нельзя автоматически считать причинным upstream-блоком. + +## Что означают обозначения и цвета + +`FI/FC`, `TI/TC`, `PI/PIC`, `LC` и `PDC` описывают тип измерения или локального +контура на P&ID-подобной схеме. Наличие `FC`, `TC` или `PIC` доказывает наличие +контрольного контура на АВТ, но не подтверждает доступность уставки нашему +приложению, инженерный диапазон, `max_step`, ramp-rate или безопасную комбинацию. + +Цветные обводки и легенда старого объединённого PDF отмечают выбранные переменные, +однако новые листы не дают полного машиночитаемого словаря цветов. Поэтому цвет +не переносится в `controllable` или в ограничения модели. + +## Что схемы дают ML + +1. **Топологические группы вместо 98 случайных колонок.** Для следующего + train-only эксперимента можно проверять компактные группы К-2, а не выполнять + массовый feature mining по всей телеметрии. +2. **Причинный порядок внутри АВТ.** Нагрузка/печи предшествуют состоянию К-2, + затем идут циркуляционные орошения и продуктовые отборы. Лаги следует задавать + по этой последовательности, а не выбирать произвольный сдвиг по максимуму + корреляции. +3. **Проверка согласованности датчиков.** `P21/P22/P23/P67`, парные расходы и + температуры одного контура можно использовать для residual/check features, + обнаружения пропусков и смены режима. Их не надо считать четырьмя независимыми + физическими воздействиями. +4. **Выделение режимов.** Нулевая/аномальная загрузка `F65`, разрыв баланса потоков, + пропавшие циркуляционные контуры или необычное сочетание `T20/T33` дают + основания для отдельного OOD/stop-start среза. +5. **Осмысленная ablation-гипотеза.** После PAK-only baseline можно отдельно + проверить: (a) состояние К-2, (b) тепловой баланс ЦО, (c) дизельную ветвь. + Группа принимается только по rolling-origin validation, если улучшает event-FNR + при FPR не выше 20%; audit-2026 для выбора не используется. + +## Что пока запрещено делать + +- смешивать `avt:F19` с одноимённым `ht:F19`; +- выводить смысл `ht:P8/T11/F19/F26` из этих листов; +- назначать единицы по буквам `F/T/P` или по диапазону значений; +- рассчитывать материальный баланс до подтверждения единиц и единой массовой базы; +- привязывать ЛИМС-точки 1/2/2.1/3 к продуктовым стрелкам без отдельной карты; +- задавать межустановочный лаг АВТ -> 24-2000 по максимуму корреляции; +- включать `supports_actions=true` из-за наличия регуляторов на схеме. + +## Следующий проверяемый эксперимент + +На данных 2023-2025, без повторного выбора по audit-2026: + +1. Сохранить PAK-only модель как контроль. +2. Добавлять по одной группе: + `k2_state = {avt:F65,avt:T20,avt:T33,avt:P21,avt:P22,avt:P23,avt:P67}`, + `k2_circulation = {avt:F14,avt:T13,avt:T18,avt:F12,avt:T17,avt:F64,avt:T11,avt:T15}`, + `diesel_cut = {avt:T66,avt:F28,avt:F32,avt:T71,avt:F30,avt:W70}`. +3. Для каждого сигнала использовать только значения, доступные на `as_of`, и + train-only лаги. Межустановочные лаги считать исследовательскими до получения + схемы маршрута/резервуара. +4. Сравнивать event-FNR/FPR, Brier, MAE, худший месяц, missingness и OOD rate. +5. Не продвигать группу, если она улучшает MAE, но ухудшает event-FNR, нестабильна + по месяцам или работает только на уже исследованном 2026 году. + +До такого backtest новые схемы улучшают семантику и план исследования, но не +улучшают численные метрики текущего frozen ML-артефакта сами по себе. diff --git a/STAGE10.md b/STAGE10.md index d470573..c55bdf6 100644 --- a/STAGE10.md +++ b/STAGE10.md @@ -9,13 +9,31 @@ python -m source.main evaluate-action-shadow \ Модуль выделяет изолированные заметные изменения только `ht:P8` и `ht:F19`, сопоставляет их с ближайшими спокойными состояниями того же календарного года и -строит Ridge/q95-прогноз серы на 60/120/180 минут. `ht:F26` используется только -как контекст состояния и никогда не становится изменяемым кандидатом. +строит Ridge/q95-прогноз серы на 60/120/180 минут. `ht:T11` (расход) и `ht:F26` +используются только как контекст состояния и никогда не становятся изменяемыми +кандидатами. UI-контракт возвращает обычное изменение серы, point/upper для каждого горизонта, -применимость и причины отказа. Заголовок фиксирован как «Модельный эффект по +применимость и причины отказа. Артефакт сохраняется как +`artifacts/models/action-shadow--v2` с checksum и загружается только из +локального доверенного каталога. Заголовок фиксирован как «Модельный эффект по историческим эпизодам», поле `advisory=false`, `supports_actions=false`. +Для одной точки истории используется отдельная команда: + +```bash +python -m source.main action-shadow-estimate \ + --dataset data/processed/aacc7c1ab3d9 \ + --model artifacts/models/action-shadow-aacc7c1ab3d9-v2 \ + --control ht:P8 --delta 0.001 \ + --at 2025-06-01T12:00:00+03:00 +``` + +Выдача раздельно показывает `within_observed_domain`, `model_validated` и +`safety_passes`. Поэтому точка может находиться в исторической области, но всё +равно иметь `ACTION_EFFECT_VALIDATION_FAILED` или нарушать верхнюю границу серы. +Экран UI «Исследовать P8/F19» вызывает этот же путь и не ранжирует действия. + На dataset `aacc7c1ab3d9` найдено 1 212 matched-пар: 546 для `P8`, 666 для `F19`. Validation-2025 не прошёл evidence gate: модель хуже hold на всех трёх горизонтах, хотя calibration coverage выше 95%. Audit-2026 также хуже hold; q95 @@ -25,3 +43,27 @@ coverage равна 95.70% / 93.71% / 93.71% для 60/120/180 минут. возвращает `ACTION_EFFECT_VALIDATION_FAILED` и не может выдавать совет. Следующий эксперимент — temporal residualization/cross-fitting и проверка устойчивости знака; audit-2026 нельзя использовать для выбора варианта. + +В отчёте также сохраняются отдельные gate-флаги: минимум 100 эпизодов на каждый +control, выигрыш MAE не менее 10% относительно hold, coverage upper не ниже 95%, +устойчивость знака в трёх временных fold’ах, coverage на audit не ниже 95%, +подтверждение инженерных границ, shadow replay и согласование технолога. Пока хотя +бы одно условие не подтверждено, `supports_actions=false` сохраняется даже при +улучшении статистических метрик. + +Temporal benchmark запускается отдельно: + +```bash +python -m source.main evaluate-action-residualization \ + --dataset data/processed/aacc7c1ab3d9 +``` + +Он сначала оценивает ожидаемое действие и ожидаемую серу по состоянию до изменения, +затем строит модель на временных остатках. На текущем dataset общий знак P8 +отрицательный, но в третьем temporal fold меняется на положительный; для F19 знак +стабилен, однако MAE хуже hold. Результат остаётся `research_only` и не меняет +capability action artifact. + +Схемы АВТ от 14.09 не меняют этот результат: они не содержат Р-202 и не +подтверждают `ht:P8`, `ht:F19` или `ht:F26`. Переобучение action-effect модели +по этим PDF не выполняется; `supports_actions=false` сохраняется. diff --git a/STAGE2.md b/STAGE2.md index 46a044c..ddaa5cd 100644 --- a/STAGE2.md +++ b/STAGE2.md @@ -1,8 +1,8 @@ # Stage 2: Real Data Preparation > Актуальный статус: pipeline `prepare`/`build-state` остаётся рабочей границей -> данных. После Stage 5 он может снабжать history-расчёт признаками, но public CLI -> всё ещё не обучает и не запускает historical ML-forecast. +> данных. После Stage 5 он снабжает history-replay признаками; CLI `train` и +> `replay`, а также UI «Прогноз серы» используют один и тот же подготовленный набор. Stage 2 делает первый практический мост от оригинальных материалов к backend-коду. diff --git a/STAGE9.md b/STAGE9.md index d233328..e76e55c 100644 --- a/STAGE9.md +++ b/STAGE9.md @@ -8,6 +8,21 @@ python -m source.main train-v2-shadow \ --output artifacts/models ``` +Если рабочее дерево содержит незакоммиченные изменения (типичный режим разработки +на хакатоне), fit можно запустить явно как исследовательский shadow: + +```bash +python -m source.main train-v2-shadow \ + --dataset data/processed/aacc7c1ab3d9 \ + --output artifacts/models \ + --allow-dirty-shadow +``` + +В этом случае `git_commit` получает метку `working-tree::`. Это +позволяет связать результат с конкретным состоянием файлов, но не превращает его в +production freeze: `production_status=shadow_only`, `supports_actions=false` и +promotion-gate остаются обязательными. + Команда обучает schema `1.2`. Артефакт всегда получает статус `shadow_only` и `supports_actions=false`. @@ -25,6 +40,45 @@ python -m source.main train-v2-shadow \ 30/60/180 минут, пересечения 8/9/10, длительность режима, missingness, возраст, а также `P8/T11/F19`. Остальные телеметрические сигналы не подключаются. +Схемы АВТ от 14.09 не меняют этот frozen feature list. Они задают следующий +train-only эксперимент после PAK-only baseline: отдельно проверить группы +`k2_state={avt:F65,avt:T20,avt:T33,avt:P21,avt:P22,avt:P23,avt:P67}`, +`k2_circulation={avt:F14,avt:T13,avt:T18,avt:F12,avt:T17,avt:F64,avt:T11,avt:T15}` +и `diesel_cut={avt:T66,avt:F28,avt:F32,avt:T71,avt:F30,avt:W70}`. Группы нельзя +объединять с одноимёнными `ht:*`; audit-2026 не используется для отбора. + +Для ablation запускается одна или несколько явно перечисленных групп, чтобы тяжёлый +rolling-origin расчёт не начинался из UI случайно: + +```bash +python -m source.main ablate-v2-features --dataset data/processed/aacc7c1ab3d9 \ + --group pak_only --group ht_context +``` + +Отчёт содержит HGB и LightGBM, их event FNR/FPR, row FPR, Brier, MAE и порог; +для exploratory ablation используется фиксированный малый бюджет +`HGB(max_iter=8, max_leaf_nodes=7)` и `LightGBM(n_estimators=20, num_leaves=7)`; +для ограничения времени fit берётся не более 20 000 train-строк на fold с сохранением +положительных event-строк. Такой отчёт предназначен только для скрининга и всегда +`promotion_eligible=false`. Только после выбора на 2024 выбранную группу +проверяют на 2025 и затем фиксируют отдельный shadow artifact. + +Актуальный скрининг на `aacc7c1ab3d9` (2024, только горизонт 60 минут) выполнен +командой `ablate-v2-features` 15.09.2026; audit-2026 в отборе не использовался: + +| Группа | Семейство | Event FNR | Event FPR | MAE, мг/кг | +| --- | --- | ---: | ---: | ---: | +| PAK-only | LightGBM | 41.17% | 19.99% | 1.008 | +| HT context (P8/T11/F19) | LightGBM | 41.82% | 19.95% | 1.006 | +| K-2 state | LightGBM | 41.37% | 19.99% | 1.007 | +| K-2 circulation | LightGBM | 41.67% | 19.95% | 1.009 | +| Diesel cut | LightGBM | 41.52% | 19.96% | 1.010 | + +Ни одна группа не даёт устойчивого выигрыша относительно PAK-only: HT-контекст и +AVT-группы немного меняют MAE, но не улучшают event FNR. Поэтому текущий shadow-fit +сохраняет HT только как именованный контекст, а AVT не подключает. Выбранную группу +нужно отдельно повторить на 2025 до любого изменения production-признаков. + ## Временная проверка - 2024: rolling-origin сравнение HGB и LightGBM; @@ -35,7 +89,9 @@ python -m source.main train-v2-shadow \ Порог обязан одновременно соблюдать row-level и event-opportunity FPR не выше 20%. В отчёте сохраняются event/row FNR/FPR, Brier, PR-AUC, MAE, q95 coverage, срезы `<8`, `8–10`, `>10`, месяцы, bootstrap CI и предупреждения по каждому -горизонту. +горизонту. Для upper-bound отдельно считаются пропуски правила `upper > 10` и +ложные тревоги этого правила; upper-gate требует coverage не ниже 95% и долю +пропусков лимита не выше 5%. ## LIMS и продвижение @@ -44,10 +100,41 @@ LIMS — отдельный отложенный слой. Признак дос `observation_id`, prepared data не меняется. Коррекция проходит собственный gate: MAE минимум на 5% лучше ПАК и coverage не ниже 95%. +Команда `evaluate-lims-correction --dataset --pak-model ` +выполняет эту проверку и печатает отдельный отчёт. Она не меняет оперативный ПАК +прогноз; UI «Контроль ПАК–ЛИМС» показывает тот же отчёт как исследовательский +слой. На `aacc7c1ab3d9` текущая Ridge-коррекция не прошла validation gate: +validation MAE `1.660` против `1.513 mg/kg` у ПАК и upper coverage `90.10%`. +Test-аудит также хуже (`2.071` против `1.781 mg/kg`, coverage `84.98%`). Поэтому её +статус `research_only`; test не участвует в решении о продвижении. + Audit 2026 не разрешает production. После фиксации нужен новый shadow-период: 100 независимых эпизодов или три полных месяца. Действия `P8/T11/F19` остаются запрещены до отдельной модели эффектов и инженерного gate. +Для проверки зафиксированного shadow-артефакта в одной исторической точке используется +отдельный read-only путь: + +```bash +python -m source.main replay-v2-shadow \ + --dataset data/processed/aacc7c1ab3d9 \ + --model artifacts/models/sulfur-v2-shadow- \ + --at 2025-06-01T12:00:00+03:00 +``` +Он возвращает вероятности пересечения лимита на 10/20/30/60 минут, point/upper на +60 минут, `applicable`, `reason_codes` и `production_status=shadow_only`; никаких +изменений уставок или рекомендаций этот путь не выполняет. + +## Текущий хакатонный fit + +Полный fit на закреплённом `aacc7c1ab3d9` выполнен 15.09.2026 в явном dirty-shadow +режиме и сохранён как `artifacts/models/sulfur-v2-shadow-`. После +исправления lead-time в selection-2024 выбран LightGBM: event FNR `3.28%`, event FPR +`19.99%`, row FPR `13.12%`. На audit-2026 event FNR `1.53%`, но event FPR `40.44%`, +upper coverage `94.21%` и upper-limit miss rate `6.52%`. Поэтому artifact полезен для +демонстрации ранних предупреждений, но не проходит safety/promotion gate; порог и +test-набор после этого fit не меняются. + ## Влияние Q&A 11.09 Новые ответы не меняют target и временную схему v2, но усиливают границы: @@ -66,30 +153,37 @@ Audit 2026 не разрешает production. После фиксации ну Ни один ответ Q&A не разрешает ослабить `FNR/FPR`, coverage или action gates. -## Результат backtest на `aacc7c1ab3d9` +## Влияние схем АВТ 14.09 + +Схемы частично закрывают Q1: теперь известны аппараты и внутренние линии для +многих `avt:*`. Они не закрывают Q2/Q5: на листах нет 24-2000, резервуаров между +установками, времени пребывания или карты ЛИМС-точек. Поэтому новые признаки +сначала проходят отдельную rolling-origin ablation; межустановочный lag остаётся +исследовательским и не получает физического статуса по корреляции. -Выбран HGB: на rolling-origin 2024 event FNR `3.03%` при event-opportunity FPR -`20.00%`. На периоде фиксации порога (июль–декабрь 2025) threshold `0.02768` -получил event FNR `0%`, event FPR `20.00%` и row FPR `13.10%`. +## Результат актуального backtest на `aacc7c1ab3d9` -Audit-2026 подтвердил сильное обнаружение, но не устойчивость ложных тревог: +После исправления определения окна раннего предупреждения (alarm за 10–60 минут +до первого превышения) выбран LightGBM. На 2024 selection event FNR `3.28%`, +event-opportunity FPR `19.99%`, row FPR `13.12%`; threshold `0.029275` зафиксирован +по июлю–декабрю 2025. Test 2026 используется только как audit: | Метрика | Результат | | --- | ---: | | Независимых эпизодов | 392 | -| Event FNR | 0.26% | -| Event-opportunity FPR | 38.84% | -| Row FNR | 4.22% | -| Row FPR | 25.83% | -| PR-AUC | 0.909 | -| Brier | 0.0496 | -| Point MAE | 1.092 mg/kg | -| q95 coverage | 93.80% | - -По горизонтам event FNR равен `1.53% / 0.77% / 0.26% / 0.26%` для -10/20/30/60 минут. Но event FPR растёт до `15.44% / 22.26% / 28.87% / 38.84%`. -Старый persistence point на том же audit-периоде имел MAE `0.684 mg/kg`, поэтому -delta-регрессор v2 численно хуже baseline. +| Event FNR | 1.53% | +| Event-opportunity FPR | 40.44% | +| Row FNR | 4.47% | +| Row FPR | 26.55% | +| PR-AUC | 0.900 | +| Brier | 0.0557 | +| Point MAE | 0.699 mg/kg | +| q95 coverage | 94.21% | + +По горизонтам event FNR равен `8.16% / 3.57% / 2.55% / 1.53%` для +10/20/30/60 минут, а event FPR — `15.15% / 22.26% / 29.07% / 40.44%`. +Point MAE v2 на audit равен `0.699 mg/kg`; это отдельная от alarm метрика и не +используется для ослабления safety-gate. Вывод: эпизодная постановка решила проблему пропуска начала событий, но не прошла production gate из-за drift ложных тревог и coverage ниже 95%. Артефакт остаётся diff --git a/global_tests/test_historical_action_effects.py b/global_tests/test_historical_action_effects.py index 5f8bbb9..5b2f7c1 100644 --- a/global_tests/test_historical_action_effects.py +++ b/global_tests/test_historical_action_effects.py @@ -2,6 +2,7 @@ from __future__ import annotations +from pathlib import Path from types import SimpleNamespace import numpy as np @@ -10,8 +11,12 @@ from source.ml.action_effects import ( ACTION_HORIZONS, + HistoricalActionDataset, HistoricalActionEffectModel, build_historical_action_dataset, + evaluate_temporal_residualization, + load_historical_action_model, + save_historical_action_model, ) from source.ml.uncertainty import ApplicabilityResult @@ -43,6 +48,7 @@ def _prepared_data() -> SimpleNamespace: "timestamp": timestamp, "ht:P8": p8, "ht:F19": f19, + "ht:T11": np.full(len(timestamp), 100.0), "ht:F26": np.full(len(timestamp), 350.0), } ) @@ -59,7 +65,7 @@ def _prepared_data() -> SimpleNamespace: return SimpleNamespace(telemetry=telemetry, quality=quality) -def test_historical_dataset_uses_only_p8_f19_actions_and_f26_as_context() -> None: +def test_historical_dataset_uses_only_p8_f19_actions_and_process_context() -> None: dataset = build_historical_action_dataset( _prepared_data(), # type: ignore[arg-type] train_end="2024-01-01", @@ -87,6 +93,7 @@ def test_historical_estimate_is_ui_ready_but_never_advisory() -> None: "sulfur_slope_60m": 0.0, "ht:P8": 0.15, "ht:F19": 200.0, + "ht:T11": 100.0, "ht:F26": 350.0, } @@ -96,9 +103,25 @@ def test_historical_estimate_is_ui_ready_but_never_advisory() -> None: assert estimate.effect_by_horizon[60] == pytest.approx(-0.1) assert payload["advisory"] is False assert payload["title"] == "Модельный эффект по историческим эпизодам" + assert payload["within_observed_domain"] is True + assert payload["model_validated"] is False + assert payload["safety_passes"] is True assert model.supports_actions is False +def test_historical_action_model_cannot_enable_controls() -> None: + with pytest.raises(ValueError, match="research-only"): + HistoricalActionEffectModel( + {horizon: _Estimator() for horizon in ACTION_HORIZONS}, + {horizon: _Estimator() for horizon in ACTION_HORIZONS}, + {horizon: 0.0 for horizon in ACTION_HORIZONS}, + _Applicable(), # type: ignore[arg-type] + {"ht:P8": (-0.02, 0.02), "ht:F19": (-20.0, 20.0)}, + {}, + True, + ) + + def test_unseen_delta_or_unsafe_upper_abstains() -> None: model = HistoricalActionEffectModel( {horizon: _Estimator() for horizon in ACTION_HORIZONS}, @@ -113,6 +136,7 @@ def test_unseen_delta_or_unsafe_upper_abstains() -> None: "sulfur_slope_60m": 0.0, "ht:P8": 0.15, "ht:F19": 200.0, + "ht:T11": 100.0, "ht:F26": 350.0, } @@ -121,3 +145,68 @@ def test_unseen_delta_or_unsafe_upper_abstains() -> None: assert estimate.applicable is False assert "ACTION_DELTA_OUT_OF_OBSERVED_RANGE" in estimate.reason_codes assert "SULFUR_UPPER_LIMIT_60M" in estimate.reason_codes + assert estimate.within_observed_domain is False + assert estimate.safety_passes is False + + +def test_shadow_action_artifact_round_trip_is_never_advisory(tmp_path: Path) -> None: + model = HistoricalActionEffectModel( + {horizon: _Estimator() for horizon in ACTION_HORIZONS}, + {horizon: _Estimator() for horizon in ACTION_HORIZONS}, + {horizon: 0.0 for horizon in ACTION_HORIZONS}, + _Applicable(), # type: ignore[arg-type] + {"ht:P8": (-0.02, 0.02), "ht:F19": (-20.0, 20.0)}, + {"controls": ["ht:P8", "ht:F19"], "evidence_gate_passed": False}, + ) + + directory = save_historical_action_model( + tmp_path / "action-shadow-fixture", model, training_dataset_id="fixture" + ) + loaded = load_historical_action_model(directory, trusted=True) + + assert loaded.supports_actions is False + assert loaded.report["evidence_gate_passed"] is False + with pytest.raises(ValueError, match="explicitly trusted"): + load_historical_action_model(directory) + with pytest.raises(ValueError, match="different prepared dataset"): + load_historical_action_model(directory, trusted=True, expected_dataset_id="other") + + +def test_temporal_residualization_returns_research_only_report() -> None: + timestamp = pd.date_range("2024-01-01", periods=120, freq="10min", tz="UTC") + index = np.arange(len(timestamp), dtype=float) + frame = pd.DataFrame( + { + "timestamp": timestamp, + "baseline_sulfur": 8.0 + index * 0.001, + "sulfur_slope_60m": np.full(len(index), 0.001), + "sulfur_60m": 8.1 + index * 0.001, + "sulfur_120m": 8.2 + index * 0.001, + "sulfur_180m": 8.3 + index * 0.001, + "ht:P8": 0.15 + np.sin(index / 10.0) * 0.01, + "ht:F19": 200.0 + np.cos(index / 10.0) * 2.0, + "ht:T11": np.full(len(index), 100.0), + "ht:F26": 350.0 + np.sin(index / 7.0), + "delta_ht:P8": np.where(index % 15 == 0, 0.01, 0.0), + "delta_ht:F19": np.where(index % 20 == 0, 5.0, 0.0), + } + ) + dataset = HistoricalActionDataset( + frame, + {}, + {"ht:P8": (-0.01, 0.01), "ht:F19": (-5.0, 5.0)}, + 1.0, + ) + + report = evaluate_temporal_residualization( + dataset, train_end="2024-01-01 08:00", validation_end="2024-01-01 20:00" + ) + + assert report["supports_actions"] is False + assert report["promotion_eligible"] is False + assert set(report["horizons"]) == {"60", "120", "180"} + assert len(report["horizons"]["60"]["effect_sign_by_fold"]) == 3 + assert set(report["horizons"]["60"]["effect_sign_stable"]) == { + "ht:P8", + "ht:F19", + } diff --git a/global_tests/test_ml_v2.py b/global_tests/test_ml_v2.py index d8c6bd0..09c51a9 100644 --- a/global_tests/test_ml_v2.py +++ b/global_tests/test_ml_v2.py @@ -12,6 +12,7 @@ from source.ml.features import SupervisedDataset from source.ml.v2 import ( EpisodeSafetyPredictor, + _upper_limit_metrics, build_episode_dataset, episode_sample_weights, event_metrics, @@ -52,6 +53,31 @@ def test_event_metrics_count_one_long_episode_once() -> None: assert metrics["event_false_negative_rate"] == pytest.approx(0.5) +def test_event_metrics_requires_alarm_before_first_crossing() -> None: + frame = pd.DataFrame( + { + "as_of": pd.to_datetime( + ["2025-01-01 00:00", "2025-01-01 00:50", "2025-01-01 01:00"], utc=True + ), + "target_available_at": pd.to_datetime(["2025-01-01 01:00"] * 3, utc=True), + "baseline": [9.0, 9.0, 11.0], + "crossing_60m": [True, True, True], + "event_id": [1.0, 1.0, 1.0], + "event_start_at": pd.to_datetime(["2025-01-01 01:00"] * 3, utc=True), + } + ) + + # Only the post-crossing deterministic alarm is present: it must not count + # as a 10–60 minute early warning. + late_only = event_metrics(frame, np.asarray([0.1, 0.1, 0.1]), 0.5) + assert late_only["detected_events"] == 0 + assert late_only["event_false_negative_rate"] == pytest.approx(1.0) + + early = event_metrics(frame, np.asarray([0.1, 0.9, 0.1]), 0.5) + assert early["detected_events"] == 1 + assert early["event_false_negative_rate"] == pytest.approx(0.0) + + def test_event_threshold_respects_false_alarm_budget() -> None: frame = _event_frame() probability = np.asarray([0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.1]) @@ -63,6 +89,16 @@ def test_event_threshold_respects_false_alarm_budget() -> None: assert policy.threshold > 0.4 +def test_upper_limit_metrics_measure_missed_limit_crossings() -> None: + metrics = _upper_limit_metrics( + np.asarray([9.0, 11.0, 12.0, 8.0]), np.asarray([10.5, 10.8, 9.9, 11.0]) + ) + + assert metrics["upper_limit_misses"] == 1 + assert metrics["upper_limit_miss_rate"] == pytest.approx(0.5) + assert metrics["upper_limit_false_alarm_rate"] == pytest.approx(1.0) + + def test_rolling_month_fold_purges_labels_published_after_boundary() -> None: frame = pd.DataFrame( { @@ -100,13 +136,17 @@ def predict(self, probability: np.ndarray) -> np.ndarray: {horizon: Risk() for horizon in (10, 20, 30, 60)}, {horizon: Calibrator() for horizon in (10, 20, 30, 60)}, # type: ignore[arg-type] SimpleNamespace(threshold=0.5), - SimpleNamespace(), + SimpleNamespace(assess=lambda _: SimpleNamespace(available=True, reason_code=None)), ) assert predictor.predict_alarm(pd.DataFrame({"baseline": [9.0, 11.0]})).tolist() == [ False, True, ] + rows = predictor.predict_v2(pd.DataFrame({"baseline": [9.0, 11.0]})) + assert rows[0]["event_alarm"] is False + assert rows[1]["event_alarm"] is True + assert rows[1]["reason_codes"] == ("CURRENT_SULFUR_LIMIT",) def test_episode_dataset_builds_future_labels_and_causal_features() -> None: @@ -159,3 +199,51 @@ def test_episode_dataset_builds_future_labels_and_causal_features() -> None: assert not result.frame.loc[5, "crossing_10m"] assert "pak_range_60m" in result.feature_names assert "pak_minutes_since_crossing_10" in result.feature_names + + +def test_crossing_labels_include_intermediate_ten_minute_steps() -> None: + times = pd.date_range("2024-01-01", periods=13, freq="10min", tz="UTC") + values = [9.0, 9.0, 9.0, 9.0, 11.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0] + quality = pd.DataFrame( + { + "observation_id": [f"pak-{index}" for index in range(len(times))], + "signal_id": ["ht:2:Mg.Sulfur"] * len(times), + "source": ["pak"] * len(times), + "validity": ["valid"] * len(times), + "unit": ["mg/kg"] * len(times), + "measured_at": times, + "available_at": times, + "value": values, + } + ) + base_frame = pd.DataFrame( + { + "observation_id": ["query"], + "as_of": [times[0]], + "target_at": [times[6]], + "target_available_at": [times[6]], + "target_source": ["pak"], + "target_signal": ["ht:2:Mg.Sulfur"], + "target_unit": ["mg/kg"], + "y": [values[6]], + "baseline": [values[0]], + "ht:2:Mg.Sulfur__pak_last": [values[0]], + } + ) + base = SupervisedDataset( + base_frame, + ("ht:2:Mg.Sulfur__pak_last",), + "ht:2:Mg.Sulfur", + "mg/kg", + SourceKind.PAK, + SourceKind.PAK, + "ht:2:Mg.Sulfur__pak_last", + {}, + {}, + ) + + result = build_episode_dataset(SimpleNamespace(quality=quality), base) # type: ignore[arg-type] + + assert result.frame.loc[0, "y_60m"] == pytest.approx(9.0) + assert result.frame.loc[0, "crossing_60m"] + assert result.frame.loc[0, "event_id"] == 1 diff --git a/global_tests/test_ui.py b/global_tests/test_ui.py index a472bfc..7f1e644 100644 --- a/global_tests/test_ui.py +++ b/global_tests/test_ui.py @@ -12,7 +12,16 @@ from source.config import load_runtime_config, load_scenario from source.contracts import DecisionContext, RecommendationStatus from source.orchestrator import run_cycle +<<<<<<< HEAD from source.ui import history_smoke_snapshot, journal_entries, recommendation_to_view +======= +from source.ui import ( + format_action_shadow_payload, + format_v2_forecast_payload, + journal_entries, + recommendation_to_view, +) +>>>>>>> 1527107 (Extend UI and ML analysis materials) def _view(scenario_id: str, run_dir: Path): @@ -75,6 +84,50 @@ def test_ui_keeps_missing_sulfur_unavailable(tmp_path: Path) -> None: assert view.action_title == "Требуется ручная проверка" +def test_action_shadow_view_separates_domain_validation_and_safety() -> None: + text = format_action_shadow_payload( + { + "control_id": "ht:P8", + "proposed_delta": 0.001, + "state": {"baseline_sulfur": 9.5}, + "predicted_sulfur": {"60": 9.4, "120": 9.3, "180": 9.2}, + "sulfur_change": {"60": -0.1, "120": -0.2, "180": -0.3}, + "sulfur_upper": {"60": 10.2, "120": 10.1, "180": 9.9}, + "within_observed_domain": True, + "model_validated": False, + "safety_passes": False, + "reason_codes": ("ACTION_EFFECT_VALIDATION_FAILED",), + } + ) + + assert "Изменение к hold" in text + assert "Историческая область: да" in text + assert "Validation модели: не пройдена" in text + assert "Верхняя граница серы: не проходит" in text + + +def test_v2_shadow_view_shows_horizons_and_never_advises() -> None: + text = format_v2_forecast_payload( + { + "as_of": "2025-06-01T12:00:00+00:00", + "production_status": "shadow_only", + "forecast": { + "horizon_probabilities": {"10": 0.1, "20": 0.2, "30": 0.3, "60": 0.4}, + "event_alarm": True, + "applicable": False, + "point_60m": 9.4, + "upper_60m": 10.4, + "reason_codes": ("OOD",), + }, + } + ) + + assert "Эпизодный прогноз серы" in text + assert "60 мин | 40.0%" in text + assert "не изменяет уставки" in text + assert "shadow_only" in text + + def test_journal_lists_only_complete_results_newest_first(tmp_path: Path) -> None: older = tmp_path / "older" / "result.json" newer = tmp_path / "newer" / "result.json" diff --git a/materials/README.md b/materials/README.md index f8d2fbb..05caf53 100644 --- a/materials/README.md +++ b/materials/README.md @@ -10,7 +10,10 @@ | Файл | Содержание | | --- | --- | | `ТЗ_нефтекод.docx` | Техническое задание: процесс АВТ → гидроочистка → блендинг, требования к мультиагентной системе, ограничения качества и правила демонстрации. В частности, для товарного дизельного топлива задан предел серы 10 мг/кг. | -| `АВТ_схемы.pdf` | Три технологические схемы АВТ с тегами, измеряемыми параметрами и отмеченными управляющими переменными. | +| `АВТ_схемы.pdf` | Три технологические схемы АВТ с тегами, измеряемыми параметрами и отмеченными управляющими переменными. | +| `schemes/АВТ_К-1_теги_организаторов_14.09.2026.pdf` | Отдельный лист К-1 с рукописными короткими именами телеметрических тегов. | +| `schemes/АВТ_К-2_теги_организаторов_14.09.2026.pdf` | Отдельный лист К-2 с циркуляционными орошениями, боковыми отпарными колоннами и тегами продуктовых потоков. | +| `schemes/АВТ_К-10_теги_организаторов_14.09.2026.pdf` | Отдельный лист вакуумной К-10 с тегами вакуума, орошений и тяжёлых продуктов. | | `Выгрузка ПАК 01.01.2023 - н.в_.xlsx` | Выгрузка поточных анализаторов: один лист, 189 651 строка и 5 столбцов. Содержит, в частности, значения серы и плотности с временными метками. | | `ЛИМСы 01.01.2023 - н.в_ (2).xlsx` | Лабораторные анализы: один лист, 1 522 строки и 108 столбцов. Охватывает дизельные продукты АВТ и гидроочистки, включая температуры фракционирования, плотность, серу, цетановое число и низкотемпературные свойства. | | `Теги_хакатон.xlsx` | Справочник для работы с данными: листы `ЛА` (лабораторные показатели), `ВАК` (виртуальные анализаторы и формулы), `ПАК` (поточные анализаторы) и `КИП` (описания технологических тегов). | diff --git a/materials/schemes/README.md b/materials/schemes/README.md new file mode 100644 index 0000000..af3ee10 --- /dev/null +++ b/materials/schemes/README.md @@ -0,0 +1,14 @@ +# Дополнительные схемы АВТ от организаторов + +Файлы получены 14.09.2026 как три отдельных листа. Они сохранены без изменения: + +- `АВТ_К-1_теги_организаторов_14.09.2026.pdf` - К-1, ЭЛОУ и печь П-1/1; +- `АВТ_К-2_теги_организаторов_14.09.2026.pdf` - К-2 и продуктовые отборы; +- `АВТ_К-10_теги_организаторов_14.09.2026.pdf` - вакуумная К-10. + +В отличие от объединённого `../АВТ_схемы.pdf`, на этих копиях от руки нанесены +короткие имена телеметрических тегов. Подробный разбор, хеши и ограничения +интерпретации находятся в [SCHEME_ANALYSIS_2026_09_14.md](../../SCHEME_ANALYSIS_2026_09_14.md). + +Это схемы АВТ. Они не являются схемой гидроочистки 24-2000 и не служат +доказательством для `ht:*`. diff --git "a/materials/schemes/\320\220\320\222\320\242_\320\232-10_\321\202\320\265\320\263\320\270_\320\276\321\200\320\263\320\260\320\275\320\270\320\267\320\260\321\202\320\276\321\200\320\276\320\262_14.09.2026.pdf" "b/materials/schemes/\320\220\320\222\320\242_\320\232-10_\321\202\320\265\320\263\320\270_\320\276\321\200\320\263\320\260\320\275\320\270\320\267\320\260\321\202\320\276\321\200\320\276\320\262_14.09.2026.pdf" new file mode 100644 index 0000000000000000000000000000000000000000..78aa89c66799649ea22fd47b513fb1a04bfce31f GIT binary patch literal 579088 zcmeEv2Ut_f*7gpeC{jd0ieONXj&uE4JQb_O0#k1@}Mp{<(XRX~Sg>*fv+`sHmbv$$aETxd<8Aotf z)lryI$k5Kl-IiTULRgYgNad{Ed0TgOajC=Lz%e^_H|?{o$DJK7Iy;?pa%Vq6DRkWV zg0rjc#WU96>Jw)#*;$`GrF@K1NXhPk`&n18>$u*Dv)0ZwpoEiWoz8>%ii0|xqLh=P zbaQt-d&ZG+^WEl0Xs4QzsuBbvfx!e|0uZ!W10f&?Mh5%x2mSO1BY~5WkyC8jPPt>V z7dj5H?4oC+-^s$rz_^c<1;KNSpNEr^M@L##_?V^sSsM#|GZTBy2Lbjjk!~ht*9(Fo z<5RM7vd;UJzb;Lye~^)t3Q8&136a3ya1v5D85t=lDAETM0+G^@?ba;q6mwSMLlvvLjCPPvDXiJ9df7dHFVhl z7#f{6wzfHY?!2v?y_>torOTdIyaKOXzY%ovR`8vuyU{WC?mvi4PI;V~mY$LMX_K4UgbP$Hr&p<`)*1mRDBSh|dmV-M5d2 zgGZB}Uo+Lo%qjH`{}Ck&gWz8U4E}!=@wXBN!l47gAt5D&lTr|c10%T%K5#lxvi&0D zyA*UN&baJ8AnL!3{#ayEZuNE!u~VNJtXx|u_i&1TIyg&|&lmChKIsJfDe?Rwoxcg^ z14K&xakC1d+qT=1Iu8yz0Flta;B>If9*7DK13%z&5E2?2O%8^5wmw@j*lGh?ZD6Yn z{8zUD`_hU_&n|dWH%iB5WX3%q*VH`Po&hBX^K5;#WU$o+{zKXThpg?d=;31*k1&hp z>&TPQZ+*68u+;|sd>aVw)q8aA+H0M%97fo$^|&qXXv;(V(gspq&_gipNc01ylSdvMKgzAomJGJqz`v*s zJQm36@R#4A{b3Wrn$ zI=d*6HX#;hj3+x}$NEIbvpX+C5UvywPq#puZ$K-=X3JLa@mGMpUu})qQ|AV~z1?l0 zJ_`yC8LQk)sK`0+>Pums!;@EBo$v36^cUGtX3F@XD8r^x z9h#bU?svD43RhvJ98VIk+FQDuPNvgB+Yl3dPyUP|y*B%A1y(ZB=L&t7NiUJ7W>1;7 zqPrOMILU_s+x)}|cD%9Um`l7i<2<aI5x~%Aq<0X>2r`-%bFmz;5 z1&AmG^4N77v}4~yyz!MMoi~<`Au3dur2Y#BCY=s=F*X)wPME_7oKZ(8gtlf(JD9&ptD)ft zIwHfHt=i9h(8`Z$q1LRRWh6T9Vyc3y0(pUBzL8+BYuO#?^Ylhkc#T zN~IO|Dv=42d}kx`G6#f41!t_qC9{f`<9QF0HSm>77S6LOvvV_N(N}$2%fI?IKcjKS z*}P&EEcb|MSErjCC+^g>eGieDiQ6g*W>o5hR!^ZEEW9d*O$rTM@WF@5GbE7ju*=Ey zG;Zt0>z$hrA9UmZg(7!|n9^v8!`ksrXjdl_mOBylMVnCk>(y@7kc;y-^@hVt3C$H4{UNUw z3iYKdw@pYeYChAyez5p;+u-{O*X_;r6M}5<0)=8z^1EBNz~0~4NdB@w2#mV1)D#2k zC#ujciKYNLbqs&Z0!e}MOiFH;u_CuiRqc7nn;%E_M@cOuUKZ1zapZK&67%!MFgofwC9(RYWo$Erjznc`CPA)dZ94I>SaU>n>ARn%H_t8#C zsv$%5Z%i+3B?Wp|(*&cKhF4eQwDx1V{5j7|s}@oJ*r7Xpa|O(?M=f@0a6aI0>X!>B zx}$iOl&-xyl~LX0(PDp?#FHzLn$7R7GaV=pcPK0^I9Nwn*oTwKV!W(lg1KkuR>hn> zK(l|(T#>wuj4!?U$Hpf-$^Jj2Xa4A({%7vzX3lw}BXJXQTZ}gOKJY zy<_)79<6nFs+MVIMd_JBmA4nv&|;@hFORtpvI%;ymR8f{4Nk#LNTzo#g|Leps0PPX zZHnWHXO2H*jHIvWyUp)(xZgj>3-b6q)za;ll6y~*He$*;Y(Gp7^T*%V*n}Rduj;cY zHK+>-TtWBFcLz^6(AM}k0*8>K)+#AU!OE@xQ(^=HC~E+$xp{DW(N~= z4S66qoL)x}`jH=outDdQ#UMf{O6`4ij_;n>9^n`CF*4rTq-%2R>1oioeMi+5Aed4Bfc?%qIG6o-1~ZGEie;SV1;*w1ytK)EQh2&|&KB7b^xJ8oYuJmO2ht`3L;Z~- zSNyTpIF8k>*%UykgS$j}j|L&GJG=`uiFmbYc&O1O(=5;N0xPRWt;c8?J>~C{>F<&I z);Zs3k#7dg{{Xo#NbDf33_GQKmeMWy3IC^2LxKr%h+k=L$hfvV6n6yev-tY`GLNN5 zyVOWXgszn!7thUCQ7ifxQ=KA#kM!BN^(0dxqsZiOvi4KMRRw+x+RrFhb-Zs3tt4lT zEf21!=u?s&8CuM``yE=-+~M9)uKSuw(-q3rO*gNp;naLt8hC(2!GqMh?F!KD&59di zZnYlFjmIDNY1XDIhtjy2k=;>dJ!<9HKrN?wd;k9K^qg~^E+d3pZ{$1~86ZFAyJn7vK$Kh-9B;}nz4z7AN^>nHu>?D7@w8+FPYLe`_t zMNlw7hI(C>CS9#`<@1Yli%%qmFW43^M>{8_$cN4`Scxi$1wUM;6k44eh}OE2sc1tI z{dy9XjhSUeULv76vYJswf?Hy>nD5wx2EO?Oe3cof7j%# zQlb>SHaXdk^{9E#{;koabb*>_<+qsjelV03b&+Sq_um-D)Sto@s$}FWNWJwtd0kiL zwxT=YLLqi}cAn6ZGF4dI+{1xce;IBg5)-59;5?KiVj9(`??c(ydvK`9tpj!4Y~Atc z#)dMmkSXQfgW(5B2j0rDa2vS5q*Wf%&0OpY;|#f82jYP>{3l%!CL7jZ(jRWd{|9KK z{~Dv`2g#Dj{NNkKZBympd=7#KV2<-KoC(GjJ5O*Hh0f^nsNyOa_C#hT6}{&tV~>h) z>M%Xa?AREdzOO%mr+TT;z3o85Ln9kQ$tQgn!KpG%|9}cjS~f+8*H3zD=^V##N2Wa-es??Y>W3Uie|haHPQcM6SC46TkBR; zJQ|J@UK1EEq4ZqNs$d>$Q`m$wXy+K7zjBS#TX-XsD_?Koa!#I00iA9LYC(rXx7cdcJ13&*!xC>w! zRoFAcx|HQ0@uIx{4tU7JX2vnKg{3z3bik8gf3VY}!S4Eg+^ zhWty%wpzJA*t6@%(b+YFPHWGH1lcp25IUjFPgF4f;lqp*k#~DEr6hP-o+4i$@Qa&J zSI&pM1cld&vQHKv*UER2sQ!rgly&l%^;V!EE%5f^8HqGTwvv{i`KUPenAUt9N2~2i znYM`}!8}`^|8f~n;msS=>r;$gZaj{PttV6!WA*~HskIv&+s{<~{J01Q`(RotU0cmg#h=e3VMKz@$sL*=fvr?i}5XAoLJKD6pucCoaT-zvD{}$l(Zxmml~fgV{q~; zdIru*ooJVGU-yJwgW~!#t8H-2;7q%WaQ^6t3`r16-e;n`B5M6{b~ds_V1`Dhh=%lEPq8EG&O_4&<@Q3V%iZ&gZpLR(=L zqUN}8ulpusg6DxtO&wa8UAp;>FZ_tqgo(6yVv);~)cj8Bn((w70aebUHriE72c8LK zLWJu&`1V+|&kM6i@eTk0HD}v~%6jX-|k)v~rWMnBEDT zPNjeJk+A9`Yb_k9Kg1uoeOo$KG16lC**Ce z8xdOYcz3=gAcu#vgAXxStN4aFw;KUqqj%NSaw~Ed^?L zKKU(8*@56;`hg*j`ek%XM@htTAbHpcjo%~Ut#f|68U6>$h@AFjbuYQsmFO(1(WR;+ zre^!6*{AVndWbcN>lsIJSuXibXr?&_o@=f5z(nU};O#s65{ySbMw*jZDv+1l7cZG^ zJKc9C;%%b!9lK=eW28Gtdnk@H?(rd2Pj!3ZQq}K?PR`BOkp1+5vq68^=3Jrex2aDg z(M=D@-qxvdP+=-G45O6u^*C$FU9U)-n?F^QUw?TuNXJO@L%)5_bMd`e2%%IVdA|Ee zW=bdcWS)6e50;X)PF+M^N}rX`vt{o&G;-#U6sxs_zJ*S^q3^3ae1%X9jd zTHW5~H6v?(e?sv#TVq&#iN6uaHl0VEv2Rn@;a7}em zZ1#s+r}t8}!vm|>yLD71>2^rMlr$i{XU|mx1EjNrxgY5fPI9d5p363wa?g6+%li57 zor3#4VMC2~Hlb9OXVL?FwA0+f>O(cC;aGxx{oasga?1r8d-AUAg-jjm>uXs;$FJV3 z#DQbnYANaVBIfAM59L&kLiNYLH(`D-g#Dd)v~}+v8Xoxlvep0eq9`sH!=Jm{QNX-2(S9mSXC$uLP7?P9*6Z^M9x3day!Wg^@dxD_6*) zO|)>Dwt^oi*%P=6x-gHSx33ycE)7=d-ODS7k&v~r?YU4mZEnL;U5v|dY$tM8e(v3{ zFz-}VkE=5;F1cKnxU|E>gq2L+kOS@2*-+Ef@$jA0kzTxQhxbFv08wc@L*aupTvCh| zALla_+;m%3HjedYHg>#SSXp9oMAwhZzV*k3SAM_zeoVQdPF51HEKr!qftduH+1r8f zu#4RTv+I)UxYZU?qHZpJV(t)wqkiu3o;h>;22T19^N?%(9~utyV^FOj34_J?e;A zlZ~o+*c$40bwFf7>6Pek)N9A3c2e_d4coEU*H=hI16<{)WAnw23ZSx_*C*Stty3J) zoQh}9N-^1s(#|0|$?tycpPyT1cc^Q;O+^IdSsmaWZg7<#awP&_=dm z>o=iu4^nwd=y$Ypp>>OQ`h@o~%>h)lfUNF;v%tyRy2xC%W1Zu?rZjT;57o*oL;GAJ zt0cN=22W7Y+Skmo@LW4AXZ7*?=N#&TA;$9)cpj3){|i^^2bF=?Y~3Po{s|u3wt9}P z67?L>kw)dsJBnb_$5vINyKTdQDXWtOxqhM3IlJ>@#?N!z@3V2{JPUus- z<0W^$;bNI3CI)+qZcM*3-)vr#zkhoHOOE`2{dh{vm#TW1{3C zUI)#x2@#OznwOjB6Sq+qPc2lg%!S~qH?nTBITW>RLf8hRS>USnfnm_)zUY@fVc>5= zrZw-WrBZ?CD(FaaCNRCqPzE6Ad zqe0x+(eumH3&tIZXNaoXh_L_qP{0Ad+u5p^wnE-JbGxNWPEZH9j`mVEPuywWyVhsSwA^+C>FwQc#Xrdk(|^O}=iX>^w` zc;*BKY4+b0Rs3hs#;yJTqNI%l z86~knhlpilVpeQiu$FzT-V7XWo4M`u30>WUq^H5Rp2Lj_FnYbN96&btvHzqVn~=+l ztA;!Ii`!`~q-;X&C_d5;2m4DDNG4reGYvg;|f@f{lMI=V$5=*E}Q*GHfr#^OZQA{2Jg*@1?0-ItQTc_b|aPV z$9QSV_Q%3MTj+IO9j@|sd_Jw}-PI-h&X%3ydov$j z8RjnjcPSwOE?s_5Aj%74l=U$97OpG^pncOLk~-7d(r6jRyd z4c|t9O}fJ%;27WAg!TbIvbeIL*zXFuTRl<^gLo@9Iy0sETCkciBW?9oLriQQr zh+~-INsNxh{OcjB&CCrk6^iCCFfsgyo}*S3!h4Veun#?Fm^l_c+px{C6fTv@I zf@FP`)(*l=irT3LJyqewd*Y2g8dHD8?(SoYCjHk9s!kdl97u35;3%RT$ zovNy)g0W@Y$3MNi42u`_+~9c@9u9Qi7hmRo(THLH#%oD0Q|v6bSX4Q?N`wcyJuZsT zF}yNIVfu*Jq5MwW7-gO60}mBNc3o*hdA8zSCzPY4!|&1iMK#}^Ehf0fdv8LX?IKD% zFv8V@eq@|b0{3zRPUmB5u%#;QH6FVPE&=~LJjJd2IOm55vm2xHLmV;4I3*?N7BY6u z`Gob3E6wBfGAtq|x66zn%k5-vgxNI9)^J`3ScO_MEEaO$Uj)5nJBgOmCiFseq{6HH zqMfiUiGmm|Go=}296=t+BCm>*^J-VW<7$@{B@RnZv)|R*$CuPlLaB^33%h7w`r#E} zh2YWO^2LD4m_BG^C4fO(S2Ze~jWIgm!oil))bCacS3c@-w3*9vD4a|oz}p}W9VJ-( zid@s{d2B~f%Sr2zXcLb0{7Frmgdfxd3d;A$a(@0n;3{2_43&W+L~l?LX1$cG%6`!+ zwDWzrj0)$;6ark{sG;qtFXo~}r%PMe3CH^EqoLi*at~xg8T2k!kJHba?YSjz$3!?% zZR!Z~L|b$LbLu~73nb*Ex3_VgYF!~xxvd}leecj`JFcTHSOUw77i){MG*2#RUQsnc zj+2W`^!N`N%s?~!Ar)2b?uI~w+xfBhV+)Hj3bXv@Z&FwoA6kdCwwmHLICDVC1LHhM zc{qnzyWS-0-Fo>QO?dD~^R0wOCgJZ!B34urkmD2<<{f5Ba3TGlkkThK1qa{O4UVX) zRA1s|*k=dKR+04^6F~F29yV_8NwPR-a%3ppzI%hS8M7u$a7V9Gjq#vWCY}$)B|P<_ zDU)1A8cV=)l1q?N@3lT(UVKdW@NhkFK1Ex)hyQMbMf6@dBW|`v=dtcLK6D(gyVO;z zAW_*Bkgorooo1`--$7#eCk^?B$<8$WCT3<(_6V}6v~*ZTF+o}&9<$m)E<9#Naf7h3 zmN0Be;7|NKFo4kA$RKsU`xlQ1DiiHfiJ1-Gud(yy8l_@~erK+)&NJj^sFOar60gJod?Rt?By<+((ap!FSE)%e#qe2O|gC>;}drOKYqdBEo1Cyuguhx%GXOR}HXCX_3H{CXbZT?{K>Xr9oTL&*b2 zuJ0z6h*+6TS>N@F;0b`xx_q8Qs!eFp3>1jM)ZbxOAaOwi8Cyavu}Wae;aD&qGX0)i04nEHFjA_AOw8dsR2&tov_vc>L!l_kRwwgF6 zouqif%stMVnba5a>V*7aeY&Mcrh~gJEv5nZAd`%Qv5i`On7^Vk%CZt%q`U+NxC_DE zh>DN@@;VGqG*BX^?-hpG?KhwZ42H7I5Z8UDFdGzW-BQHOVaz8Lx)uz1gaO&R5t$cLbaq$#8S{a zdhI(vUJ6AIC;_O&=C#;YInDIvr&_@qK~5Om-8Q-*IS>`Pud~Gv`6+iGKInz}AMd;T z(X$k5*hhG5SLu^siJ8U5IyvhBaacs~=vDsZX}N%sapDGAE^w`d(_QbM;kUzA<&jqh zW|q)TN}PA1(9X*l@Li=y%7x+$WR6=wtEXWqMsuLcpCbv#hUpvf$DaV@wt&*Vy8H}z z2azi)A;4Lm$UYCL?%O&2N?*mg_;K;?y7;gkMwwDkU4A8EBaRu|e z6$tp0)K`FQ0^r>WR2+2d$bqsjF@nt3ILRlB@wE6`e7MXXjGBmCRVAeD5_uFm{E%6q zW99ZBlW$sybw>+TY%@U)$oMKsdd49j4@7E;Q3F8Z^n4ewA1>Dc6&l-wOfmBkAtvVB z;JgKpYh%3%3?^mOPnEBd&;ccz5H;Q#Xq?}r^}dToqjk+UA>$8nvtwpvuCbfY8n(&T zx)&HqX0+FR7crWFYu&PDjMpm`G3EjOY}EfOs!r4|IZl<{FFYjPIIPS+ENYf#Drj9X zobK~Qbd@xWZt%&O1TG{hEBsE){9PUYf#%*i{wK+0zfYDILUp#oxo%9tGty4HeG|i! z-txoz%)j?zO%s4AgB?UJYt5sdmmulRPtoDl(}|uZI{lT4zdfP?+f(8Jmv}cy$*9GI zsIdh}IDgrsu=>U1B`7(HqeCZhoY(EpM^P+Zr}1JDYIafROVq@@O1>|g{{~3Mh0a?SR8t@Ur}x=`MfwN(lEYqgoEkB!GUgSPwX;jV z_*;QoqB?ql$V8tmjU6XyrrpyHF-~cIHcJ+>tF%NH?useH1H^?O{OrcWi|>-}XiREW z*S&z1BeKzG)irR-kI)iv8E#29m#a#VSW6;}q+Y;YKE9B%hTD)_ahII{&fDL=?BDur zn)ZTFzPOIb@$hH>dD0W%BFhnK5C$Fba$s(D1!X)kk^K3oiT@-rheTQNpQ#eGi3PI4 zc^SmuENT!FCirov2Hc?16-$8Q9Ug(r7l$a?L0(ScU(o7-ZMJPO0DrI_2>dGUwb9BC zG6adv?t?@lD1sQVfU|i8Pv+CEQY;cUOzjuZ%?bdcy*RNP2HE{ZO+6UvqydGcs66_V zMSfrZ>tPD(j_b_~on|OZ_5;FLwj(X!7yy{c@V0Q|JRUc_g~BpAhY~?uvsS7HpT*S}{1B>T&SxwbzU>s!hE9N~J=S zI7x&8-XYHPe3ZYjm&9b@4GTW_;Yk}fsOGceXO%^V{6=aP7R6CcW|yAhHCUFI%M{NL zf8@@Ldue;TNff={S?87TIrXWfq-}PNkp7*e;bhMf-nz1GQO5Z#Ka#Jpb+7-dnTdY& zeYpA_K#YMtTZ)SFh;Ep`FzVb9Jq&!2oezRx_2@SWOzlBTs}Wv9Jtxli6_1pD*nxPD z^#|sF(+3!fxQ^of>pzH853vE9>^q<9UkMrpPY`4uk1cB%*R`y4od2SX8e41g<9x@) zjMf0|f9u*NG!3KxefT@=1Ao+o#I9fwo`y=&lsJiq6~fRav=bfb{+q}PENoC~WjP3s z-N=e|BH~=AjG**>WJ8I;g#4}^6sE&sCIV-+c=2$;?BW*?-{(870BdD161$4*18_Z? z<4OVn83pLp6ZF~~NuS@X<`Lm~{bo;R=BY=B9Cz_Q1X4GFC+a!5@XkIhBK_6jIM#?V z7w*;07nbMVZ$f?IrHm{mkrDIa(9C2RYN=BCO=rG!w8PnRoN=#Y`ID35sy@ZM)MYny z^_jS=(HcO~9w3^+B=L;>=HX8)=erpWGLl3r=~u2>#39~a+JvTjJ-mVsRi)zZITN~Z zI?buQH;p#lV{&Xe+`tInoXTix`Hk=F=7O-!Cqi}@iV>heiS{bslPsadX%sIR({2O?S(JOVkVpR@#rdn+ z{a;W99nny&oQ|z)0cjB&$n}J-LMH$nbeUqO;|oHPK!;UxUPk6X&TV33%+~(uM&<{j zyN>9qY8wRxcKIDF=}y$SsmlF9L)X#kBfeIvMn8^2O)*rRS5@pC+Ck6QgyuFd2(JbU z+&Gg~$(PXo#Fr%h!lJi#%sZx_kJiq{`CXS9Hz5Iz=Jn)~55{jMbvZEH{RjQvOo=p) zSwma(#WRf^rMaCRBX9z6mS0bOkUft)Z$kys%h)8rn?(UcdA?FvajbEy7s3ZNl94z)=9!S|kT6S*CCf`t1o$=gK_G{Hh7I#&S#I z;eod-9uO-$u#w+CMNx<4MaW^&j|oXxHz(K|9tB%UKM-0=YfhDW2)@>|$@fKVLi;Ds zsK5<=uV?vBS82^qB?N}D@5aOb>t?@I-oMeP1x6|+VUH~BS8Ot2;U*ynK$Ft=@h`~0 zrS-bv&T;;{mOT6;v4nD4xY9r9)BmWS|EKNShDHHP?EFzJwc1wX&_PL}Zbg?A-ov*K zFoY!oO7I1urP@NiXuT4=n^QQNu!pOVPYQc_=kz2sbZ+I6?!k}%xe4q-j{S%@b9q)P ztcpf;Vt}E&1!g*3$(`Wg%(Ak`6c7iYiA% zyM}eNkj4zm&LXtR0UWSc)idjcuqc=Iaj5uC71LPMB4^hb!Bc-#nXAOjXBl*@p3ZyA z+$CBKWj|%(Dt)8>7%l)u8aBUAfQQ|9n24(96A8zywoM>rr}1)MRi+-w02qrYrqj8Z z8o2wGhi)8ZkCt^vMr&^5v*bTNe8o38#B_)7XjD^$O%ZfHmqNnURs?&G6aX|Pl$Uup zic^z6TB;{WleG3ZPC~{{(=AL*%x`j?^Le{_M3z4tZB*Yrq3R!(hce!m=pn(n35_Gh zPU!(_Hv^RlS&f-G6iE?qD+Y^a>`-&8Ab{ldy~2IEG6xi{W-E>ReYv33b`S8|x6-{m zU+gC%*SF-wxuw&GjC&3==$GD@sCAEiGwz?*x$lC1$#uv$P(dK+aRPTSzagVip+1zs z-OZ4k?iz5kt%Y1*jZ)<8DbSw$M)Dk;odnS3>6bnFDYyq1x(pBUdgq+v<(#_5inr~f zdBz$VtcvQCY-#hOw{(Ae4{riSirpufx~EZnn-JxPY(X)?@nz(e1^oZB75)kKo5AgPFDeN(ukpj{S6fz%-1L8=EkS z<=(gkSTJsD!M&j4MqH7`x3}erYprf?B;Xe5VU-HZq=263#>gk&ao>oF$_6`&j^U!+ zB|KWiQawvg@pisyH1Ygwj+v;7;9ZU^QszW*`?=M_2UkCa2QkEb8k*-Seoz+Kj8h*x z8d}%2I!$fXDp+$^1)CkbJ&~FPv)iI#PG)q+(p#N zGHRKc%YPGM&miP%jCp_^PL&tiY-Q|p@LKcW;j3mj6;5&Myb)<;sMK5JKU;ELzs#lu zE^b%8F8iUndgN&rZP39U&X7kj+zUD5&{LFT#z5-a^@QE(NvL`*1ZFi>!+{`^fNxh%a(n!$PI?m(lV@SLHKO26PsFH{_1NI_ zptBO`x}WLDgW)3}G%^`VB&jHMI!c7*?A zIS;JT_Wf)*1Los*9?D;hg|=1SKllC^Ua#Dw%~uW||AOzX_+Rd;5hd4K1XiS=`@X&F z*l|>}+hWU#^9(JKtT;OQ7v&Nuy8usg&SO12)e88cyYJzf$NBAXKZCrjBKTCIHSH&v z0YK)Y9OV(a=YFDn))8LKpBHdfTPu7O*(NTX=)bPzH5B9K;O(yWQG~y}-%r4vz2AXL zVpI>sH#&u-Gr=%1^&}b|O}dewJ8wct#v#%E1$d&6%As9&)t!O9Hg)nHc9KR=NH2wL9lZ%1V1xo`p`q7b0vZxn>;~`sOeP9CNdu^S_4EQPY_YVsC^Xt z0y6>RVP&T>J(T|q``>LJFHl9rs6IkLkV!mmbMJwL_jm_Ne>TP&&CL-{?MJiXJ`eP> zd3P)Rq4_wd@D|APt`whHG$;rPIxgW_^O=j=PIf(qg-(h$?mHZ!5oW?XG`pC%BR^F1 zvG=J~dDe<}x!2iBcc|d8we10inb9LHBYj#3B5*30J?5~BWC4_ zk&^&!?b-uUS@-vbBLlm~O!9vLhZAxT3*3Mk)(RF$`A#Kk67j0twAhVC$l|ff-8197 zwuv;F^oPA_pwD7a32Nh=f$V2mLc1lOrf?-5)TvTvD|9?Z`}i>!+ha~Q$!5U!nxDI; zV|3Ssp6%#j&bs*A)c)h4Phgm~l3p%JuW|v*#cEbOe>xnZRfCK#RE|F!(@c9q)Xjb( z;(cts*!KV~cG#f-P#Sz43YV~A@cnc&Tl($K>9@b4+!z`>Z(4Z*4C7}CIc5RO4Ixr@ z7y zM$IcmBKPz@*4u~U87r{H3&idYYjQx%F8QXc8lI?h_u-H40x0w(h#K)PE4Kmo@z>dD zR1tJkJ<4BfUi}-}8$Emv0QIS|34tj5Ysr3bID%qr2%yE6Czh3&&?Ge6TFckHpiw|F z_#5F5{$Lhdpavp^xp)WiqHJ_3R$#xUbu=)_g!r5mikm#*;{EpI{Js0-9Vdq z1z%8qE}_$t@U>r@b9fe%d*|dFgu$KeD)lo#9?Y%r_A;27l=I2LOiaaM)0A#3spQpP z&?X-U5^RoMia2FZWFLQTd(m?}4hqiB28b7+s+p)%xd)PK_W;OxC|2ZYbMr>tS07%I zaJ=2)lNA0NxR=KPF$;p5)3F*m2nxakPDL+-UgP%*BDn-B+C)h1*Ou#jgUkh03e^>Gsd92LqB-{D37Y4F`v zeSW>q{jcOxqD6;m3IQM((3wwfiQyE#s7z&v%1@OQa_OrE5OHfU5SX(h(#BxLH)6~D z)^GnSY|}Lpl1~!DXtw~@z1~1;nRX%P^$Ku=KS%_9*`tZ_0~!Y!09pvf9FWPKh?X9s zu1j&vs;5#x-!r^jo38dM172~oODkw_-syZ~-EnT08F{LfgCgeH6p%1?y4P6N{c=EW z@XbfxuQJ7pnX#3v#>^H=^(D5D-X)?Hr5|fsm z->SdA--dJ7AZJGsW^gsH<*mrP4T=!*Wg^w7mYHLj9R5 zXvloEYWz+-!H(e3InX$Cx;zlhH0}@)7i*G!mnK~t)~lqT5bUATIwJOtf}7ig!OM@* zW?oqN=EFB3woJ&`1K6LP7zJMMt51G@4cXdJ;e&O{~%5p`uY&DG;V zcmOW{g%l<2X8RVE_6IYcK-wBRe!=`c0ArsFUsXZ*Ft%34K~|U1fv?WvK<0Q>0-FO! zB44XsL^k-d5tqJt`5Wfa|4McLi|$RXk#KPK(UdIz4&|!O1pF&tyPy_n31c(CFIzDi z8UtVt4?GcQEjYrW`2CLK_{C}2ai2YXif8Hl=0?R%dljvjXOZw`? zYT0B}SDbd0w06-Mr_~uQ@EExH?c#`4GDWaMp2$Xo&Q0i&=EsO?n$eJHw_ng-!14Tl$a|XdWBQPev;zrChe^Iqkvs4uUNWhf%=JkI2s<*cP*6jb2 zWs!7ed|T5#d1|gn(hhx8VK10TLPxpR5llt!q}81)n6I)orxI)~^`chC^G$cq|8$N% zSc!08zSFjN4-Z(u%@Ocm;Q6v2tV4SnfUX zfZ=@d==RZHE>vH71MXfK$4KJEM_ULYInDYTqw8nE`4&wf{;~`dU+Y6~_jx`*@Op|< zY}wta{nNH`&lIt)1b5TzqZvQnJ!L8eOm)$Tl0HPbpoehyQoGeQJeu}vDfWD{1=U}w z@%@PxzMk0nx9SXo+@uO2Qv}Wo(JT0+9FI2>ZI7e4x=0}}AKQ~uNhbY!!RlgQOd%=U zK#D30_kVQt!t*{;)>l+mD*N(OLO<6KP1^BGWxgo`RE8*Z;v<{=?U8$c%NL*ZU8U(f zU`OT+=Q9^PP8T5hFLX){Jg!^K)EUUFX>Wf`p z*-^Qk_AEbQbw}lT(3&=df(nKtCa)kXBCnUjs}KqDt=ja{I}Mbi-eK0db&cZpMhL&z z56o#qv||h0>^ORFs+&l?pvP;GG6&U&uF_zf#Jg*ZB5I5?a<=f4aSM?F%e>3kr~3*hTJus^wU2`DL_1p(=F;zzFqauqx8+20+3e3LbvRx}zlt&O*5? zdBVLOX_Hgu-;AA_Wc^yVCPYbLU;TOEXwT`gAk3J_0<3Z3Nvp-`6XC<*(Iu9I zv!T>|i4y7-*CYb#$xYaO^P6d#Y;IvWeJ5ez3$Wai4?&ELci}xCL$vt`FU&{mR1^7m z*rKcg!xjw^OcnhN5txk40z?BtH*z*IV<|QQtk#O=CAf0FQ0693d=c_Q#@<)@i3)~N z)1_8WuLHMdsdr9#&`)ap!iVu^xvPFoZewQJFeRvp;rgdhhAvf2UqkF1a5r$}3VQ_` zn#vtdy61#RYaOoJ)1+A)8)O{e`;eDS9?_KL>FYBqb0V~k!XA3}WdxIsBwzt`gXR@M zi5E3N1fb6t;Rw5a+TIEde$zz%YM}D|QHQ&wyR|N}9y`TcjLu$=c!OBoUQJkpRPj+D zForxnm$nJn^r4zt#zq~^S7KFT$!*xUums7doIyV5;;GIZ}>6xxTsW-a6mJBh#mvM{)wZ+bE%WLPN=@ld^>x@;MrX8}eAl39pNo zO{79iW<6a6Me~5;n2oCu^<7mdtYBf#CH-da$nG{!g`|ju$W?>aGZDrL-Cr+ASY1U# zBi<9Ijf((>Fh`OB_I}SJ-ih&PDqNL#o6HUB3qC$}B4hH*;5?bZVV9K_@vLMNcGzpp zplIb3h@Kv41NkAiPj)%UZ#N+?59D>nHbj{`6YAZm^LYEkg>BFboNxfNmY7Zgf zAnQ%&><6My`P*CCzC7d0!NQ|0UcU51VR!&x@UOBcq<+{}M2Uo((7(`f@{$=y*!YCv)0!-McBI4<+h0hVv6#~DCyaJHG-V~qTn%UA zw>Vzma98BW(MTm#jQFbct~#AkI-^hvtrzF@SD6dv$6_@EtH8Q_$N3tW+@*A&s$moSUcdpJB-=Cuhej+?MC^jj?lzX^OqB5e0Qv@+3& z@W$-@I)-^6htK{%9atHH(t|__?;R4jVlZhju8VIqGG5-e>e5Y8WCh}w31H=~UzUIs zRo!#2uP)1cfOChR5*EXedN#G7cKsIX)u0NOw){}>GO@dh`IiMuCs>~=@1=5i)M+~{ zbZGAmF|@;}UX2?hOnU?exX>SII!$LSD7oJkWk0xCDCx?|JRASQt}*R{lIS6dkJJ}h zb*DHDp9fi!)lQY-nlHe5ZhD-y8q{uH=~`5jE!8ZuVE5Kty0xKV-ZZdde}1KUe6Zmo zSNjiEAMCW4KRCzi8;NN?W&7yWMX1+LnCGIFQNTmZ_QSSmDcK<}*3RYZj zWmh#}jNsAStuXj>@iwZ|A%T7y2!gFuH@gwt)sc-OC@+ymHVbQps(&1)c($R+Ge-xj zrtTdxiGE{WJ=}Tr<|97^wlZztUt$%M#!ok{R04ZP&kI-q*1!tL^iX&x2@EhWl}pg8O=ylX0zU#+sHsL32Oi>? zPHd~U^Z_gSy0J7_7VDFYa zBl%w9=zYwwcg$ZWN=;*}CjT}tM=#r?*|5Yv)vkxbGkAKB)*r-Fa^Xm30+U2Y#vi#8(;!e>nSF%98ATP&QD~Fem zf0v3@>t$3)sw|RHb4Z(H7Nd6b(wK58_lh=23k|JY{_d{uO(;wl))uVO;=?oP!wWrN z-mq;Jzr10a2%j5Mn2}$*lro!0Hlrf#OP{jVb}12FWNcm$XD1RKf434V56QG3Q=z4l z4IW5#G&y|GZ}$eTZ*fXO+wF_g5HGSRDqYPS6u{-B4ZBUT@9y~=I3iVuNgTSoVR!UY zgtPHcZIVm1JR8!j1bUaX77|M2%Lp%Nf?nPJkFnATbpc+%N85JhUz$=~xsuH^4ehi8PnGjl4bRWGF&ORI`GN!Ln$CwJg`M zm^{{Un}p@C6YeG?u4iOX<#tEVfhB4_o)OXesbpg_jg7T@P`JA`+SPLki#auI0=I{) zI0&Tng9qyTi3>NVb-3Gf}|%vAse!CUc-Hx^+dm@ zwX%R()|$(ZkpQzME2o8=VUe<86g$_I z7D=g0uDT#$(5)G}_UUn?u>|+Uero5m1pBb)5Q=g82EQkO4Ds9qO8kH9y$4uR>$Ww# zLJJ~Ax1fMPP*j>qS9*d|4Q&4Fl(lrPPun~>)B8otw5Q-2Gr3i{(q1Y$^f`Wh= zIwCE!K;r+#UCuuDyZ3%~-~XKR-Sgb@JYup)va(podgmNtjydK@n8{EuQ~rWlc96-( zVo;HiyaOsy@xY!&d!SVD@OMaLdm#3&QSsl+!|4CQ2mR}P|NH*N5XM1aXkIvmIWy&A zJ>ksu3$c(wUI#d62-}>dF4d4Ai{_D^Sh3Enu;1 zu%Tk3T8mVA=#1$isbvI@lKc*d>vRsy5v^b>VR3`5**q$4TSK{eEe-EAwbI!;PlN}p zP=eRT$K_;JJ2+yR_FSCmO9c0=RJm|MRqUxv9Ri0xwv=`Zywiy;lo5 zuj^1OPg>1q3lNK*YE7AexWx`OR#Jy9XRF)V?RPo+mzN zr32PKOU!*j30;?4e8;&ho24EyWu%oyiQ)xV?a6{^|13!z#{CiD9n}?|JNkhBrr<%n zS9VVPv!D(^UoR~BO^=^r?2R4Efi1b|=iw%z%CEDNe9+Ca%$b}G_(Ml;Uz_Nw_TB-q zjQ_ENMtFdt9PoL}-8kxY!Y7Rmb5=1UqKo=y;&keEO6!FHl;n*?)@&YzfggT45QsTA zVcdTnThFle=ASxPrj}w}A7L2i=2y#F{`~c@ruj6Aqn~~z{xwE%;X4*yL&(orRoaAx<2~RuA2yi^kOkW^;`1KVEK5lcMlldEA#BTjZ;F(yyz1ovt$DVuX{+TJpo@o`iX`_E)oUudgt=Lq(=)yR>tVl`SsK$OfUqp{3{8JO&1K_ zRS#C|L@Zh-QW*MqQJs^}7Ddf$pqX$<%8euWTrUzwZ*Iu$>`SK;Ly24ytJY>g46ASo z#9q`@R%{j+or&C#WKnAi%~&&U-3#K0>dLax98DaGg=BfyAV$f@!VBWE5lqhyW>^KG z;9b<@g~80+$jaPwD5rss{l4{voI+U4(YY1dS|$hyI{-Gy)CJH30GSOFMqmI+UX4_G zqY&DHy*u?=V_A@n6~;=NpJ6>Uf#QQNs3kqW`F5L7!O*3YF<4|w)+G7tiBHD1*-?b^in7*KYlcs^anEb__-$P!W{^i- zz2SBS*k*Y%1&EY*|9A=gPrA=uGRA!gUDgQ47e%M;&l>p>v5S6mjVKbx1w2`h7iZAd zSr4hB*yUEznl5tT6>=uU#u~MmnTuE*CM>t+eVqkGKGE;czxQh(cX(=OY6uw*gN#u( zjkPL#36zi8fBSV%-B**eGn)kA2Kd1kmL3f-Cs>6Sy&%bqWDop$#Vo5?qMM z+S#_(1Hula);-i}c;M#2bSC5=PB*ZI8*+lfd)Hnp z+tDp$jj6N$@tFCoU;Z>ur7AGQ;7Rb9)@mo~fa(zB->jB>)kzwSIiy#;75bl2~=VWDtG5=;-OhfNsFC zukV7_6|D;Z#%oP!%3d&bThV$E>)G|S7xQ;pd6A!`^{+7~*ykv0T?5%sTiCT^)tVWz z?H=p+2^f5Rm(&|ZCooilO~f*Ng6y@w*KoGPWKDpXkpxhSIQidHIb)%GpVW z?Hlg9gZgms11}Y>UCOlF^b;_1j%Lu`WtT}RFB*1Tpd1pUG|Yk!)NKo z^rU(4js7`$X03u{R~GDh2-zb$V!WhRzP?~lJ7KJ_@#c4EHreGn6pDPEv|_TKDYIVU zxhB=ix>N{iDM`NU?{9&&^_xw#Aarlw9?HiqzXNB;g~4_;q4q8rmLYozM^Kb~K?Hg2 zF_J{leh=((gRlt%bHOBJ?K$tu%SqqB)AY)ecUrEECQodAZ22v%khIkxNcPCBZyG5w zi|-AZf*0E+=6E$`@yfanE|%ec57obg?0?<-y)&p{ccK!V&6s5(mc_A~D5=N&ZVnUmi1z7`(R^W|% zo+(BI1&su$^tf`G@hKFJDL1j&$=){vC_qk7@X2A{s z06j>u*ybBJp;=-)1>tYzDi6Wd$8{r*=YaUG*-lo8b@#bJ=9<{$lr*n-)?oXqz8tU9mowD^4QrhDa9L003b zk)LULhcVO#%fwr$%V&FofWdQV!XfqrQZzaDYTtuWAIER)A{czY<+3lSqxPOkOFCdF z-J6wE0d(T+d&&r2bYVZK=&B`(%F<@7qzV_$t67zmUVCCm3m%HeDp@^^bP^<(PO^LD zSVXZ7GjJ47AM&cj?`T7aic|$DM{n8DBDGSgr!Q3H`XugK$@#IVBO+;Od|6e(XsX9% zZ?o&koQYMh8U{6bS8&*uPuLtH_KB~WmF1J)BBE;~;i3c4m_?3^PW~&}Fn7+u37ZE3 zqVJtKhrwT>WYqSj5{RcWy)~3NpV7-&tVgE>>)We>yZ@AT-oMSzX3+Wb`648h$T+iG)b`IgMj8>}JQ-qg&Rd?W+(gi~e_5cD64F z^BWN}RnT9JK!rQQltv zm9X<4JZA)`Vw1CVv53X8@~Xhpq?KDJWIwACR&6OebxgAOt=78tBV8-y2?JfWY5+5L zlpDUc)6ooV~ALJb-{GcG!xELthl9_D;W7I4jt~AC9Qi4(afik(2!O>17m}? z7+}~!vRSa@8RK%a+Z>Tgl3QMLy=W4y?g^G8;poiTDPs~OdtOdgN)C}^ru8Zx!m!i@ zy#sOwCLoTeIEjn5|+>mPZZ#!VPbU>QaZSQ4{}hyQzjRRod3LY`!@;q-{dxFd3(7(K)X=I>YIS%r zM-y(W%8P1wwfi=A%W}&ik^ddiJbYBOz1AvwSSqrrKz;j3`1OMRVE@zWlX9jS`LNcy@F@Cuzac!=*FQKuaz?oUrVG@v?9AH0r zB|#|dhb|^xVg&4SP96-hQ!kNlzhp3w7Q3^m&d$R8v(pyd2E5a)tnbiz%v!{p zb9e;JjIPwc2eq3Fd@@HtsUd7lf(s#c6t3)}L%8v-LRnCfN(tZGEDypm0ekgX7`Y z9d#HfWy;8y92mcxSt?;Dhxj@syx27Zap}?)NN3fG-if@{-BvxUeBz^$-sHfQ3hA`h zIUL-guXINftR%T*K&y`p1qnpxDxwEyECmg{ClA+4BJb~uTR#r)28g~S|KH6=xm{3S zdY;Cu+F1%>Ta{QmJ^t1JAlow#BX+t#@FZQqTC?II3)`P9FN#Q2t;kRfm{04&sb7b6 z<73Od*v4dy+V{;*FW(%ox+L1B?DNIzf$AAjI>Kyk^Z6E4(`>2Ss@*}`wC&zHdc95c z$V#*9IUV&@jp%%mqeZytkhb>W4oXZ)iU~&i7fHWo=>mdgesa`*+)E-Vq1X`~=*zYz1;YA5qP zliy>3R8$sBx`JfTf6~G9YB0-y?)`0KaG^saHWaDNF=oWOm=_X6=mTwuy5JCzRYR;x zNo}}V%IP2quIJr%$hTpAVQk8rd4Cm6nNdkI~f>Jlq* zaq`$U$#+j|!bWj{YNiuMxu*^CC6Rn+NQb44v4WW1+Fh^HKLT=5V3Wim%HHoY*(i4E>Xbbsy#RxQS z6DNNNdnXnjt+ZL{P+qx)J?p(Q>ciTzNLQbBGff$;P@<7V(#55;Cu-R{%OFmTF3w~j za$sG3iOgziP<<^YzK^05`w<>0(oGYaYTAeE1jy*>|I0*BzN~+n<~!|slg^GuKPA7e zA%cNLogm z-&nHe8M^Xk&TdXSF~?g*?Yb+tQFC`X~KO^u9Ye}n}Uuo;lmr_}_#7kQ>b|*sjY<=$UP&k-2 z$?aKoOXm^)34=+WVeLaZ_$gcUf4278H}HIMcXjNZ+PbIy@+L1rWkfJ!{p=jhq@k3PW&osm z>q{3Io1)fpC@q?@#);!;TuMVSlmp-9OBWBvtx|UiI*1H z6dhKOFqFuStVMrn91Z90h^v-Www>Pu-re!Yj+4nsvraeU)vvvmyM4?O%NPfePhSTm zAgOP)KlCZR51A1n68A+;1$OgvYGW`_z}L{gck45pRY3Mq@x3canpy8qjqmSV(9ej( zalk}1-b`&2pG5BbTod1GzBgL#O+}-~yHfWBr(034;}6ag1+#{8iB{rHfe}SDB z&>dR_Z5}*TL1@NwcUVfsdqq+yc2lFkolM>#HOb~i}F_ZH9!}ad9MDe-uC+^_0J~nXCJS~Yv*{M&6pY5LRnd@d07Z= z9kvvp!^$8+b8@I=q14k41hQT-Z+2@BjmAy^p92?@mXx~omv&Ln8=xe z(v1|Z%qfBiaAZ<(T0}nW(lcLgdS>It-aW)Pxb!6#$qqFLQW2`H&!I8(k;JcR`TFmrcuMRsnRboT13LJ@=!d~!Q zes1c7GXXA zSn%i)i?sSZ7Fcl}#Aowvn@|u3`A%{zBw@6g5NH(&Zc8qPp2sdCrp@NVSn8qc@|OVf zD37}}W!#|W9pSCj4lr^q%l(QuTIl4!ChQm1gG#BcN^ zCFw~XWc?I({)P07T>+g}ycgTY9bO_HzCKjy+d>W&PLBSE&2cvf3K6*%Bd?_giS5Zd zX4cuO@8!09MWNPpiDN*7_i+t!eh|4fnfvg_Q0Nh)5$W|B#E?G-oXf`y&T#I=A<(FO z!X4unRaWrhsBd!jHyZBwJc-msOV&?ZW47gC>&CEIX|;l8%ufk}dE2JZbHI`?F%Ss65O4V=6fF&Cz==@JSHR+?Ti;~rrP2;B<5H^v&R6;3#_2+a=& zhu2Gp{&kTcmfr_t2PHxXEV5Ss!^n@^eB|W|&=v-1U`^8Ab?tCB)f_{jBMYLw%4)vi z4LBhsMfiz#L$h$UxTv`J$7kxRwvUw%Id-$d-0o@wnkdspXp>fW>s^b(O8#VDZ)?%Y z@sy{tfFHrJXVCI&eX1V-0(ki0+a{K4oU8U0^~>L(Pcp02s+&#OGR&y;A`YJ)VuyT> zD${40VvWF+q5|O(cp+I1Sg)m-gp5j_vRs?2Tngk2#5W~5rDT7L3QaO9=6SsJ!YOQH zjQNLF0Ut%_sp4#2%P-w;68xm#jwzHwn&%Sbz)O7aFRQ7Y(i>GDKVt3ID&Fprv~tQl zqA$r)T4TKQRN2CU)7;k{cRlwFn_Ep`*4Z9psWy~X=ZVm|j|aDH*pQH1+qi2itGn2z zKk9Zlwp9~IEfkXg@mbM}9Tp#&;iKgvwetD56_v3NizH%;*X@?~vB*rtY~s%Utf1Ck&~sW>kX6P|{~)Yp=my7}psr z?PHIS0Jc@HB|mPJuj9#g$jTS_0z3CvmKls}wc@F_@7}R6EhZGG@J`Z;lhYu%N$>4Z z>{Uo@k<1t#8}cr`F)8KDyi|5y0uL1zKXTfz`s{tcyE7|+s%KI?2)8aEwKHGZraRt+ z$HC+6>bD}5j$W5ZOJj6!Glb0Krct8SF`zCeVVr0MR5wmlxQ2OE1JM0oR5Sity8Ex& zKS+-6{*&AvG^sSIKbCZ@Z8XjmI*T?Su$zGM#2-=>kJAXN)|zq=UmL|oiG5hdD?`DoTQheJWlEgTT$a}VK8OF@3tvo zC24KX12XSIlg|`rRa06%b_VUyCiW#t^3pTTLLZB`ITCC0imkD0{kUkJu!MDgxsPoZ zS(!r_E3RXmcu8-z$WJtos zgSbQjkyn!l(Ps0dm3QEz7lkYN0Zdm`I(38kx!`c+wol~XrLdHbPS;?Zy?csW`pIvd z1TT)wl_D#gi=>aNFsyo(L=xY;OIn*+?HrX|?p-K5_%#fuP(X)`ep5)&qUEYhCHPR> zE=p$JBn-K=C8;2vAhl_cEui&IH#hTZrYGyfD?`!{W3BP)<(I74_3?!*U|+qGOXdx$ zW4k#hGptMXTQjj!Gn^Ga9$~*r%b0W6hy8ObfIk%U-vA0kH-Ek8m$yl5d22Z%)RwND z8!tq2zEJhJS+`hd%pv%(Q|u%Aiyt8ay%&*+4!QGT^+vpzhdXZC4K*M@*qeOlB zd`rZ3qy7`woa2GM_sjf^@EQ#+V}cuC5rT^OcMCp&)c5Rf!flR&14y&6uc{iA45H@o+QfyIYjdJ+*5Rdnffmn$bZGM z+GlAv8#mAt!h7d9tgm)R;933P`9Ly${Kpc^Ddf6FV|Jpco(C=vmf-WHIoKJ&hR~|g z2HI=HGJKx6q3G8>3sOL#@!l!j(@hH5!-7KHb{Vmnd#^oy%57?>gQFhte}a#^dz0F6 z(9r`qyV<7Ggg2oSE_Ov<`|{nJd;OxcqSXj?BJRLJrgBBv@2uBbpH|X+-d0hKI_X%< zk(lwj6@I4IU;ZTD{`M_W-_YMa;~>ZO>yGC=KJ4Vs+5Akz$Yqyev)@gH_-{jPLFn}3 zdpaaKtHndh&)=z`?@CF0;$ZHm-7cOjOH_Em(x|u7kLMo}K=$UC?q&T=gXdaxcoo2s^K-r2CKp%?;rT#$ox69I+ zjE4cK8wHB0a$62O(MhW0kLs-j351&m$)}uYuwe;YOlAWe)_2| zvqf%0o6pnO;AV%}#5@AQn%-jMtAJU~687eItKY*GNBUNd~EcCll7fR7bYj$Br># zP7I0z5(xCr-(elX9bgk|V^5CU-W`?Mofb8Nt~)*$fp4fSn7r})VxYtB;|p2uI?8S+ z`tPmY;O@n-aW%GwS?BrfRps)G%P#qmm&Ms%YIF8*1{CSgc5127N9(d6xoJbmB2I40 z_2bE)!8L_E+4vp0l{i#BfNZ7y^~j-rMo{~|`ItpsyGAkZAPR3WeM`!0fab5TkQvNI z^q2PB#s^G#KLOc*`w|eNmYWGE3jzQymoM5*GYa&Anz`ZX>Gkg*u`ls+D>eZ}n$uu3 zM;2@}nbsE-59s30l+3}r6sr11%yER@_1wyvTie~5qwMtix7CB~Ui^OUr@*E{a?mDyU#MZdw z7h>y*#!fkVR7Bb#s^1-sQ%x$8S2QK0x;sFH8vApkFguNL4>CtVolVM;CP?w4Yhk2z zGZa_AiglZuo)$fi1GlA2yAv=K0fgol(1o!+5stS|F^`W7ZUnVtm9DBiaIz@tIP{G< zp3kt7hXBNvo75t9a4Z0gleUcqp*b^AK3+TNd0NUUg*H!IRL8-6w3a7Od6MH$UV{Za z!i!czh9qMVIa0Ussvvo;&Q(eniO8;kyMoif1$cSLnwo4w&qq+A;JS;XF+B#wJ&NfC zGYS|ibqYM@(BxP(gW3u%XRbf>6}3XiaGm|{iVb2YIIV$$@Dp(FwKgNH_;?9{lKr$` zgY~*|uL0U_B??qvPW&!ziu;x|RtorVHl)6zClEw+o8O*9a2r8Mim;ojJysHWzj9w3 zcx2xc*f;!cAN{`Id)h2<npq?}Ay(#N!0gG_IN5(EQ{m)W480h=GkdFowFI<9x+f@w=pz2!cWz8SNiGe4=E9CG}xYt+BS z_W!6>v#L>RJ>x^g3# zt`3Vv4!eUO8@#-v(cs7lWTgGUYlVzIEc}PVSVU^rl?K{#OF)Jvleaf43Y>V#4oPAl zC({#}7lu^;288pBOQ$xYSfu%5$%&7vksrz^wa86HE{6A^RYS;o1@p0acS_Xi9JSh~ zKHXQcPDBzI-dCWw;Uqwwfa-ZlBCwWQ{4Mq{yO@6nitNPTUqAn3=U^m6BBEaVJ5Tysk+Pp-cU_As_wR2_>7{w&WPhQN2>v!{845r^r&(*fEg~rr5jrG$*i4X-mEZwF< zCoU2>qPnEIZNiA$GI+RKbIeKyk}NIf^BtO_?a^z<35p#;USx$(KfaWW%F|=|YL2hc zg_}HtfbYviRE4$Naq_%FZ5&WL|xwJ;)su^voNzik!`YajG) zOV?pP8dAedYWHtT`idGnuWgV2#D39SAFd+$#I^0$G&G9z zy~x3Ie)ZZY&iPF+hg%lDK+@0dfU95Oj1Mv&OnM$ahl0tL*9&74 zP_|IAID9#O`Y0+QqDX(R^5-nKxqJ>jx7mEIB$61d!Hh>K$WSst zjM1?}8=RTgJrKW8^u+rI|&A2TQy-`BB^Mqcii_bJJK1m zWUc3>xJO1KPt9k5sXt_Ue}y(f*8C=uVjho=%lLl!edGOj2@q#u9lLtq`zu{K^-OA<@e7?&*UD1 z{3o{DB41){f=@V1!Cx%!4(D#2bcQ)~|1t8Os@7#vkU9QT2t+{qP#WmG*Z7Ncchdlv z;iqx?Ght;elT}9;Ry3N?H)zK4wavD#Uwo~%n@uG*f$t};g)qU4R(0uE)9U$JD8R*K zI9ey{)iZ^Fc2EiSOLL8!tCdLf;`B6pq;}-BlMWOo(vDL2I#rZ%mqyjWV;UJwME zDHG0YYt$z?kwz!FCpILAeMwBW0^&l`wF0@fw$7Fgj5$7n_tm55_R%^&%@F zA!=sLXJcz)&=+!50A5Hl_{47e=W5~sW!lAj71c`&N@8VpWTjwj6Qol(V(i@Ijpc)M z@W2Bm41rdS;E{ux5Hzz(`9Ko|9SvT-n|VJkiGmZP6K$lJ@~Z64|02n8M4Xw9IAlFz zI&BR8Vib=YCuo=P(dpO`@Xpfjqg`Z%dp~ff;LZZm0RWczy(ILvxo_uZ*ry|QVgo;% z%hv2$2FQN7RT8|WvYl?v+w(1IM3^yhOxM>Y=}8K! zl9?t}WBrl8|Fs?{3n{!6?~rtdk9)VEucgOq32L!6|L}_Y^`PXdT-*MWtdJ)tJtN=S zorQSnC2<>+VpMZDbvUe=BC)rGaJXs9_47+mZgXG2TC0P8UJS$j4E5&9)}_uf+h^1W zkDYdO(9a+fv`MB!ndlw-n_mhZl5@^oDVDpN52Ehx?NAV1qwNC_)tuk!(J)_j6>RlM zOa-JyV3It30hprtyDrz)DeX!Xnn0tE8cAxa>s>;UPg#u8P8goMZyM0LTz2QU-1B%C zN<`!IK%phh^gYJjEpGQtd)$%@W{vu_^P2lDs#06<0*38p7yWPagX;Kznm?fo_IlDap5$a|6vwM3$VSaIch$R@hhVBmVHd7-MWb#;x4i z*_XNCasx=Kqm5dlVDZX zVezI-hN2uet+VlcJ^^WjEH{e}Wu=>5$dFE5jO8#tH>nbBfgs5Pd?9OoDS ztBR;BayA996m=NDyF1FVjLajkKmRLQ|1Bcs|F3`cj6VqQjZzTY>2G9^@`}eIMXB?#IPY0s`Fd=V+@$` z;feCf0jO9#3?(ctmp;iOyEA_7oiMO^)3JV)H%sJ_#j6>~0qD3w2^E$Sq8UQ6XxF$o zdOp%jr-K8Jd>++erX|gkgQ-#DmzGJO7l;Uj@FUNvOHIO@4Yr;SvBF#AcC;#hRqQa) zDSLitf~yoKET8ov*d4Ypm`e2QVS{J=n4XFBrWW*qaczzaC(W&fJVPG&}h2 zS9vu7NWjkSZE2UbPRmMokS$L{N(ZXac6 z+RNj67RNXG!~ka67tnbXK?<%)`?6cW{YlYki!Ctc*l2o$I{88pZkF{xS4TdTf(n{{ zs>QIchunQ>qpl5r{msn|DtrcF%84uxK-`vn&4H#N`7+`gpVX%lzn)2s#Nx~6Xvc8d z0xwt^oOo5__I#^p`ps9Y-Ob*EJKaymsx?!v;_sF7tMAJ5j3~g!!FUIh@s?*rPVhc4 zODjxq(a^DkC=OGIE*rK`TD!B3a$dJJaojR;=rEcZHak!zJRLcSCGE+gDxnkw#KMAf z{OFt_clcErZ+lqft#+p$*E;x!Ti8z0h+%$&NABnBdDBl;8IASUAIl~%&of3 zB&!Ng`Z8=g=8+_&rI(g3lO^j{dB_G)Zbu%Z>`!>JfPWqqlkLW8)6Q^dy)#iMp+24hIx>r(d7%+!-YJ)9 zB9M>~9%(b>e$|luLIo;6v1Q%mWOcIa-a1%*A*?LLFlp;0w}?l3XIH;Nrjq)zWe;jC zg$VRqrm@-NpBfGa`&z! zTZ`$PmIUL56@NLFa7uBpYtgYJ-#X zi+z7O*L7BUArZF*X3}PJj2+L3GmL0tX>?I#<4&F2eJg4e9OQYNyidnos?An0x%|Y4 zdr5KU)p%pFi)0tc*LpAiq;xOE`5WQ7-pP{CqZli=xuLWc!~j$7H}YS^4|Ty;#Rp5p z0F_=X_7xoO?|H&+x`Kb*{`ZaH{^94&xvT`ZXxC1UI@mc^mm<5gSiqT?C=kz-M>eO^ z*_c^5f5z;XtbQR$d0Ss{C~3xE@<$0#jhfhA)d?ubnL#Z49$Kk-Gk9P=Wb5WCuE z(5&4dxA??>2hsLquFhqiAOCG7kbH-||``~(;7c6z_D+wMp=8NotN zBd0kOObhI5g7}LZSsWN^8C*`(( zVA`1XaF9)1NwGZ?(=9$muN4bVdrHe z70j3OdnW$TneYVkA;UWD{0{X`hb9Q=rgZdV$#LFLx|DA=c{^0)Q|?6(AHB$kzNbtBU$zVO6Fc& z#sx7;E~&-Lu)N|u=C&1#>2^dM$*0pQpW5i|G@0gvOvA(n42dF{m=B3ZGB}%`rPFuj z)m7mq^-{|scqbL*Kh;w5e>&7`vSIa#b_BM$-1L(2fr)$g`iI|q6rGC7@&>jC4Om*+ zcI%KK-gdNy;r3#FyunvPec;FIGNF>8ctxJ_3)H1qPpjVl4yHidliSx?<-;__Z0 zD)pGVcVRnqSJvLE&9p9RrNvwr#}a6*lKub(b)E>LH_# z`z==%esp6+iwpv)q9q&gO=twQCjBfZTY<+KPW^1|^<#^pyIFg_fL}gW{eScGX*3(@d_uhMHWrQCsrg zn0<28#gA&_P#Gmh3*!Ol{xQv_Dk1Q!^HQmR+3@7H-8)=Xn-3&@4{w{@)MDHjT zLu{&FJqRV!TQ3X+3qP&w>Y6Qh+vFaYx66tvM@T&~LQ&Ag-D_07LRm=9*XZ@Qb4zyo zn1s#78x5QW>t0Lwx~$-4M9%(;`FXR`sMvLRgM3gM4r|BoC|SOa0VsJsPgfigLd&)2 zg?GkpopT|=+H_PdH!MrcEd_LA}6q;(#Tx>wM=?`n> z->ixhw@X4o{9P5r>hSgQTpQC}Dr9Z>0&3&grwN>%=Teov2smUKrP$#uc%OYD``PZ+ zjBQ`rQ*2>gB#{&KXq$9Dk1DsGo}z`G`^Cims}GEe`g{;Vd!||fgQ}6EzL6$*>F!7D zrDz&~sTS|}njAGWcXMhT9`|`|xrO;bm~gbmH2C(34GQ9FtXKES@4k90e?d_Dyof$+ zUhJXshvzFe!I(By@Iyz}Bcz;eSX)?++?wdd9jo3@K0 z)>Yxwxo)i0((%3f&pRy_91&yfaJWN}i66;s=sw=JdEn#qHYcwmea~qlvPiRSj;;r{ zBrW?eJ~WaE`z(9BUEAPOKMAey=4DCZM+kurCYM^G-H_jU9-WuDc}CFic%~Wt@O*VU z@t*BO(4nA>kHcvAeehwWh)cIC?S-||sr?>VYAvC%8Wr3xZR49iSM z;9xegr=9twDqN3L-gCm_2iMBf zK+I0(FEwXlMG`~uYOZz@$kNp8#cL===huAWs%sAoplIG!jr6S7H(n?dF?wD$R7a-m zlijdCR=#Ob!Pz^t^oqGLC-a78OGh`4pS{eLgjOls0bj38DCuEwwAPT#Tf2sC_+U-) zcc@=G-F1>H;PfyfSJ57?wVo_`w#BMGz3L6uQM#-q1BRyF={kp)P4ab5o2DkR)53qi*d|_^^%)JqlUP){n$^3Ng)}pRxV@n6Td^s6Ms5+Xvt{* zkvrJ^$Q=qpe$5?OK`KDKB^mTb z;nvPtv7f1KS5~f!0o9sWH+XAyX5cn?9y?V?2bsXRl>8zaL;k`d^+`F-{bR;g4)I$z zXJ_{yzbeLiyiG#bx^RQSv{bD0xz^76^wBE6(mOSXy(Zhc`5yj~_MZg(&oTn#G~gwC z{|s`@3LVe*r;6tPlQWI~XUA3>;dc8WVW!vGxw;}H)N<`C?tqY)1$@lBe^xkdZVI&W zv2EXtRE$*6S)4JLXyRmaaBZ8e zZnn)ndD>y`%O|(=Ov|p@pL5b?kb^3c28GBRp5`QjVf4vF^Rwi!RZ8L}5A__&$Vz^= z@S9t@CEgNPzR_Hr(^*&6c$%@xa-5qc(^j)h0>%x4RSvB5)SY&a&|fKg+JDnQ6304T znw+)^8E2-IM(XmEtTnWpcHXoYHXHR1>4(JFn_CW%>M`}FKR8N=_Mo{p` z?yzyWMMh`5J8p~CL}GdYa6|?aeUmV#_@UKsz~8=$grI@<&8&wc=WP2!WcrBoDf@kO z1QN$ykO2JMe23-JJYPlBhi2Q3gMn~DjW%MtPa4spQr_Fw3)`Om_DMvdYuAR_dHDUp z3cmbYs8qlxtgcq&$U(VhtC!v#mwCImGjQba?nk+-Q{o<6v;swoH^&l%c?_AOT3W^D z{C@hNIr(08C%G$q=I(o|*s5^cCwJgl`0lsMgD(to*+J~wWmauzzS_jY{a-jLUU6pQ z$9=Zn+*_Llc|0@ZVOipO;u@7YXb)Mj<0rBNBYM zIf)!MNZtN^e8*|MHmMW#_>1vix;^ujchaI;aY^v;1@l_xl8Qse_Vh&cO9`zt7nzcr zjo%t7>rG+sKKYr}+5r*#yj6tLMJ1p zu4gh&m9?J$JkIHQoe)N^nCy@`)Jc8(MhPg_Mb}~qDUVdBkNahA%L#!Fa4W9vKX6m- z7xP$pr`p-vfNLEV2mxD4ku!u5=O&J`)u34yAyYUg=}Rs%M`_kcK0Vd1LH%>B(c8-x z-4f1T3}$@;k=8!r{aeT7(geOQnp;1gK3SVq5Baml1)oEXu!ju$gCWCZ-1tL{A<}l< z{+}Fgmfqv#ipP(XYiJdwTOu zbvA}l!mR6OVep3|{9@KR4I5iC5&cU!0QUn%Ap3;9OK4B>SS}VlK7so+?T3l9Gty4D ziUUF;5Xg4LTB^NgT}UW^~o=L%TL$bRF>wCA9k?ZTJB}5 zq}&!ES6{RFj-fS+`T!IiUVt%>u5VZv)|JGQ6*>E{pLbSCwE&fSP{7CC&GqLx=Xs3B zee(G)`Dq2Xl8jk(4%W_3?_()R$QG11?Hfz@=GYtvm3)VpUZSB* zyZ0ND5E;sxpy|zv$TelY0;Fg8ixVcC4K(wKUuH-|X1uwVMk#ymT2&IOx%tMMfV5** zCwaeQN`&1@c6+9}9;b^rp6c8pe&aken$fJv?2!>`(VB5CpQGL&Urw8Le7`AwsdQZU z)qZH|sIik@Q@-=qFyb|v={T+UpiiUmvoR(NDAaIy`cHpU{`s9L(cW@jaGaFH?$BvU z(rcXXELQyJm{dCGkV+~dav>>6bD&)sZsv^DhIE$m+o?$;PSG*YC<3JBdth`hj{vuA zPg)fx32`)-$gQ;puS3uOCI>UNd>&&gI?yzQ2TyJMEMkz%TZi5b^;J>>F{Vq;!GR$aDIZyhLjE0j@bvxs*R4sH| zJ3so%q+E+fqJC9{A8+(#(Q!rZ@cSmO!n^z{fIF9CRxBXR`Hf#ao3bo0x_wE{=SfyZ zrA2FdbRpNtF4Ov-`j z*>h*+oSA#)-rxMeFCpbk^5x6B)_T^no~IEx_<890F8N&=Q9>Xg=9ZA$>2;MLLxjf7 zgqVIMeu*Q8EQ;)U=ZLay`BnxA1@%@JpM1^u6L#tw)X2fYb9Z+AH1S+0Qp#rz(g&8< zxeVEv{E;|qZOgJ&_NNvpMM6NuF?u&Hcs_f zITn`x6#pp!KlHR#Km9ZWmiAtqA7swHYsW|S*S~ysub=o552n<8%+1aHTYE3wQ9X6+QB*!|lUiyBq{MeP_iEC-vkx z>+uO>EeV%QoU zz(~<2Tk^+Dz#ay2mAutOBXmS?|1Xi>H7<6PE; z%jX<;JKpk`?K-F-+5cfc;9mk4L*D6>U4X2p{t~Yt(%kv@&^mqSTAVM7f}9bqT&ENXkghIydLuq+LIU8ufhx0|H$>X1q%%kiW7O=Rn|ACoxaFKDS2ub)%?(?cR^@xJ;1MQo@pig_{oBnA zz!h-o^k8<(%53`%aVlufoxI&*s)z5{ZM*AGO{+@Yo(x~wd9@t5a=G;=N)LaBc_G|p z0cs|zGr$~YL>6UPlgLbOUu9TvDpK&fVB!CkfRz6Kh@#2=>GxS=W-zmqg1~?Tp%rxh z=x?TOZb?y1G!n_QuTF-4rk9q*yDZ-8U!P|O-Ok3{%b-DW`)X}XMw}h^mbSI7;MT%U zN!Fxf%GL@K)Lzd%uBMR85W+|@*bB7aZsxf=yB zsqs$Qc{qCt3jsxd7#^HwRh^2BhY+ouQUPCRRrVAMKv(Fp`LWr`9)-dU2Se_)tvQIt zSuZjtXgn21_77~MV`D#&B+)Dc+_2N*yk6~%GnbNHx{+p^buSN3+||U`wb0Unn0U}j zFw)q(Epz%JCNL?gZF$ci{B;h0f9yL?rg2R9Azz%N-neIb?srHDms@vt<7Aqg-dU6* zRqw0Eg~F5+OrwQHOHDRCi@kX8$X@{Kn1T$Kb|Bp8WyUdRC-7@%q{4wELy}jT`)2>k z;KROwtuzmjtV}#Wibp@}#X<8ohT>fYYBLSCB0aN;2fYw=nv_8*F|TDg5s(73+V*7K z+V4LPW?!%pOK39CFbKF{i2?Wl#X<~Lg6)(sk*PVDq}lD$i@OMhu0g)mavgH(ArMxR z0$%GlV<`Ns{k|0TQf^&qiQJ2(hX}^;VkVL01ay^ALDc;92;!}F{^id2;ZPb@hHp@% zm3xkHqZ2xQGlUzyy-=ZDu}RWo)>ZjWn>^S`bxiF?w%+sVYx=2M7&A*QvhRvD3@9HC zC2}gi7BuyCMbiw+%HBNXC;fdB-9MFyf1S$kEAHu5=e6=Q`a^Ok`F#`!-~gCW=B&A+NL9APG=T*1@aUaZ+wwcD8TUC@a&5WiKUz=tI*}O=V9yN z+}Hacb5DU(gTMh6%p~lKI}O*t^yvG@q8xXvx!>* z)W+j^2U!s|g9$6Uq)P8GgXeMspc}&~9ByJXWtr(kNJifzEAi^Xgo-R>T|D%BoYrQjp+m zYz)4nrh9sze`dlKXLo9Kpo+21Q4A{qrR{PR{0>@I&&V(xxddIcxHC&#dD zZPO_@F;BoA*-7M>^{F(%vDan**@EHUU%wmDTxWSFtY%JM)zIf~p`UN*JEV7LV84){ zUL?rw%LQ&h9AudezuZK2TDNBu4Bn`H^AiD?rowM<<-afg8~lzMKU*MxFX&CZnpzPTf>(#uWex!Zz=gfe}}#_11N@#+6!2N(wdu;%D`Hf)SG zWJ9t%3F&EzCW8d@Z%gE^hug#GwgaH`EW(R~$%^4QO6CHOswQztRybHIL&2Q4yvVY4 zlA9bXnc-R(zVx0BmYhukN(80NLnO1<;@FaTb=!jK`^+qognd~t%$CAMT?x(xhUD;B zHI-$7|LhV)Obx{`8~^!N*|>c`!}N4uE#hpiKN+1oS$&;*CfY0uLO-iG8UJlnK_VBj ze4wFT0f-Sm2WA_Hp~&xKgm`A+YJp~m>u3y!Ef6Jk1c!?2z6Oresw~AEuY|r8-x(Cya}g1eT*M*Rg2^>uh0X@n=JN znUtlA*{O^hwMg;ZQ6KGrU$urgk;gxIu>3FUCy0ztTW2d1Tb~N+EfrifROd3tq?n6_ z>uH~)e^T6#bdL`t1!x5td!b&d?l*Is{`ztC~lt$SMD=~YoW z3n^9c2Em=x2SV~;E}W)af=!J;R#R7Zf<;xpy3eA-<2FlUAlV}yj~<^9W}Gf_IG$uK z&D^@1Qo=;6<~rTN8fPh)nY4O3vaMHUPt_I3n>;2xO0TF|N3DTO)}V`j2VjtdH`_Q1nhiYY8sQ^mwUV(f3I#j%Us_&6&{dS+ zbY4sm(d@6tE46mPFs0&=hqe)P>7wd$-H2pP< zz|wemz$l=rJls;B-xqCjfrnLuF(xg>`C_YcVw~-0VZD2xHKF&UFiNQf^XYO+9g@EjKe<0WjA zZyrF6Kz+=k@lp17a0Db-6b;~47tYoQI@$sqV16fj|LY6T?Upe{;R=+~?;p_~kPquK zr2)kM)AFE4QSIMk^>JZB#k|T}s@-%bDdBxt@Hcr@Ou5r%zA<-Cd6|_@^d=YyCCfiw;vgf!O&J^ujSGlw zSg_eG&)PlUJQ%9$sH&3cptuvVljq;=T;~;2vy;<)NvY06noLCQ=q&s70yg+m{b(nO z!X#Inu!cMEiDJ>XkqnVYn1Gf}kTJW(*>~wHdkQz!9l{^uxCB)?lnEaHG?QBI-XB*!Sa`G+E(3$d4Nh?d)IYVc~%XlNI%6(8UZr z5fIVQ+}FrNIgqqe`NnvhTShVCSjuVfMcEG}b-CV)stvxu*K&QLwEL-;j3p&gmZBql zv@?x&cV9^F==^QPEkWRyrgP zSYcUQY_r?hINRxAF&>#XDQtl04~GCDoIvFetb1}Uq-Qvbt&4v?rEYrr~7q=&BD1`~F2*gq3+0ep)jP>>y<;e=eE*4oYj zgL*^|CDUz9u<#iiG#TIsfKlI_tRDU+aRHaJpkK3&4>d;vE+Ki2m(uXjjrOOh1{)5d zHM;7vGA0l(5?JEFsA4MI>|shd#1H5KXb~Bkd!^w6qQRL(q$bcklZxue`Sgu~WlR;^! zE&vqhAcT-*sbCO+|8K!xxTu}darrG8NBl58!$aNnFm=Y;~8Mdv8hy8uB*J8N%`&?b_+MSur=J*zxf3?>luE2ES`de1!C!oTG ze{4%;^3=v~J<1KGK2Itvz!G`S;(7Am6U(H83>&AtDv}!+9Nz6asCgV6oL*wxu95rc z3^l1@S571NFF+za7VXgZ^*c}=!6E-t1!PZWclxj?hzT;BS0-jpVYBW2*)>WlZ-qcn z4F=1kz^D0j_#2PU@Tt3;hKOc3CVPNO&&VUhZ%#9z#N%qJQ{N%xdghd6y?Z-{+N5Mf zsc~qv3UGaaYL6K(BS7Hj6s%RrO6`?H6S#swM{L-WbZv ziASD$nU>!!;zLBUN&Z(zHl2iYm!8vbWkL5A;gxQjFuPHg~5R%F8n@6r5KSagl1# z?sE)#xXbY9O#ah4x;yHVQP1C8T7P~u8$z}MRpPvC5%;5t_74ICzgvLd2Q;U?kt9+s zkF6RTr#>7YkiYy``e@m)P8@q(Je$>&2%O@oF zhX(gSfo-u0WU#2_b1xR0<^;WmVIxJgHMCHyHjm1L+Z6 zb~`0gbJM5o`V3RYxNRUbC$`wppn;wLxJ3iuwL^w*lvzIPbK&oiF-5=*7Y`FwsSO_o z1;di>Ytgi!ClkzDUeH-AVt(KQSLr}i--x+|sq^Ds=_|= z5yPEwWVd&ds<~AoOKOhFfRgGu&oq}XUWM*SzklC#GEFUwztlMRN8YZ-QY&a;)bT0A zA2dbY74ocas*91oqFkqzgeXg>&@c@dQG@Q0e#@clrEH*Y zI+B9yXs32pI?zi&_s~@zVafM&)n~x=$MNsb`#(rOK=>7@zLPq;eHwqQW|kYL(nvI$ z!_zWgYUQ(SP>N|SL@3?`%~fTjk~tY(*G;kjU~t?;fi_d(ks?B%SnT9?GJDk6AnV<1 z{B-q8OSU*rC|ToYIgzZxLv0)qnj@{gum?Mtc+xZR!&zk2Kxs4uyKN)E4gzKt9^Sy0 zw4DqZ&8d0_CDt96XEZJ<0B>J2WpY~sfr7Ap%lNjeEJvDSZw|;_{u6dQYMU(#)GfE1 z24803h1o(_>0*+s+`-6QRKR7}$B-kf6zFF~pz#Jvrp=RTKek;25~cLP^k#4p%Cy_o z@%yf;K;M@lrZFBamLLp#o$EBgki6S*o{t8esjTb`AM0XkyIW433cUFT#~d34?plJ+ zMXRTS^xP%HTP6FrI~3mHin>XKDjRuJK9)=7OGx;M&;OJuhrj$Et#k7?tjEN5=Lsa- z2hN6fX;0PGsI!V7KFWJp@7}F}wsU+0ci=Z(#}p6;4eC-S3ozrgk5TrpRA_7IMaHvo zzE%GC?upKv7MT8VD!+Hu7>9yz)pxLa5G0;l04H7(JWaPQRIlCwAw{ zC}1l_TNxuA#l8k!0_>Eras~kst1KqQrI9phXP4Y`A9k!p5iaAVh>#5l4XULt=lO5y zhLSxq;Pbg)A6lO|Tndfnc(tmx&L4rQ^(!S+(Mw=#Uc2`E&T~t( zN2vgE23A>QkkCE#{^}Dzw9oktX(I3>0ag9s-Z5h9q)&=6cIXt@9?sD=MtPW0gVK`+ zAf5`NASe1EO{(XVXLUD3+;RcRQl1M`VUo-^s}ocWUyk10mtuGsaNotj{6f>kUW_a@ z;aw+YpO$clW-!ic5?-i1cY!BvrXohC;(3hKY-Xqu`*Z&Lq>+OsB$6fet?g6hzh+%> zcctUZp*3j)xlT@(CK)?@bbXW6*wn6)rfw9^l<7KoP!=oW?=5jEye|wHKa_Wk%p48w zmO9Wabw%S3OOrW}-MN~UyY8qp!ky;CU=@hT$r5qnX{ISKX$CWsH-KGL8?STPuS9xK znjZ&QFZTkf9f-mFcvPap9zZ&>O>_!dgZ!+I@&XQ6K|q0K^<|pEZ^_ANUWj+Kh5qaO zRWLI5q{eCI*Psg`b87D}r_`&0wD9ecrWMg)Gvg=0r)~Zm%|V-bkL|xnp%sYfA0oF z|CvEE-S&u}=lCn)V*saCbc8Uv5oLhwsR7)!TcC53>XEubdZ0mYuP>qahEqu@qx#X@ zAXi9;OrDl#kQ7OJRMwM8!}f0(HfUoaNEotY_go<&H#*yY{njV0#>E%FT@H>cy}|Vy z9rYa&OK%Rid?};@5^T6dwgqM*duChjfMk@}+R>YX8Ei7!Ir8Wygmn_+ugd3dD2fPl zz0aKByJl-lDB(G5Wao4dIveOY!|nGE)&_k?jsNKW>MACge(=v9~^7@d{zIz7ey{r5jO21RCY${H6~ zqlv5=Rgcik)6OLXvnKf{1UPfe1}+BzN9nXXlTjhtAP%bF(G1iqD^8uxmIY9+uPcWE zB%X)_ayr;1N(UfmB`F}|4HV&Mu`&KSkaSRv%1Jy0nx>)}xY4rEFIlsoXpLFKgt4Ks zemqHROI~uqc2f#!PXhgt3DnGAV1dv%slB=ngx&RcnH$(^f&w zPF4>SRh!5IPG1V%K(+!AX;``kyf~1@Snz||LecGp!GkM zwUfR1_I_&8<^92OjpTq__c4q68d&$#5zmZ$^+kQQ{Dt`Y-uf2!RHzWelL;$%vtP4arx9B&i<{eJaYai0RP+Yko z@MI7yex1_IpD<`~IeS;Ej%Fwy4F5ITNp#}bc-xPqLvVWMs4i>Q%Hir$(E7ut80gY7 z93B{nrOSYN7bTH}Wxl(H`cnGXr`+&h3XIdq4WVvF(JWSVXjMEW`3|`;=yxS|(mOB90qG3HyRQ|xDjT;~K5}U7@biV@kkAZeA=M!3oTHsGg?F{^wTX6prgq~ytnE(Zd zg#d;AIIKJ4y#F=)2MYbh2jAWn_H|cuhnDkAk<4xSWtm2%2F^Es6PXQP$dwY6BG78! zm2GI0_~45aXg@iRXT^lnw9)3%a#_bQ8jZd&)#6{c1-j(~r<8*q-z?9pA|Z`+sx0J} z%CB550oKWL=#x=DvW+GiFuGv-%@Vtm)2B4e*B*Y99`T+MOBIFm!bwprJU5E?_pkt!xAR+!Rp5kN%s&aT)DlN)Cn?g9zY8K zqaT*yxj+()?OVp_@>0a#qL0$USzsGTKQahTAo3>(@oTk@Rq!f~G4zWa0%!X`dL;RNLfdUPY z6m5wDLdkSu2@GxHL?-Z!2EdZ(lOaGQO&TaG)kDBI9Dr}B2msauP+j2Wp_u{&#t@tG zPW8DE33j*RrQ;=VKSZV1M^m03;(OHZy5Aq!o)H;Venz@KJc~Y6i6?OKqZs#N zTl6B5kmUi;L8OvVay#t5kP>q_^<#+JBw%*2hn$Yx*B=d-RE9(=5RC>mAtwS@UA2Rd z1?eLKA6ggg#NM6V%D;Da(3vSDS?Melp%#_DsI23K3|8o?;v2};6~R#O*Bj@|fg;WC1xro=qD{~# zS!|g62T-|)y&mlcL%H7f+ma%I64oX2VWq(s_Xg4Dr|=E;#sk8!?~rOX{GS_ac+?*N zclR^>kBSMt@>b3D&acp@%;*Zg{$LKrUf5~)SG#p;0F`(9oTT-#t{)Ai3+Y){?zIXH* zS5u=EG}-{u*O6~_P#QiF6em&gN%x)6q`8w}PP`2f-D~K#yk~9i(vh9SV&`na^|;J} z$Au;Q3hozcX%kk=H+a5MUmZBW?AU3$fS+#JKl4NXf4_E-4V-?NIec@sD4{0VhGo8f zDg;Ck-XOY1AqY{;J^5ZO8|LY~v^z*C;jAvoJ&>W_vOl{3gabk${BA%Y6DV$;(sV+I z;L=El7=0Gqpx5eX7@rh$9*paT<$MLZtYo?CB#;LOvp@Y>*mBVSqo7}v0|v}eD*)x} z$s&x4NPs|1lb3ngvgk2j*f%|>X;oSnFo#+o2o4P&H&sxVLCo%HZfA&Vg|AATMY3%# zBH{SgR%M~lU3ka9{GtU~e;xm*m&Y{_G#gD}aK2>~{9|_C?M?Z8&D2E&d^CS~@)DX> z9te=Mx*Cesk7&-lzV;5vu!EvpxX5%#&J~R*z2P{f*?4|-?p-^L24{i~OOiPWe>K-9 zrqN0}G6D_g9q@{O^fUceyY1SNL&Px9tJy|W!%&{k*F`d5lpuU)bq@;0C-PSw3L^So z({eTTD-Q)g`z`eglT>6C=qB+{#$I*f)?86zm&UiELAHM-Dye!FvQE;aP38akQaGpZ z>NiC=TXxYWT5|FpZ_)Jd1IL~t#EB2FPx11iIigCQaRVwN>-MhVF8VUoFc-6dRs6-Z zok_thmNs|S-$E45i9@X$TB;?FSHeF$+tNU158btPBzL8J2J; zn2_hYYRQrM*?HM$ShK#RWVG7a^l1f`k$|h%nDlNGb7k97olY1uOI5?5wGS>A#@%$E z!zaEW;Em&LBzvi7Yw*iyA{km)^A)P)&}KNf1!-5TfZ37LcZj(dBf5x7^qWuuiItNd}$YUU@Q zFuX4PFY|p2V@U+k5j;I`9|ktnbY)^WIoH5-tFHdcb)7%+nX}2&(v5z_Oe(Z(Y<;sIerUqj~I!P z+`=kcf}$DSG~qA&a{1rPOV_BmD2Q}jsl69%VVEe{DN;b?81Vd;H_3}o@+@8UF@;D9 zGs6h91F^x#rJywqd(hK;1o8=d2JUy1tiK9fZB#th0Wd`%zHPP+a1aT-8Bx>+vdwYqscE%$M*+g)Ic}=5}0w*2_7inU*mC z_eZ^3qt4F$zYt09=CDNHKmMrWM`E`&)4*c0&Wt6qoiJ^x+|V)@DQTL(B-$XzBHp}Q zdP;jvS2}}+CNdQzbVPX~zbKnG`pzf5KGY+HRoIX$)ly6E>Ze$u}4%PKo=fcK$=ch10!RTJq!Axx60MdB1DYxy9+=}0srN)h6(TO9CM!<#|3 z<1=t%*Zlp|{)4yw=l4ld{y|;y*MXwHj}Y_!(t4n#1Dp4J8M?J6{e-E(_`hI%IfcketJQ zuT5nDK@yYF)w6Z`cx1epm&L`wgvH>Tikt*W*e=3C*~wzg7=z&$7!gmPJbe}(aWA_a zkkPh5ifM(#&?U1$Hm6XO+Zh!6u1JVXYX2iQ>D?>_jcYI>SaS>x@TX8Bq^r*bD@h2a zA@M0Q#DQX%W-A*7n5|9fYXw8lAaZM+XNO6~)C(^JoSzB>9OKsL3dnUf@x2U6THBex zGi;XV&&oO#)Tt~Jm6h~(WH`yWYQtC0Ja6sS3GiEe-M@zEAG}3Ss^%JGNOk>rihgyB z>pNq*oqi35e{#C}XS?fv_w^)5?gM#(WB;&J7kyJ7syT7#@X9wwLBOy~*F*BO%_fcg zS_H361=t`u=lD!7vtyqu8L^zkdtw}5r1u{=eUWF=vz=0&kh(WpMaY^O$O~g;8XRts z!`Wj8g?qiUbkMZ?syGd0yl9GXvdA!z7NbmaN(Bj0^0S}Ldr5@}zO`67dp0uF7&$y> z&12NiFU>iqXZ_^0^CvEaPx3HLBcUsO^A;~STm&%Fpoh8H_98Ov=JcES!1CCz# zalC3OOnGV8h=R)T`=q2{G1Tpwo1P5Uh=RJ*>vB(KcEhuBaqWtaVlGg+!M{W9204kx zptJiFE#4KTiGK@vV;U==D0jXr2O_>7AQcc@;i_e;|881=no>heaiQFU{s7v8{amE1 zUX|jTTsbCVDdH59k|FA6GPoT5`Ki*}bd7`E3-r5IPPK3EHN!CSu^DWl(_6?7cY?aa z&Q$n$4!fPD4hrBb>VLvthu9-XW^1}@sV8sPr zVaGuO*jmy#hZz2|2?Wl{iZsfbf-!y2iqG^0YkC8xJsXDkOcD;f(Nl24DxQCA?FS+H_K!u(p`Lcz5KTFG z6lEG=S^mia-in=}%xftAMT3OF$I9j0XmXUbGQ8EOsP8y`{9g2RrS*&}--OC*y_wpK zr+xI+c+WBs7n0J3>J6rCp^AAT+`9DptWL%wQ(o8UDsIW0JPWVm^b!C&W!vD-+a+*( zM9gAMrvyB9+EzdKHJB#b1;(0O4X*9krpMHbE&h?oygRS-GZR!Vd&k{>H_R)$Y(QdEl!N>k>hI`*QW3 zkItcgrtyK>x)wLi4oVA$LLfHRZrcBlm>oabh@JhYl-tE+FWtV+u-0TVDV>$MjrYET zQfZXkNTQsQ(rE$yV~FdIQJ})H6Zwywu8RmJCK_EiF10qTC}bU%svDWOI5MXZIhz_SZQ_$Dp12OtJp`ll%_s4#*yyz?D^lc7uXq|MUXlm2@ zQ7r^F1Sf#q&R37F#Y1GXWa5oFZ>0a2)91HU!aGp%KCKHB<{?Olf)%=UcWZH{ zQ04YWIB0P`%cif3G&sc5RLvRtn8OpG?$eQgSwQp3P2Z{zlk>H0-ggY&EHYi&ZkIZ|o;f|U9(mfB0krOvwO3gb%+ zX%csbxTJWV;}*Axv*q|j>(C3%sGqo_njjFI0JfZUdMWPU?mXuY&pIWBy;eAE1zsnY zWs?F;;9v(a{M(%_koJ735qp9sO`T$qL8?kC>p1q5Gs$k$Px3gg`0+Y;ZrwC|C4cX@ zIj49~IhAAXb1m+J<7Hu%Hy5~+@0E_DZzLTc+%4YGGe*zShKy>|Iszpsnk>hK#) zeSEBY!Gu<5VosqW(Ibkj=s@X#HznA5wlX~R9(7GjxX78K<}$fd1Q>^nesi!cHLzKN zt>MGG;2H1Vil%>{ak^@!sNe3?PJ4g+k>nB9mlb~FgPnK#$!Dop#9QE*kcvp%={p4c z0(Qb5%UA;4f?)8bUg;n%WNqogydC(CB*8YlnsIsuy?JBz!?{(tv>!{zC%rs+pM$dg z4zXFG1YNM0)RDd8a6w6l?~ubm!@!({H!$w3t!UT-J+n`@4o=n{CP==^bv+u~$aVO)dSFbjxh}B-vT2}7c#mukChEmEgEUO^OSCQkRGhk z&&s}}^0!Ak&|G9SX)f&w#=l_F%u5?vAAZPK z6AhqWz3^4`WvDv19VY6P&^Sq-Y~}YtW6nErreNZvazxQf3ulGhp71*)A?e}!t?Yw5 z;743FRs?H2)QK1$|9GU^s!nEqRa7t5mpe_VA>$3|?#T;HRxUTMTGA>2M6-i zRa*V6w2uuLTZL1_Q?n=c{CE_1&mDhc`X4YKYkLr2 zaex@7?;Ji&_W;bYe*)yaSEVRTR&&1$AN%ZJnT+c_BtcRb4%@$&rZATNQw6Ipgkpc8 zH8B!!XW#XNGqcd_VymDG>sKq3ow|!)U>g(@@#VZ7Q(LIpFdlz&)Xh1jT`cxl^6`vc}ke$CG>@^Og?*7E=XzUqMw&4k3T>#*n|HeCaF$PC0SHXePtOt~2L- z6$d{!&a3bQpNq2}5!$VunSmXxd_0;q7Q1A0v&9DBCMMgRh`pgJYZ6fV(a35} zGV!3;19f-B--of6g*)`BsoVz+|KwQn>@BU_-*iWgwaD$wedLGJHo6H=(*6|Q= zzraDSq}}nxC+g;DnjvRWle()_5%ZdIlcL(rxzvL{jTc+)r(&g-fj0;Ao4`kX{Tb&2 zC~%{vZyRQ_y_oYl#ANy~Y=6@v*!7T362lh;Iu3P2e+apgjZko0^3l$xE|o2dOl?jW zr;K-?NY##gx8#P#R2DGFLD%_G?{ql;x^>9%@ikd8J?p^J!rgd&DaoXxT$nmsWu_J~ zB`9GBe0VrMOKP#6_pp2kw%G0Hos1LKeltw1B^6zBIJ*R=`n@B{BT~NTXn$Z+VieF-}A5j!J~>@4PtaJI>ADh$d3%O1Es=DWnyT8LYfOXzffttr=*f zK7mwdl{`*dJJlvi{3+l>N)bmH%+iYmA10VGtC8Y+6goP##p6^rp^R>;mRlvw%YQ3y zEmaBTtPF8zOXyzDl#bm&6?p3z=*9+4+mIXSJ?*NPe)#UG!1?Fb*g5DtBf~i6H*cS& zr;?@%S^wq5eu{WHLfSXDOGLWQ^Qdyx;du^bt>A)lZJ#=!QBCeFYDfb~E(e*`D}7iHOVQ%iV9j(Pr~+_W#Ti-*0HY&>RO!Q~=PV?9#o1Ze zF9(y>DG#-Cf0Tr20X}`Q5KH?j#H9zlc@~P6flURXjx!xptskI^$c7bKrQ$Xw!eVDe zL9e@?Kg;Lb!}L8|jor9&eQk>O(^PtTRgChb;vQ*CR-tP1U)#@$1#&w@AG?JKnJ@Mt zr}9NB%8cQ62~EyVOZeGOuQk<%(2~c@akueTyn5YjRnEiH9y0C6sg_kB2GJ8p?I@@= ztEGHOUc6p$j;=X(c{f|&l5j^-iR3~NSD(3A;fY+6O16R2rSM~)a<*L69Ct#~#M~>4 zXZP12|^1A?RC%{UH&Y4M*@z+Hvk^!|BVb z7A(Oy-hZW_w-&~JL5+U+%6jZWzZVR-U)^HX*Cb>)noHH!S5uoNZv%!wKZc$@8qP+M zsDG3J76S|!e;x4l9L=N;G%FCbr}zD~c+fZSnJ%BcpZ_Ff{j^v7^z;3P-X96XWd5gV z0Xfdw7$69t83q|CpTy^6{TH(x}nV!omh= zwzD`v_w7IEWAuGxE>_b7GAubTcC*b7iRZzi97Wc=<+r7le432M;)uHs5iq*4Lf`{cn!o;7NP^0}hEpDRD z%HV|h6&g4j=4LqhB>iT0+n25=(hAdYomrHdm(txqW?D%fOpLadwXRiv>4hHgLZ$eg z5`OP^HlfQb&^&?O78`se+z)Ao1EZ5yD0uRYIZmn~>Cm0%(!ne87svA*(UW(gjh;V3 z>SqfVxoHQQ*Gih=MhN3&IgN<;8nk1Va&zONOq8=H^P(|i_<}{rY805kFec+FrPlr# zUQ_-wmqOn@62?#JGe{xuE&LaNC33jBF+Rt1N@=wqayb0Zz5ZZ(_mn6geiK`X*aJRp z{{ zoA~e+LArxN);3-wYaj!Ak+tpVhD08WAm}hv_*kSbe!;;rBETn5|7>;rnIy8IS6zN7 zTYaEUtIoIy=-9(@rWd@e=$~bhLaoEM$a5;AG6h_&Pojq>t5)*g_{?NZ6dwM5`u)i#5LCQ$eS#_2Ov`RaZ=snpPo~o(V5zQ{ zy}$zpR=&1p*-amrcV%~|R-OK2M?)~u`u*d(lCMMc{mwJ(u|)b;3-~X-q`LheTu}o| zX#~p+@4mo*%zR1Da!LJI2y&vAAxbV~o9YzAp076Kf=MtgrP$4FsaG~>3Xh+)WzV`G`22V5_bJz-} zT7Fl~m@3O|ILVY5pcbwa#(THQkQLdlIs4Zud6_YZ^8!T$R1wf>G{P^f+p zYy=sIx13HT55bTiog?;3+IhWl{)c|~Wveps!XT*IfeqzPdar+Qka4qfTyNGF+sbzu zQMS6UW3IyN%N5THZJUcxIpuEBhJ?MGEwYb)I;R)rK_fV=-Wv|8d1n_AHZqWk=A-ngFSsV1pIUGt6Gz#)1jHzW2 ztd@1iCF0l>^`=leb{iu?11pi(d^+ct5oRCAGEy*Ktvd(O~1s27M}C$@maI| z(-!=Hii6^W7Z^SENcP!i?=mZ?K9a5W6rT$tPbSgv`0W5EmAsbH&2mRZ-&enYZR10;qc_CT|_a*}7mB8{K`W#yVcwg%`>$;h-sn zyNsOl6duPojmYcOYF>P`NGe|Fv}1GI5ivfEB0xIPfR zE5DT{50p86{ki2j6FOSa4GAz6&2g@(IuWD$!no#v79EJ$3GWmo+w&E}n zw6FlZilDfPaFNeYxbBVqD30U-*SiVto?R|jERL$=TD!D~mg>9C;D3gf$H3KLb(`6}lf6pn8^f2i;Y$R6=F z|HpHPPfTb(t0qeqhO;2x%ZW~s_bew9pPgL&hO*R-g|}*DXKuLp>!MM-x-V~awH2k_ z^M7%m>#(g?Vg4dY{mW2(?V3zR_B;vn#Hvx+$Rj+xwQpRFQfZp#Q(^u?mHw#mOFR`g zl~`O@Aosqizra6!{>&N!}GkE;Q*E7z=JM7#g7s!Tv zf`iKiAv7aaE>E^zgDOUsdB~9(3nxF!D_|GrCCbRyWxe5 zfRFEiC#Tw?W>wh-B_ZCBd!~b3rr#lJea|vr^{&H58JOJVQS+LU?ZStp4`{n=)1%%7 zL{>DZAN!KFG#(DV%QtE~JYtrd#)D_5m!Av7^=FLSUX@=Z%G)tzTL?F2m%S40)tn{| zdmoh@yUnH`Y24lL>RoP;ySxT`WDij~fe?LkZmfe6-wjvCLl66;01+Ip*XNY6z<#mY zmo_^+V#V{pcz@t(a&zVStvhw}b;7Pg>Y94?-gD*VmY?iH7@aFB*K;;;dgkFv?|ONY z9`TY$d)Sb7-Zh$UZ|4xn>%(PC|3YT2)1Fg&4h-uAXi87Jcl~}c{0z01DAz=S)N!*VM_!87O?c9C1Kk1RPPNB0a%I_oI9RA%IZ*Ibx0q zHbve}wfayQRNZ~p{UW76K6M#@EW8`8Qb&1`qivHW!jkKbhAsahr2se2{)&SuHE-?~ z&%1(hZ2;o2MC)0ThjzvdOR78#S)bOMiFnUgFm8wz<1}ZdZCcg(kfP(O*ey1snrHv~ zk!x=h(k7XHZ+I)CEBt8nU~nYfwaL7H0KcS4UVFcvat2B$OCNgW&V410xAB!ryw)lw z_K!!)1hs_U+k`x_k6~_eLrx@I71A7IS?n=wE}>l*%kJF6 zbfHR(KIB7~&#Q?JO>)ES+sbnqo*5Z=R0V6w@oZ{q%39p}QM>&F>65qb2PGcB&QA_u z^jSe80ezh8>jB)sRh=IgodE5$jqpVS^2X1WM+Z-VBT6r`dyJ;+?*b=06~2CtRiM{v zhfX9B$rR@Z7s?XA;13X zJ0X9b3vaunl15S}CxWUy1I_Kyx%+WLC8P7Pc4Pt^2%n&bETSB;bSSC)9Mnd6Xx};n zoY5dr4orzP{q~vR@rL&Gu#KFv2tIKb4aeDjV+ zVq)ISVWue{=O5)~D6#<=nz6(m3OXRv|2ckDoWpX{Aj$kHEh&7W5M?b`Arz$J4oA|xgPv}{8BT|O)IWl>CrUX0W0XnQC9{7YS zIEVe-b|)>z1qOqfdL<#tHDcLrj4P{HAD*-c6R*gE>~8}{FAg;4bM~%&%ik?1k!&^V zv|1`v`qs6;BaYW~Pm4fz335n;%2m5oX+hz%#wmC(|2p3|a_AM$M!X)4JE)UuiQ4

u?J}D~y+p`Iooy!a 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