SIENTIAPDE-1645: code snapshot (part 1)
This commit is contained in:
93
.dockerignore
Normal file
93
.dockerignore
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# WHITELIST APPROACH: Block everything by default, then allow only what's needed
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Block everything first
|
||||||
|
*
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ALLOW: Application source code (model_manager package)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Allow the main package directory and all Python files
|
||||||
|
!model_manager/
|
||||||
|
!model_manager/**/*.py
|
||||||
|
!model_manager/**/__init__.py
|
||||||
|
|
||||||
|
# Allow subdirectories structure
|
||||||
|
!model_manager/activities/
|
||||||
|
!model_manager/activities/**
|
||||||
|
!model_manager/schedules/
|
||||||
|
!model_manager/schedules/**
|
||||||
|
!model_manager/sientia/
|
||||||
|
!model_manager/sientia/**
|
||||||
|
!model_manager/utils/
|
||||||
|
!model_manager/utils/**
|
||||||
|
!model_manager/utils/models/
|
||||||
|
!model_manager/utils/models/**
|
||||||
|
!model_manager/utils/repository/
|
||||||
|
!model_manager/utils/repository/**
|
||||||
|
!model_manager/worker/
|
||||||
|
!model_manager/worker/**
|
||||||
|
!model_manager/workflows/
|
||||||
|
!model_manager/workflows/**
|
||||||
|
|
||||||
|
# Allow reports directory with header.html
|
||||||
|
!model_manager/reports/
|
||||||
|
!model_manager/reports/header.html
|
||||||
|
|
||||||
|
# Allow temp directory structure (but not its contents)
|
||||||
|
!model_manager/reports/temp/
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ALLOW: Dependencies file (needed for pip install in Dockerfile)
|
||||||
|
# ============================================================================
|
||||||
|
!requirements.txt
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# BLOCK: Explicitly block unwanted files even if they match above patterns
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Python cache and compiled files
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
|
**/*.pyo
|
||||||
|
**/*.pyd
|
||||||
|
**/.Python
|
||||||
|
**/*.so
|
||||||
|
**/*.egg
|
||||||
|
**/*.egg-info/
|
||||||
|
|
||||||
|
# Tests (not needed in production)
|
||||||
|
model_manager/**/test_*.py
|
||||||
|
model_manager/**/*_test.py
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
**/.vscode/
|
||||||
|
**/.idea/
|
||||||
|
**/*.swp
|
||||||
|
**/*.swo
|
||||||
|
**/*~
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
**/.DS_Store
|
||||||
|
**/Thumbs.db
|
||||||
|
|
||||||
|
# Logs and temporary files
|
||||||
|
**/*.log
|
||||||
|
**/*.tmp
|
||||||
|
**/*.temp
|
||||||
|
|
||||||
|
# Local configuration
|
||||||
|
**/.env
|
||||||
|
**/.env.local
|
||||||
|
**/*.local
|
||||||
|
|
||||||
|
# Documentation inside code
|
||||||
|
**/*.md
|
||||||
|
**/README*
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
**/*.bak
|
||||||
|
**/*.backup
|
||||||
|
**/*.old
|
||||||
57
.env.example
Normal file
57
.env.example
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
POSTGRES_HOST=localhost
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=changeme
|
||||||
|
POSTGRES_DBNAME=sientia
|
||||||
|
POSTGRES_MIN_CONNECTIONS=10
|
||||||
|
POSTGRES_MAX_CONNECTIONS=30
|
||||||
|
|
||||||
|
MLFLOW_URL=http://localhost:5080
|
||||||
|
MLFLOW_USERNAME=aignosi
|
||||||
|
MLFLOW_PASSWORD=changeme
|
||||||
|
|
||||||
|
LOG_LEVEL=DEBUG
|
||||||
|
HTTP_METRICS_PORT=9090
|
||||||
|
HTTP_SDK_METRICS_PORT=9091
|
||||||
|
PROJECT_NAME=sientia-model-manager
|
||||||
|
|
||||||
|
TEMPORAL_HOST=localhost:7233
|
||||||
|
TEMPORAL_NAMESPACE=model-manager
|
||||||
|
TEMPORAL_USE_TLS=false
|
||||||
|
RUNTIME=single
|
||||||
|
|
||||||
|
MONGODB_USERNAME=root
|
||||||
|
MONGODB_PASSWORD=changeme
|
||||||
|
MONGODB_URL=localhost:27017
|
||||||
|
MONGODB_DATABASE=sientia
|
||||||
|
MONGODB_TTL_INDEX_HOURS=1
|
||||||
|
|
||||||
|
MINIO_ENDPOINT_URL=http://localhost:9000
|
||||||
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
|
MINIO_SECRET_KEY=minioadmin
|
||||||
|
MINIO_DEFAULT_BUCKET=model-training
|
||||||
|
MINIO_SECURE=false
|
||||||
|
|
||||||
|
STORE_BASE_URL=http://localhost:3000
|
||||||
|
STORE_OWNER=aignosi
|
||||||
|
STORE_REPO=suse-model-store
|
||||||
|
STORE_USERNAME=
|
||||||
|
STORE_PASSWORD=
|
||||||
|
STORE_CACHE_TTL_SECONDS=3600
|
||||||
|
PYPI_SERVER=http://localhost:5000
|
||||||
|
PYPI_USERNAME=
|
||||||
|
PYPI_PASSWORD=
|
||||||
|
|
||||||
|
TIMEOUT_VALIDATE_PARAMS=30
|
||||||
|
TIMEOUT_TRAIN_MODEL=2700
|
||||||
|
TIMEOUT_DELETE_FILE=120
|
||||||
|
TIMEOUT_UPDATE_DATABASE=30
|
||||||
|
|
||||||
|
CLEANUP_RETENTION_HOURS=24
|
||||||
|
CLEANUP_DRY_RUN=false
|
||||||
|
TIMEOUT_CLEANUP_LOCAL=120
|
||||||
|
|
||||||
|
CLEANUP_SCHEDULE_ID=cleanup-files-daily
|
||||||
|
CLEANUP_CRON="0 0 * * *"
|
||||||
|
CLEANUP_TIMEZONE=UTC
|
||||||
|
CLEANUP_EXECUTION_TIMEOUT_HOURS=1
|
||||||
33
.github/workflows/deploy.yml
vendored
Normal file
33
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: Deploy Python Application
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- closed
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'release/**'
|
||||||
|
- 'feature/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy
|
||||||
|
if: github.event.pull_request.merged == true
|
||||||
|
permissions: write-all
|
||||||
|
uses: Aignosi/github_workflow_templates/.github/workflows/reusable-deploy.yml@main
|
||||||
|
with:
|
||||||
|
branch_name: ${{ github.event.pull_request.head.ref }}
|
||||||
|
project_type: 'python'
|
||||||
|
image_name: 'sientia-dataops-model-manager'
|
||||||
|
helm_chart_path: 'sientia-module'
|
||||||
|
helm_release_name: 'sientia-dataops-model-manager'
|
||||||
|
helm_namespace: 'sientia'
|
||||||
|
update_version_in: '["pyproject", "values"]'
|
||||||
|
app_owner: 'Aignosi'
|
||||||
|
private_repos: 'sientia-dataops-library'
|
||||||
|
helm_repo_name: 'sientia'
|
||||||
|
helm_repo_url: 'https://raw.githubusercontent.com/Aignosi/sientia-dataops-helm-repo/refs/heads/main/'
|
||||||
|
helm_chart_version: '0.6.0'
|
||||||
|
helm_values_file: './values.yaml'
|
||||||
|
use_vpn: true
|
||||||
|
secrets: inherit
|
||||||
18
.github/workflows/quality-gate.yml
vendored
Normal file
18
.github/workflows/quality-gate.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
name: Quality gate
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'release/**'
|
||||||
|
- 'feature/**'
|
||||||
|
types: [ opened, synchronize, reopened ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality-gate:
|
||||||
|
uses: Aignosi/github_workflow_templates/.github/workflows/python-quality-gate.yml@main
|
||||||
|
permissions: write-all
|
||||||
|
with:
|
||||||
|
project_name: 'model_manager'
|
||||||
|
repositories: 'sientia-dataops-library,sientia-model-library'
|
||||||
|
secrets: inherit
|
||||||
258
.gitignore
vendored
Normal file
258
.gitignore
vendored
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[codz]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Code quality tools cache
|
||||||
|
.bandit/
|
||||||
|
validate.txt
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
#poetry.toml
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||||
|
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||||
|
#pdm.lock
|
||||||
|
#pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# pixi
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||||
|
#pixi.lock
|
||||||
|
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||||
|
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||||
|
.pixi
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.envrc
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
venv_311/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Abstra
|
||||||
|
# Abstra is an AI-powered process automation framework.
|
||||||
|
# Ignore directories containing user credentials, local state, and settings.
|
||||||
|
# Learn more at https://abstra.io/docs
|
||||||
|
.abstra/
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||||
|
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||||
|
# you could uncomment the following to ignore the entire vscode folder
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Ruff stuff:
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
|
||||||
|
# Cursor
|
||||||
|
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
||||||
|
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
||||||
|
# refer to https://docs.cursor.com/context/ignore-files
|
||||||
|
.cursorignore
|
||||||
|
.cursorindexingignore
|
||||||
|
|
||||||
|
# Marimo
|
||||||
|
marimo/_static/
|
||||||
|
marimo/_lsp/
|
||||||
|
__marimo__/
|
||||||
|
|
||||||
|
# Ignore Docker volumes
|
||||||
|
docker-compose.override.yml
|
||||||
|
**/db_data/
|
||||||
|
**/kafka-volume/
|
||||||
|
**/zookeeper-volume/
|
||||||
|
**/mage_data/
|
||||||
|
**/minio_data/
|
||||||
|
**/venv/
|
||||||
|
**/certs/*.pem
|
||||||
|
**/certs/*.der
|
||||||
|
**/certs/*.csr
|
||||||
|
**/deploy/*.yaml
|
||||||
|
scouter/.file_versions/
|
||||||
|
scouter/pipelines/**/triggers.yaml
|
||||||
|
**/postgres_data/**
|
||||||
|
|
||||||
|
# Ignore Python cache files
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
|
||||||
|
# Ignore temporary files
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Ignore test run reports in model_manager/reports/temp (but keep temp folder and .gitkeep)
|
||||||
|
model_manager/reports/temp/*
|
||||||
|
!model_manager/reports/temp/.gitkeep
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
git_key*
|
||||||
|
git_log
|
||||||
|
tmp/
|
||||||
|
sientia-module/
|
||||||
|
.secrets
|
||||||
|
.event.json
|
||||||
|
|
||||||
|
models/
|
||||||
|
.cursor
|
||||||
|
openspec
|
||||||
|
|
||||||
|
# Training smoke test: track JSON inputs, ignore local sample CSVs
|
||||||
|
input_dataset.csv
|
||||||
|
scripts/**/*.csv
|
||||||
|
!scripts/inputs/
|
||||||
|
.claude/
|
||||||
78
Dockerfile
Normal file
78
Dockerfile
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Multi-stage build for optimized Python application
|
||||||
|
FROM python:3.11-slim AS builder
|
||||||
|
|
||||||
|
# Set build-time environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
# Install build dependencies only
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* && \
|
||||||
|
apt-get clean
|
||||||
|
|
||||||
|
# Configure SSH to trust GitHub host key
|
||||||
|
RUN mkdir -p ~/.ssh && \
|
||||||
|
ssh-keyscan -t rsa github.com >> ~/.ssh/known_hosts && \
|
||||||
|
chmod 600 ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
RUN python -m venv /opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
# Upgrade pip and wheel for better caching
|
||||||
|
RUN pip install --upgrade pip setuptools wheel
|
||||||
|
|
||||||
|
# Copy requirements files for better Docker layer caching
|
||||||
|
COPY requirements.txt ./
|
||||||
|
|
||||||
|
# Install only production dependencies with no cache
|
||||||
|
RUN --mount=type=ssh echo "=== Installing dependencies ===" && \
|
||||||
|
pip install --no-cache-dir -r requirements.txt && \
|
||||||
|
echo "=== Dependencies installed successfully ===" && \
|
||||||
|
pip list | wc -l && \
|
||||||
|
echo "=== Cleaning cache files ===" && \
|
||||||
|
find /opt/venv -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true && \
|
||||||
|
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true && \
|
||||||
|
rm -rf /root/.cache/pip/* && \
|
||||||
|
echo "=== Cleaning venv site-packages ===" && \
|
||||||
|
find /opt/venv/lib/python3.11/site-packages/ -type f -name "*.md" -delete 2>/dev/null || true && \
|
||||||
|
echo "=== Stripping .so files ===" && \
|
||||||
|
find /opt/venv -name "*.so" -exec strip {} + 2>/dev/null || true
|
||||||
|
|
||||||
|
# Production stage using python-slim for better functionality
|
||||||
|
FROM python:3.11-slim AS production
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PATH="/opt/venv/bin:$PATH" \
|
||||||
|
POD_ID=unknown \
|
||||||
|
HOME="/app"
|
||||||
|
|
||||||
|
# Copy virtual environment from builder stage
|
||||||
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create necessary directories for runtime file creation
|
||||||
|
RUN mkdir -p /app/model_manager/reports /app/logs /app/temp /app/models /app/data && \
|
||||||
|
chmod 755 /app/model_manager/reports /app/logs /app/temp /app/models /app/data
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN groupadd -r appuser && useradd -r -g appuser appuser && \
|
||||||
|
chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Set entrypoint for proper signal handling and PID 1
|
||||||
|
ENTRYPOINT ["/opt/venv/bin/python", "-m", "model_manager.worker.worker"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 aignosi
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
227
PIPELINE_PARAMS_CHANGELOG.md
Normal file
227
PIPELINE_PARAMS_CHANGELOG.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# Changelog de Parâmetros do Pipeline de Treinamento
|
||||||
|
|
||||||
|
Este documento descreve as alterações nos parâmetros de entrada do pipeline Temporal para treinamento de modelos.
|
||||||
|
|
||||||
|
## Resumo das Alterações
|
||||||
|
|
||||||
|
### Parâmetros ALTERADOS (Breaking Changes)
|
||||||
|
|
||||||
|
| Parâmetro | Tipo Anterior | Tipo Novo | Descrição |
|
||||||
|
|-----------|---------------|-----------|-----------|
|
||||||
|
| `lag_train` | `int` | `dict[str, int]` | Agora é um dicionário com lag por variável. Ex: `{"var1": 2, "var2": 3}` |
|
||||||
|
| `lag_val` | `int` | `dict[str, int]` | Agora é um dicionário com lag por variável. Ex: `{"var1": 1, "var2": 1}` |
|
||||||
|
|
||||||
|
### Parâmetros NOVOS (Obrigatórios)
|
||||||
|
|
||||||
|
| Parâmetro | Tipo | Descrição | Valores Válidos |
|
||||||
|
|-----------|------|-----------|-----------------|
|
||||||
|
| `model_name` | `str` | Nome do tipo de modelo | `"Linear Regression"`, `"Polynomial Regression"` |
|
||||||
|
| `degree` | `int` | Grau do polinômio (1 = linear) | `>= 1` |
|
||||||
|
| `interaction_only` | `bool` | Apenas termos de interação para polinomial | `true`, `false` |
|
||||||
|
| `nan_treatment` | `str` | Tratamento de valores NaN | `"drop"`, `"linear interpolation"`, `"fill linear"` |
|
||||||
|
| `scaler_name` | `str` | Nome do scaler a usar | `"Standard Scaler"`, `"None"` |
|
||||||
|
|
||||||
|
### Parâmetros NOVOS (Opcionais)
|
||||||
|
|
||||||
|
| Parâmetro | Tipo | Descrição | Default |
|
||||||
|
|-----------|------|-----------|---------|
|
||||||
|
| `start_date` | `str \| null` | Data inicial para filtrar dados (formato: `"YYYY-MM-DD HH:MM:SS"`) | `null` |
|
||||||
|
| `end_date` | `str \| null` | Data final para filtrar dados (formato: `"YYYY-MM-DD HH:MM:SS"`) | `null` |
|
||||||
|
| `support_filters` | `dict \| null` | Filtros customizados por variável | `{}` |
|
||||||
|
| `static_threshold` | `int \| null` | Threshold para remoção de janelas estáticas (1-1000). Só usado quando `rem_static_win` é `true`. | `1` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exemplo de Input Completo
|
||||||
|
|
||||||
|
### Formato ANTERIOR (não funciona mais):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"experiment_run_id": 123,
|
||||||
|
"target_variable": "temperatura",
|
||||||
|
"variable_columns": ["pressao", "umidade", "velocidade"],
|
||||||
|
"lag_train": 2,
|
||||||
|
"lag_val": 1,
|
||||||
|
"rem_static_win": true,
|
||||||
|
"low_lim": {"pressao": 0, "umidade": 0, "velocidade": 0},
|
||||||
|
"upp_lim": {"pressao": 100, "umidade": 100, "velocidade": 50},
|
||||||
|
"window": 0,
|
||||||
|
"use_scaler": true,
|
||||||
|
"include_ar": false,
|
||||||
|
"bucket_name": "training-data",
|
||||||
|
"file_name": "dataset.csv",
|
||||||
|
"line_separator": ";",
|
||||||
|
"decimal_separator": ",",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": false,
|
||||||
|
"experiment_name": "modelo-temperatura",
|
||||||
|
"removed_intervals": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Formato NOVO (obrigatório):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"experiment_run_id": 123,
|
||||||
|
"target_variable": "temperatura",
|
||||||
|
"variable_columns": ["pressao", "umidade", "velocidade"],
|
||||||
|
|
||||||
|
"lag_train": {
|
||||||
|
"pressao": 2,
|
||||||
|
"umidade": 2,
|
||||||
|
"velocidade": 2
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"pressao": 1,
|
||||||
|
"umidade": 1,
|
||||||
|
"velocidade": 1
|
||||||
|
},
|
||||||
|
|
||||||
|
"rem_static_win": true,
|
||||||
|
"static_threshold": null,
|
||||||
|
"low_lim": {"pressao": 0, "umidade": 0, "velocidade": 0},
|
||||||
|
"upp_lim": {"pressao": 100, "umidade": 100, "velocidade": 50},
|
||||||
|
"window": 0,
|
||||||
|
"use_scaler": true,
|
||||||
|
"include_ar": false,
|
||||||
|
"bucket_name": "training-data",
|
||||||
|
"file_name": "dataset.csv",
|
||||||
|
"line_separator": ";",
|
||||||
|
"decimal_separator": ",",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": false,
|
||||||
|
"experiment_name": "modelo-temperatura",
|
||||||
|
"removed_intervals": [],
|
||||||
|
|
||||||
|
"model_name": "Linear Regression",
|
||||||
|
"degree": 1,
|
||||||
|
"interaction_only": false,
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"scaler_name": "Standard Scaler",
|
||||||
|
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null,
|
||||||
|
"support_filters": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exemplo para Polynomial Regression
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"experiment_run_id": 124,
|
||||||
|
"target_variable": "temperatura",
|
||||||
|
"variable_columns": ["pressao", "umidade"],
|
||||||
|
|
||||||
|
"lag_train": {
|
||||||
|
"pressao": 0,
|
||||||
|
"umidade": 0
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"pressao": 0,
|
||||||
|
"umidade": 0
|
||||||
|
},
|
||||||
|
|
||||||
|
"rem_static_win": false,
|
||||||
|
"low_lim": {"pressao": 0, "umidade": 0},
|
||||||
|
"upp_lim": {"pressao": 100, "umidade": 100},
|
||||||
|
"window": 0,
|
||||||
|
"use_scaler": true,
|
||||||
|
"include_ar": false,
|
||||||
|
"bucket_name": "training-data",
|
||||||
|
"file_name": "dataset.csv",
|
||||||
|
"line_separator": ";",
|
||||||
|
"decimal_separator": ",",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": false,
|
||||||
|
"experiment_name": "modelo-polinomial",
|
||||||
|
"removed_intervals": [],
|
||||||
|
|
||||||
|
"model_name": "Polynomial Regression",
|
||||||
|
"degree": 2,
|
||||||
|
"interaction_only": false,
|
||||||
|
"nan_treatment": "linear interpolation",
|
||||||
|
"scaler_name": "Standard Scaler",
|
||||||
|
|
||||||
|
"start_date": "2024-01-01 00:00:00",
|
||||||
|
"end_date": "2024-12-31 23:59:59",
|
||||||
|
"support_filters": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exemplo com Intervalos Removidos
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"removed_intervals": [
|
||||||
|
["2024-03-01 00:00:00", "2024-03-15 23:59:59"],
|
||||||
|
["2024-06-01 00:00:00", "2024-06-30 23:59:59"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parâmetros Logados no MLflow
|
||||||
|
|
||||||
|
Os seguintes parâmetros são agora logados no MLflow:
|
||||||
|
|
||||||
|
| Parâmetro MLflow | Descrição |
|
||||||
|
|------------------|-----------|
|
||||||
|
| `model_name` | Nome do modelo (`Linear Regression` ou `Polynomial Regression`) |
|
||||||
|
| `models_params` | `{"degree": int, "interaction_only": bool}` |
|
||||||
|
| `target_variable` | Variável alvo |
|
||||||
|
| `input_variables` | Lista de variáveis de entrada |
|
||||||
|
| `nan_treatment` | Tratamento de NaN |
|
||||||
|
| `lag_train` | Dicionário de lags para treino |
|
||||||
|
| `lag_transform` | Dicionário de lags para transformação |
|
||||||
|
| `static_threshold` | Threshold para janelas estáticas (1 ou null) |
|
||||||
|
| `lower_limits` | Limites inferiores por variável |
|
||||||
|
| `upper_limits` | Limites superiores por variável |
|
||||||
|
| `scaler_name` | Nome do scaler |
|
||||||
|
| `scaler_params` | Parâmetros do scaler (mean, variance) |
|
||||||
|
| `include_ar` | Se inclui variável autoregressiva |
|
||||||
|
| `train_size` | Proporção de treino (0.0 - 1.0) |
|
||||||
|
| `test_size` | Proporção de teste (0.0 - 1.0) |
|
||||||
|
| `start_date` | Data inicial (ou null) |
|
||||||
|
| `end_date` | Data final (ou null) |
|
||||||
|
| `removed_intervals` | Lista de intervalos removidos |
|
||||||
|
| `retrain` | Sempre `false` para novos modelos |
|
||||||
|
| `support_filters` | Filtros customizados |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validações de Negócio
|
||||||
|
|
||||||
|
O sistema valida automaticamente:
|
||||||
|
|
||||||
|
1. **`train_size`**: Deve estar entre 10 e 100
|
||||||
|
2. **`variable_columns`**: Não pode estar vazio
|
||||||
|
3. **`lag_train` / `lag_val`**: Todos os valores devem ser >= 0
|
||||||
|
4. **`window`**: Deve ser >= 0
|
||||||
|
5. **`degree`**: Deve ser >= 1
|
||||||
|
6. **`nan_treatment`**: Deve ser `"drop"`, `"linear interpolation"` ou `"fill linear"`
|
||||||
|
7. **`scaler_name`**: Deve ser `"Standard Scaler"` ou `"None"`
|
||||||
|
8. **`model_name`**: Deve ser `"Linear Regression"` ou `"Polynomial Regression"`
|
||||||
|
9. **`low_lim` / `upp_lim`**: Devem ter as mesmas chaves, e `low_lim[var] < upp_lim[var]`
|
||||||
|
10. **`bucket_name` / `file_name` / `experiment_name`**: Não podem estar vazios
|
||||||
|
11. **`degree` vs `model_name`**: Se `model_name` = "Polynomial Regression", `degree` deve ser >= 2; se "Linear Regression", `degree` deve ser = 1
|
||||||
|
12. **`removed_intervals`**: Cada elemento deve ser lista/tupla com pelo menos 2 elementos (start, end)
|
||||||
|
13. **`target_variable`**: Não pode estar vazio
|
||||||
|
14. **`static_threshold`**: Se `rem_static_win` = `true` e `static_threshold` tiver valor, deve estar entre 1 e 1000 (inclusive). Se `null`, assume valor `1`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquivos Modificados
|
||||||
|
|
||||||
|
- `model_manager/sientia/models.py` - `DataPreprocessor` e `LinearRegressionModel`
|
||||||
|
- `model_manager/sientia/metrics.py` - Funções RCE adicionadas
|
||||||
|
- `model_manager/utils/models/train_model_params.py` - Novos parâmetros
|
||||||
|
- `model_manager/utils/repository/training_repository.py` - Uso dos novos parâmetros
|
||||||
|
- `model_manager/utils/repository/model_repository.py` - Logging no MLflow
|
||||||
197
dashboards/sientia-dataops-model-manager.json
Normal file
197
dashboards/sientia-dataops-model-manager.json
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
{
|
||||||
|
"annotations": { "list": [] },
|
||||||
|
"editable": true,
|
||||||
|
"fiscalYearStartMonth": 0,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Data Preparation Latency (p50 / p95)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "histogram_quantile(0.5, rate(sientia_training_data_preparation_lag_bucket[5m]))",
|
||||||
|
"legendFormat": "p50",
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "histogram_quantile(0.95, rate(sientia_training_data_preparation_lag_bucket[5m]))",
|
||||||
|
"legendFormat": "p95",
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": { "unit": "s", "color": { "mode": "palette-classic" } }
|
||||||
|
},
|
||||||
|
"options": { "tooltip": { "mode": "multi" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Model Fit Latency (p50 / p95)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "histogram_quantile(0.5, rate(sientia_training_model_fit_lag_bucket[5m]))",
|
||||||
|
"legendFormat": "p50",
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "histogram_quantile(0.95, rate(sientia_training_model_fit_lag_bucket[5m]))",
|
||||||
|
"legendFormat": "p95",
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": { "unit": "s", "color": { "mode": "palette-classic" } }
|
||||||
|
},
|
||||||
|
"options": { "tooltip": { "mode": "multi" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "Data Preparation Errors / s",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "rate(sientia_training_data_preparation_error_count_total[5m])",
|
||||||
|
"legendFormat": "{{model_name}} / {{model_type}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": { "unit": "ops", "color": { "mode": "palette-classic" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "Model Fit Errors / s",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "rate(sientia_training_model_fit_error_count_total[5m])",
|
||||||
|
"legendFormat": "{{model_name}} / {{model_type}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": { "unit": "ops", "color": { "mode": "palette-classic" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "Model Quality — MSE",
|
||||||
|
"type": "stat",
|
||||||
|
"gridPos": { "h": 4, "w": 8, "x": 0, "y": 16 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sientia_training_model_quality_mse",
|
||||||
|
"legendFormat": "{{model_name}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": { "unit": "none", "decimals": 4 }
|
||||||
|
},
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "orientation": "auto", "textMode": "auto", "colorMode": "value" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"title": "Model Quality — MAE",
|
||||||
|
"type": "stat",
|
||||||
|
"gridPos": { "h": 4, "w": 8, "x": 8, "y": 16 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sientia_training_model_quality_mae",
|
||||||
|
"legendFormat": "{{model_name}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": { "unit": "none", "decimals": 4 }
|
||||||
|
},
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "orientation": "auto", "textMode": "auto", "colorMode": "value" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"title": "Model Quality — R²",
|
||||||
|
"type": "stat",
|
||||||
|
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 16 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sientia_training_model_quality_r2",
|
||||||
|
"legendFormat": "{{model_name}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": { "unit": "percentunit", "decimals": 3, "min": -1, "max": 1 }
|
||||||
|
},
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "orientation": "auto", "textMode": "auto", "colorMode": "background" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"title": "Dataset — Train Rows",
|
||||||
|
"type": "stat",
|
||||||
|
"gridPos": { "h": 4, "w": 8, "x": 0, "y": 20 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sientia_training_dataset_train_rows",
|
||||||
|
"legendFormat": "{{model_name}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": { "unit": "short", "decimals": 0 }
|
||||||
|
},
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "orientation": "auto", "textMode": "auto", "colorMode": "value" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"title": "Dataset — Val Rows",
|
||||||
|
"type": "stat",
|
||||||
|
"gridPos": { "h": 4, "w": 8, "x": 8, "y": 20 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sientia_training_dataset_val_rows",
|
||||||
|
"legendFormat": "{{model_name}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": { "unit": "short", "decimals": 0 }
|
||||||
|
},
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "orientation": "auto", "textMode": "auto", "colorMode": "value" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"title": "Dataset — Feature Count",
|
||||||
|
"type": "stat",
|
||||||
|
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 20 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sientia_training_feature_count",
|
||||||
|
"legendFormat": "{{model_name}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": { "unit": "short", "decimals": 0 }
|
||||||
|
},
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "orientation": "auto", "textMode": "auto", "colorMode": "value" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "30s",
|
||||||
|
"schemaVersion": 38,
|
||||||
|
"tags": ["sientia", "model-manager", "training"],
|
||||||
|
"templating": { "list": [] },
|
||||||
|
"time": { "from": "now-3h", "to": "now" },
|
||||||
|
"timepicker": {},
|
||||||
|
"timezone": "browser",
|
||||||
|
"title": "Sientia DataOps Model Manager",
|
||||||
|
"uid": "sientia-dataops-model-manager",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
33
docs/DB_CV022_WIT230 _double_date_column.csv
Normal file
33
docs/DB_CV022_WIT230 _double_date_column.csv
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
DATA,DATE2,03CV022/CORRENTE_N_M1_PV(Value),303-WIT-230(Value)
|
||||||
|
01/05/2022 00:00:00,07-01-2022 01:00:00,170,33
|
||||||
|
01/05/2022 00:00:10,07-01-2022 01:00:10,169,605
|
||||||
|
01/05/2022 00:00:20,07-01-2022 01:00:20,169,178
|
||||||
|
01/05/2022 00:00:30,07-01-2022 01:00:30,166,468
|
||||||
|
01/05/2022 00:00:40,07-01-2022 01:00:40,162,136
|
||||||
|
01/05/2022 00:00:50,07-01-2022 01:00:50,157,804
|
||||||
|
01/05/2022 00:01:00,07-01-2022 01:01:00,155,883
|
||||||
|
01/05/2022 00:01:10,07-01-2022 01:01:10,155,684
|
||||||
|
01/05/2022 00:01:20,07-01-2022 01:01:20,155,484
|
||||||
|
01/05/2022 00:01:30,07-01-2022 01:01:30,155,284
|
||||||
|
01/05/2022 00:01:40,07-01-2022 01:01:40,155,85
|
||||||
|
01/05/2022 00:01:50,07-01-2022 01:01:50,154,885
|
||||||
|
01/05/2022 00:02:00,09-01-2022 02:02:00,154,685
|
||||||
|
01/05/2022 00:02:10,09-01-2022 02:02:10,154,486
|
||||||
|
01/05/2022 00:02:20,09-01-2022 02:02:20,154,286
|
||||||
|
01/05/2022 00:02:30,09-01-2022 02:02:30,154,86
|
||||||
|
01/05/2022 00:02:40,09-01-2022 02:02:40,152,866
|
||||||
|
01/05/2022 00:02:50,09-01-2022 02:02:50,150,87
|
||||||
|
01/05/2022 00:03:00,09-01-2022 02:03:00,148,874
|
||||||
|
01/05/2022 00:03:10,09-01-2022 02:03:10,148,2134
|
||||||
|
01/05/2022 00:03:20,09-01-2022 02:03:20,148,2068
|
||||||
|
01/05/2022 00:03:30,09-01-2022 02:03:30,148,2022
|
||||||
|
01/05/2022 00:03:40,09-01-2022 02:03:40,148,1976
|
||||||
|
01/05/2022 00:03:50,09-01-2022 02:03:50,154,139
|
||||||
|
01/05/2022 00:04:00,11-01-2022 03:04:00,165,112
|
||||||
|
01/05/2022 00:04:10,11-01-2022 03:04:10,170,42
|
||||||
|
01/05/2022 00:04:20,11-01-2022 03:04:20,170,116
|
||||||
|
01/05/2022 00:04:30,11-01-2022 03:04:30,170,191
|
||||||
|
01/05/2022 00:04:40,11-01-2022 03:04:40,170,266
|
||||||
|
01/05/2022 00:04:50,11-01-2022 03:04:50,170,341
|
||||||
|
01/05/2022 00:05:00,11-01-2022 03:05:00,170,416
|
||||||
|
01/05/2022 00:05:10,11-01-2022 03:05:10,170,491
|
||||||
|
647
docs/DB_CV022_WIT230.csv
Normal file
647
docs/DB_CV022_WIT230.csv
Normal file
@@ -0,0 +1,647 @@
|
|||||||
|
DATA,03CV022/CORRENTE_N_M1_PV(Value),303-WIT-230(Value)
|
||||||
|
01/05/2022 00:00:00,170,33
|
||||||
|
01/05/2022 00:00:10,169,605
|
||||||
|
01/05/2022 00:00:20,169,178
|
||||||
|
01/05/2022 00:00:30,166,468
|
||||||
|
01/05/2022 00:00:40,162,136
|
||||||
|
01/05/2022 00:00:50,157,804
|
||||||
|
01/05/2022 00:01:00,155,883
|
||||||
|
01/05/2022 00:01:10,155,684
|
||||||
|
01/05/2022 00:01:20,155,484
|
||||||
|
01/05/2022 00:01:30,155,284
|
||||||
|
01/05/2022 00:01:40,155,85
|
||||||
|
01/05/2022 00:01:50,154,885
|
||||||
|
01/05/2022 00:02:00,154,685
|
||||||
|
01/05/2022 00:02:10,154,486
|
||||||
|
01/05/2022 00:02:20,154,286
|
||||||
|
01/05/2022 00:02:30,154,86
|
||||||
|
01/05/2022 00:02:40,152,866
|
||||||
|
01/05/2022 00:02:50,150,87
|
||||||
|
01/05/2022 00:03:00,148,874
|
||||||
|
01/05/2022 00:03:10,148,2134
|
||||||
|
01/05/2022 00:03:20,148,2068
|
||||||
|
01/05/2022 00:03:30,148,2022
|
||||||
|
01/05/2022 00:03:40,148,1976
|
||||||
|
01/05/2022 00:03:50,154,139
|
||||||
|
01/05/2022 00:04:00,165,112
|
||||||
|
01/05/2022 00:04:10,170,42
|
||||||
|
01/05/2022 00:04:20,170,116
|
||||||
|
01/05/2022 00:04:30,170,191
|
||||||
|
01/05/2022 00:04:40,170,266
|
||||||
|
01/05/2022 00:04:50,170,341
|
||||||
|
01/05/2022 00:05:00,170,416
|
||||||
|
01/05/2022 00:05:10,170,491
|
||||||
|
01/05/2022 00:05:20,170,566
|
||||||
|
01/05/2022 00:05:30,170,641
|
||||||
|
01/05/2022 00:05:40,170,716
|
||||||
|
01/05/2022 00:05:50,170,79
|
||||||
|
01/05/2022 00:06:00,170,865
|
||||||
|
01/05/2022 00:06:10,170,94
|
||||||
|
01/05/2022 00:06:20,171,15
|
||||||
|
01/05/2022 00:06:30,171,9
|
||||||
|
01/05/2022 00:06:40,171,165
|
||||||
|
01/05/2022 00:06:50,171,24
|
||||||
|
01/05/2022 00:07:00,171,315
|
||||||
|
01/05/2022 00:07:10,171,39
|
||||||
|
01/05/2022 00:07:20,171,465
|
||||||
|
01/05/2022 00:07:30,171,539
|
||||||
|
01/05/2022 00:07:40,171,614
|
||||||
|
01/05/2022 00:07:50,171,689
|
||||||
|
01/05/2022 00:08:00,171,764
|
||||||
|
01/05/2022 00:08:10,171,839
|
||||||
|
01/05/2022 00:08:20,171,914
|
||||||
|
01/05/2022 00:08:30,171,989
|
||||||
|
01/05/2022 00:08:40,172,64
|
||||||
|
01/05/2022 00:08:50,172,139
|
||||||
|
01/05/2022 00:09:00,172,214
|
||||||
|
01/05/2022 00:09:10,172,288
|
||||||
|
01/05/2022 00:09:20,172,363
|
||||||
|
01/05/2022 00:09:30,172,438
|
||||||
|
01/05/2022 00:09:40,172,513
|
||||||
|
01/05/2022 00:09:50,172,588
|
||||||
|
01/05/2022 00:10:00,172,663
|
||||||
|
01/05/2022 00:10:10,172,738
|
||||||
|
01/05/2022 00:10:20,172,813
|
||||||
|
01/05/2022 00:10:30,172,888
|
||||||
|
01/05/2022 00:10:40,172,962
|
||||||
|
01/05/2022 00:10:50,172,981
|
||||||
|
01/05/2022 00:11:00,172,943
|
||||||
|
01/05/2022 00:11:10,172,905
|
||||||
|
01/05/2022 00:11:20,172,867
|
||||||
|
01/05/2022 00:11:30,172,829
|
||||||
|
01/05/2022 00:11:40,172,79
|
||||||
|
01/05/2022 00:11:50,172,752
|
||||||
|
01/05/2022 00:12:00,172,714
|
||||||
|
01/05/2022 00:12:10,172,676
|
||||||
|
01/05/2022 00:12:20,172,638
|
||||||
|
01/05/2022 00:12:30,172,6
|
||||||
|
01/05/2022 00:12:40,172,562
|
||||||
|
01/05/2022 00:12:50,172,524
|
||||||
|
01/05/2022 00:13:00,172,485
|
||||||
|
01/05/2022 00:13:10,172,447
|
||||||
|
01/05/2022 00:13:20,172,409
|
||||||
|
01/05/2022 00:13:30,172,371
|
||||||
|
01/05/2022 00:13:40,172,333
|
||||||
|
01/05/2022 00:13:50,172,295
|
||||||
|
01/05/2022 00:14:00,172,257
|
||||||
|
01/05/2022 00:14:10,172,219
|
||||||
|
01/05/2022 00:14:20,172,18
|
||||||
|
01/05/2022 00:14:30,172,142
|
||||||
|
01/05/2022 00:14:40,172,104
|
||||||
|
01/05/2022 00:14:50,172,66
|
||||||
|
01/05/2022 00:15:00,172,28
|
||||||
|
01/05/2022 00:15:10,171,99
|
||||||
|
01/05/2022 00:15:20,171,952
|
||||||
|
01/05/2022 00:15:30,171,914
|
||||||
|
01/05/2022 00:15:40,171,876
|
||||||
|
01/05/2022 00:15:50,171,837
|
||||||
|
01/05/2022 00:16:00,171,799
|
||||||
|
01/05/2022 00:16:10,171,761
|
||||||
|
01/05/2022 00:16:20,171,723
|
||||||
|
01/05/2022 00:16:30,171,685
|
||||||
|
01/05/2022 00:16:40,171,647
|
||||||
|
01/05/2022 00:16:50,171,609
|
||||||
|
01/05/2022 00:17:00,171,571
|
||||||
|
01/05/2022 00:17:10,171,533
|
||||||
|
01/05/2022 00:17:20,171,494
|
||||||
|
01/05/2022 00:17:30,171,456
|
||||||
|
01/05/2022 00:17:40,171,418
|
||||||
|
01/05/2022 00:17:50,171,38
|
||||||
|
01/05/2022 00:18:00,171,342
|
||||||
|
01/05/2022 00:18:10,171,304
|
||||||
|
01/05/2022 00:18:20,171,266
|
||||||
|
01/05/2022 00:18:30,171,228
|
||||||
|
01/05/2022 00:18:40,171,189
|
||||||
|
01/05/2022 00:18:50,171,151
|
||||||
|
01/05/2022 00:19:00,171,113
|
||||||
|
01/05/2022 00:19:10,171,75
|
||||||
|
01/05/2022 00:19:20,171,37
|
||||||
|
01/05/2022 00:19:30,170,999
|
||||||
|
01/05/2022 00:19:40,170,961
|
||||||
|
01/05/2022 00:19:50,170,923
|
||||||
|
01/05/2022 00:20:00,170,884
|
||||||
|
01/05/2022 00:20:10,170,846
|
||||||
|
01/05/2022 00:20:20,170,808
|
||||||
|
01/05/2022 00:20:30,170,77
|
||||||
|
01/05/2022 00:20:40,170,732
|
||||||
|
01/05/2022 00:20:50,170,694
|
||||||
|
01/05/2022 00:21:00,170,656
|
||||||
|
01/05/2022 00:21:10,170,618
|
||||||
|
01/05/2022 00:21:20,170,58
|
||||||
|
01/05/2022 00:21:30,170,541
|
||||||
|
01/05/2022 00:21:40,170,503
|
||||||
|
01/05/2022 00:21:50,170,465
|
||||||
|
01/05/2022 00:22:00,170,427
|
||||||
|
01/05/2022 00:22:10,170,389
|
||||||
|
01/05/2022 00:22:20,170,351
|
||||||
|
01/05/2022 00:22:30,170,313
|
||||||
|
01/05/2022 00:22:40,170,275
|
||||||
|
01/05/2022 00:22:50,170,236
|
||||||
|
01/05/2022 00:23:00,170,198
|
||||||
|
01/05/2022 00:23:10,170,16
|
||||||
|
01/05/2022 00:23:20,170,122
|
||||||
|
01/05/2022 00:23:30,170,84
|
||||||
|
01/05/2022 00:23:40,170,46
|
||||||
|
01/05/2022 00:23:50,170,8
|
||||||
|
01/05/2022 00:24:00,169,97
|
||||||
|
01/05/2022 00:24:10,169,932
|
||||||
|
01/05/2022 00:24:20,169,893
|
||||||
|
01/05/2022 00:24:30,169,855
|
||||||
|
01/05/2022 00:24:40,169,817
|
||||||
|
01/05/2022 00:24:50,169,779
|
||||||
|
01/05/2022 00:25:00,169,741
|
||||||
|
01/05/2022 00:25:10,169,703
|
||||||
|
01/05/2022 00:25:20,169,665
|
||||||
|
01/05/2022 00:25:30,169,627
|
||||||
|
01/05/2022 00:25:40,169,588
|
||||||
|
01/05/2022 00:25:50,169,55
|
||||||
|
01/05/2022 00:26:00,169,512
|
||||||
|
01/05/2022 00:26:10,169,474
|
||||||
|
01/05/2022 00:26:20,169,436
|
||||||
|
01/05/2022 00:26:30,169,398
|
||||||
|
01/05/2022 00:26:40,169,36
|
||||||
|
01/05/2022 00:26:50,169,322
|
||||||
|
01/05/2022 00:27:00,169,284
|
||||||
|
01/05/2022 00:27:10,169,245
|
||||||
|
01/05/2022 00:27:20,169,207
|
||||||
|
01/05/2022 00:27:30,169,169
|
||||||
|
01/05/2022 00:27:40,169,131
|
||||||
|
01/05/2022 00:27:50,169,93
|
||||||
|
01/05/2022 00:28:00,169,55
|
||||||
|
01/05/2022 00:28:10,169,17
|
||||||
|
01/05/2022 00:28:20,168,979
|
||||||
|
01/05/2022 00:28:30,168,94
|
||||||
|
01/05/2022 00:28:40,168,902
|
||||||
|
01/05/2022 00:28:50,168,864
|
||||||
|
01/05/2022 00:29:00,168,826
|
||||||
|
01/05/2022 00:29:10,168,788
|
||||||
|
01/05/2022 00:29:20,168,75
|
||||||
|
01/05/2022 00:29:30,168,712
|
||||||
|
01/05/2022 00:29:40,168,674
|
||||||
|
01/05/2022 00:29:50,168,636
|
||||||
|
01/05/2022 00:30:00,168,597
|
||||||
|
01/05/2022 00:30:10,168,559
|
||||||
|
01/05/2022 00:30:20,168,521
|
||||||
|
01/05/2022 00:30:30,168,483
|
||||||
|
01/05/2022 00:30:40,168,445
|
||||||
|
01/05/2022 00:30:50,168,407
|
||||||
|
01/05/2022 00:31:00,168,369
|
||||||
|
01/05/2022 00:31:10,168,331
|
||||||
|
01/05/2022 00:31:20,168,292
|
||||||
|
01/05/2022 00:31:30,168,254
|
||||||
|
01/05/2022 00:31:40,168,216
|
||||||
|
01/05/2022 00:31:50,168,178
|
||||||
|
01/05/2022 00:32:00,168,14
|
||||||
|
01/05/2022 00:32:10,168,102
|
||||||
|
01/05/2022 00:32:20,168,64
|
||||||
|
01/05/2022 00:32:30,168,26
|
||||||
|
01/05/2022 00:32:40,168,26
|
||||||
|
01/05/2022 00:32:50,168,105
|
||||||
|
01/05/2022 00:33:00,168,183
|
||||||
|
01/05/2022 00:33:10,168,262
|
||||||
|
01/05/2022 00:33:20,168,341
|
||||||
|
01/05/2022 00:33:30,168,42
|
||||||
|
01/05/2022 00:33:40,168,499
|
||||||
|
01/05/2022 00:33:50,168,578
|
||||||
|
01/05/2022 00:34:00,168,657
|
||||||
|
01/05/2022 00:34:10,168,735
|
||||||
|
01/05/2022 00:34:20,168,814
|
||||||
|
01/05/2022 00:34:30,168,893
|
||||||
|
01/05/2022 00:34:40,168,972
|
||||||
|
01/05/2022 00:34:50,169,51
|
||||||
|
01/05/2022 00:35:00,169,13
|
||||||
|
01/05/2022 00:35:10,169,209
|
||||||
|
01/05/2022 00:35:20,169,287
|
||||||
|
01/05/2022 00:35:30,169,366
|
||||||
|
01/05/2022 00:35:40,169,445
|
||||||
|
01/05/2022 00:35:50,169,524
|
||||||
|
01/05/2022 00:36:00,169,603
|
||||||
|
01/05/2022 00:36:10,169,682
|
||||||
|
01/05/2022 00:36:20,169,761
|
||||||
|
01/05/2022 00:36:30,169,839
|
||||||
|
01/05/2022 00:36:40,169,918
|
||||||
|
01/05/2022 00:36:50,169,997
|
||||||
|
01/05/2022 00:37:00,170,76
|
||||||
|
01/05/2022 00:37:10,170,155
|
||||||
|
01/05/2022 00:37:20,170,234
|
||||||
|
01/05/2022 00:37:30,170,312
|
||||||
|
01/05/2022 00:37:40,170,391
|
||||||
|
01/05/2022 00:37:50,170,47
|
||||||
|
01/05/2022 00:38:00,170,549
|
||||||
|
01/05/2022 00:38:10,170,628
|
||||||
|
01/05/2022 00:38:20,170,707
|
||||||
|
01/05/2022 00:38:30,170,786
|
||||||
|
01/05/2022 00:38:40,170,864
|
||||||
|
01/05/2022 00:38:50,170,943
|
||||||
|
01/05/2022 00:39:00,170,993
|
||||||
|
01/05/2022 00:39:10,170,967
|
||||||
|
01/05/2022 00:39:20,170,942
|
||||||
|
01/05/2022 00:39:30,170,917
|
||||||
|
01/05/2022 00:39:40,170,891
|
||||||
|
01/05/2022 00:39:50,170,866
|
||||||
|
01/05/2022 00:40:00,170,841
|
||||||
|
01/05/2022 00:40:10,170,815
|
||||||
|
01/05/2022 00:40:20,170,79
|
||||||
|
01/05/2022 00:40:30,170,764
|
||||||
|
01/05/2022 00:40:40,170,739
|
||||||
|
01/05/2022 00:40:50,170,714
|
||||||
|
01/05/2022 00:41:00,170,688
|
||||||
|
01/05/2022 00:41:10,170,663
|
||||||
|
01/05/2022 00:41:20,170,637
|
||||||
|
01/05/2022 00:41:30,170,612
|
||||||
|
01/05/2022 00:41:40,170,587
|
||||||
|
01/05/2022 00:41:50,170,561
|
||||||
|
01/05/2022 00:42:00,170,536
|
||||||
|
01/05/2022 00:42:10,170,51
|
||||||
|
01/05/2022 00:42:20,170,485
|
||||||
|
01/05/2022 00:42:30,170,46
|
||||||
|
01/05/2022 00:42:40,170,434
|
||||||
|
01/05/2022 00:42:50,170,409
|
||||||
|
01/05/2022 00:43:00,170,384
|
||||||
|
01/05/2022 00:43:10,170,358
|
||||||
|
01/05/2022 00:43:20,170,333
|
||||||
|
01/05/2022 00:43:30,170,307
|
||||||
|
01/05/2022 00:43:40,170,282
|
||||||
|
01/05/2022 00:43:50,170,257
|
||||||
|
01/05/2022 00:44:00,170,231
|
||||||
|
01/05/2022 00:44:10,170,206
|
||||||
|
01/05/2022 00:44:20,170,18
|
||||||
|
01/05/2022 00:44:30,170,155
|
||||||
|
01/05/2022 00:44:40,170,13
|
||||||
|
01/05/2022 00:44:50,170,104
|
||||||
|
01/05/2022 00:45:00,170,79
|
||||||
|
01/05/2022 00:45:10,170,54
|
||||||
|
01/05/2022 00:45:20,170,28
|
||||||
|
01/05/2022 00:45:30,170,3
|
||||||
|
01/05/2022 00:45:40,169,977
|
||||||
|
01/05/2022 00:45:50,169,952
|
||||||
|
01/05/2022 00:46:00,169,927
|
||||||
|
01/05/2022 00:46:10,169,901
|
||||||
|
01/05/2022 00:46:20,169,876
|
||||||
|
01/05/2022 00:46:30,169,85
|
||||||
|
01/05/2022 00:46:40,169,825
|
||||||
|
01/05/2022 00:46:50,169,8
|
||||||
|
01/05/2022 00:47:00,169,774
|
||||||
|
01/05/2022 00:47:10,169,749
|
||||||
|
01/05/2022 00:47:20,169,723
|
||||||
|
01/05/2022 00:47:30,169,698
|
||||||
|
01/05/2022 00:47:40,169,673
|
||||||
|
01/05/2022 00:47:50,169,647
|
||||||
|
01/05/2022 00:48:00,169,622
|
||||||
|
01/05/2022 00:48:10,169,597
|
||||||
|
01/05/2022 00:48:20,169,571
|
||||||
|
01/05/2022 00:48:30,169,546
|
||||||
|
01/05/2022 00:48:40,169,52
|
||||||
|
01/05/2022 00:48:50,169,495
|
||||||
|
01/05/2022 00:49:00,169,47
|
||||||
|
01/05/2022 00:49:10,169,444
|
||||||
|
01/05/2022 00:49:20,169,419
|
||||||
|
01/05/2022 00:49:30,169,393
|
||||||
|
01/05/2022 00:49:40,169,368
|
||||||
|
01/05/2022 00:49:50,169,343
|
||||||
|
01/05/2022 00:50:00,169,317
|
||||||
|
01/05/2022 00:50:10,169,292
|
||||||
|
01/05/2022 00:50:20,169,266
|
||||||
|
01/05/2022 00:50:30,169,241
|
||||||
|
01/05/2022 00:50:40,169,216
|
||||||
|
01/05/2022 00:50:50,169,19
|
||||||
|
01/05/2022 00:51:00,169,165
|
||||||
|
01/05/2022 00:51:10,169,14
|
||||||
|
01/05/2022 00:51:20,169,114
|
||||||
|
01/05/2022 00:51:30,169,89
|
||||||
|
01/05/2022 00:51:40,169,63
|
||||||
|
01/05/2022 00:51:50,169,38
|
||||||
|
01/05/2022 00:52:00,169,13
|
||||||
|
01/05/2022 00:52:10,168,987
|
||||||
|
01/05/2022 00:52:20,168,962
|
||||||
|
01/05/2022 00:52:30,168,936
|
||||||
|
01/05/2022 00:52:40,168,911
|
||||||
|
01/05/2022 00:52:50,168,886
|
||||||
|
01/05/2022 00:53:00,168,86
|
||||||
|
01/05/2022 00:53:10,168,835
|
||||||
|
01/05/2022 00:53:20,168,809
|
||||||
|
01/05/2022 00:53:30,168,784
|
||||||
|
01/05/2022 00:53:40,168,759
|
||||||
|
01/05/2022 00:53:50,168,733
|
||||||
|
01/05/2022 00:54:00,168,708
|
||||||
|
01/05/2022 00:54:10,168,683
|
||||||
|
01/05/2022 00:54:20,168,657
|
||||||
|
01/05/2022 00:54:30,168,632
|
||||||
|
01/05/2022 00:54:40,168,606
|
||||||
|
01/05/2022 00:54:50,168,581
|
||||||
|
01/05/2022 00:55:00,168,556
|
||||||
|
01/05/2022 00:55:10,168,53
|
||||||
|
01/05/2022 00:55:20,168,505
|
||||||
|
01/05/2022 00:55:30,168,479
|
||||||
|
01/05/2022 00:55:40,168,454
|
||||||
|
01/05/2022 00:55:50,168,429
|
||||||
|
01/05/2022 00:56:00,168,403
|
||||||
|
01/05/2022 00:56:10,168,378
|
||||||
|
01/05/2022 00:56:20,168,352
|
||||||
|
01/05/2022 00:56:30,168,327
|
||||||
|
01/05/2022 00:56:40,168,302
|
||||||
|
01/05/2022 00:56:50,168,276
|
||||||
|
01/05/2022 00:57:00,168,251
|
||||||
|
01/05/2022 00:57:10,168,226
|
||||||
|
01/05/2022 00:57:20,168,2
|
||||||
|
01/05/2022 00:57:30,168,175
|
||||||
|
01/05/2022 00:57:40,168,149
|
||||||
|
01/05/2022 00:57:50,168,124
|
||||||
|
01/05/2022 00:58:00,168,99
|
||||||
|
01/05/2022 00:58:10,168,73
|
||||||
|
01/05/2022 00:58:20,168,48
|
||||||
|
01/05/2022 00:58:30,168,22
|
||||||
|
01/05/2022 00:58:40,167,76
|
||||||
|
01/05/2022 00:58:50,160,151
|
||||||
|
01/05/2022 00:59:00,161,484
|
||||||
|
01/05/2022 00:59:10,162,817
|
||||||
|
01/05/2022 00:59:20,164,281
|
||||||
|
01/05/2022 00:59:30,166,777
|
||||||
|
01/05/2022 00:59:40,167,148
|
||||||
|
01/05/2022 00:59:50,148,45
|
||||||
|
01/05/2022 01:00:00,117,525
|
||||||
|
01/05/2022 01:00:10,105,3
|
||||||
|
01/05/2022 01:00:20,105,315
|
||||||
|
01/05/2022 01:00:30,105,6
|
||||||
|
01/05/2022 01:00:40,105,886
|
||||||
|
01/05/2022 01:00:50,106,171
|
||||||
|
01/05/2022 01:01:00,106,456
|
||||||
|
01/05/2022 01:01:10,106,742
|
||||||
|
01/05/2022 01:01:20,107,698
|
||||||
|
01/05/2022 01:01:30,115,81
|
||||||
|
01/05/2022 01:01:40,122,464
|
||||||
|
01/05/2022 01:01:50,129,847
|
||||||
|
01/05/2022 01:02:00,137,231
|
||||||
|
01/05/2022 01:02:10,144,138
|
||||||
|
01/05/2022 01:02:20,145,801
|
||||||
|
01/05/2022 01:02:30,147,464
|
||||||
|
01/05/2022 01:02:40,149,835
|
||||||
|
01/05/2022 01:02:50,160,808
|
||||||
|
01/05/2022 01:03:00,171,2
|
||||||
|
01/05/2022 01:03:10,171,36
|
||||||
|
01/05/2022 01:03:20,171,7
|
||||||
|
01/05/2022 01:03:30,171,103
|
||||||
|
01/05/2022 01:03:40,171,137
|
||||||
|
01/05/2022 01:03:50,171,171
|
||||||
|
01/05/2022 01:04:00,171,204
|
||||||
|
01/05/2022 01:04:10,171,238
|
||||||
|
01/05/2022 01:04:20,171,272
|
||||||
|
01/05/2022 01:04:30,171,305
|
||||||
|
01/05/2022 01:04:40,171,339
|
||||||
|
01/05/2022 01:04:50,171,372
|
||||||
|
01/05/2022 01:05:00,171,406
|
||||||
|
01/05/2022 01:05:10,171,44
|
||||||
|
01/05/2022 01:05:20,171,473
|
||||||
|
01/05/2022 01:05:30,171,507
|
||||||
|
01/05/2022 01:05:40,171,541
|
||||||
|
01/05/2022 01:05:50,171,574
|
||||||
|
01/05/2022 01:06:00,171,608
|
||||||
|
01/05/2022 01:06:10,171,642
|
||||||
|
01/05/2022 01:06:20,171,675
|
||||||
|
01/05/2022 01:06:30,171,709
|
||||||
|
01/05/2022 01:06:40,171,743
|
||||||
|
01/05/2022 01:06:50,171,776
|
||||||
|
01/05/2022 01:07:00,171,81
|
||||||
|
01/05/2022 01:07:10,171,844
|
||||||
|
01/05/2022 01:07:20,171,877
|
||||||
|
01/05/2022 01:07:30,171,911
|
||||||
|
01/05/2022 01:07:40,171,944
|
||||||
|
01/05/2022 01:07:50,171,978
|
||||||
|
01/05/2022 01:08:00,172,12
|
||||||
|
01/05/2022 01:08:10,172,45
|
||||||
|
01/05/2022 01:08:20,172,79
|
||||||
|
01/05/2022 01:08:30,172,113
|
||||||
|
01/05/2022 01:08:40,172,146
|
||||||
|
01/05/2022 01:08:50,172,18
|
||||||
|
01/05/2022 01:09:00,172,214
|
||||||
|
01/05/2022 01:09:10,172,247
|
||||||
|
01/05/2022 01:09:20,172,281
|
||||||
|
01/05/2022 01:09:30,172,315
|
||||||
|
01/05/2022 01:09:40,172,348
|
||||||
|
01/05/2022 01:09:50,172,382
|
||||||
|
01/05/2022 01:10:00,172,415
|
||||||
|
01/05/2022 01:10:10,172,449
|
||||||
|
01/05/2022 01:10:20,172,483
|
||||||
|
01/05/2022 01:10:30,172,516
|
||||||
|
01/05/2022 01:10:40,172,55
|
||||||
|
01/05/2022 01:10:50,172,584
|
||||||
|
01/05/2022 01:11:00,172,617
|
||||||
|
01/05/2022 01:11:10,172,651
|
||||||
|
01/05/2022 01:11:20,172,685
|
||||||
|
01/05/2022 01:11:30,172,718
|
||||||
|
01/05/2022 01:11:40,172,752
|
||||||
|
01/05/2022 01:11:50,172,786
|
||||||
|
01/05/2022 01:12:00,172,819
|
||||||
|
01/05/2022 01:12:10,172,853
|
||||||
|
01/05/2022 01:12:20,172,887
|
||||||
|
01/05/2022 01:12:30,172,92
|
||||||
|
01/05/2022 01:12:40,172,954
|
||||||
|
01/05/2022 01:12:50,172,987
|
||||||
|
01/05/2022 01:13:00,173,21
|
||||||
|
01/05/2022 01:13:10,173,55
|
||||||
|
01/05/2022 01:13:20,173,88
|
||||||
|
01/05/2022 01:13:30,173,122
|
||||||
|
01/05/2022 01:13:40,173,156
|
||||||
|
01/05/2022 01:13:50,173,189
|
||||||
|
01/05/2022 01:14:00,173,223
|
||||||
|
01/05/2022 01:14:10,173,257
|
||||||
|
01/05/2022 01:14:20,173,29
|
||||||
|
01/05/2022 01:14:30,173,324
|
||||||
|
01/05/2022 01:14:40,173,358
|
||||||
|
01/05/2022 01:14:50,173,391
|
||||||
|
01/05/2022 01:15:00,173,425
|
||||||
|
01/05/2022 01:15:10,173,458
|
||||||
|
01/05/2022 01:15:20,173,492
|
||||||
|
01/05/2022 01:15:30,173,526
|
||||||
|
01/05/2022 01:15:40,173,559
|
||||||
|
01/05/2022 01:15:50,173,593
|
||||||
|
01/05/2022 01:16:00,173,627
|
||||||
|
01/05/2022 01:16:10,173,66
|
||||||
|
01/05/2022 01:16:20,173,694
|
||||||
|
01/05/2022 01:16:30,173,728
|
||||||
|
01/05/2022 01:16:40,173,761
|
||||||
|
01/05/2022 01:16:50,173,795
|
||||||
|
01/05/2022 01:17:00,173,829
|
||||||
|
01/05/2022 01:17:10,173,862
|
||||||
|
01/05/2022 01:17:20,173,896
|
||||||
|
01/05/2022 01:17:30,173,93
|
||||||
|
01/05/2022 01:17:40,173,963
|
||||||
|
01/05/2022 01:17:50,173,997
|
||||||
|
01/05/2022 01:18:00,174,103
|
||||||
|
01/05/2022 01:18:10,174,218
|
||||||
|
01/05/2022 01:18:20,174,332
|
||||||
|
01/05/2022 01:18:30,174,446
|
||||||
|
01/05/2022 01:18:40,174,56
|
||||||
|
01/05/2022 01:18:50,174,674
|
||||||
|
01/05/2022 01:19:00,174,788
|
||||||
|
01/05/2022 01:19:10,174,902
|
||||||
|
01/05/2022 01:19:20,175,17
|
||||||
|
01/05/2022 01:19:30,175,131
|
||||||
|
01/05/2022 01:19:40,175,245
|
||||||
|
01/05/2022 01:19:50,175,359
|
||||||
|
01/05/2022 01:20:00,175,473
|
||||||
|
01/05/2022 01:20:10,175,587
|
||||||
|
01/05/2022 01:20:20,175,701
|
||||||
|
01/05/2022 01:20:30,175,816
|
||||||
|
01/05/2022 01:20:40,175,93
|
||||||
|
01/05/2022 01:20:50,176,44
|
||||||
|
01/05/2022 01:21:00,176,158
|
||||||
|
01/05/2022 01:21:10,176,272
|
||||||
|
01/05/2022 01:21:20,176,386
|
||||||
|
01/05/2022 01:21:30,176,501
|
||||||
|
01/05/2022 01:21:40,176,615
|
||||||
|
01/05/2022 01:21:50,176,729
|
||||||
|
01/05/2022 01:22:00,176,843
|
||||||
|
01/05/2022 01:22:10,176,957
|
||||||
|
01/05/2022 01:22:20,177,71
|
||||||
|
01/05/2022 01:22:30,177,185
|
||||||
|
01/05/2022 01:22:40,177,3
|
||||||
|
01/05/2022 01:22:50,177,414
|
||||||
|
01/05/2022 01:23:00,177,528
|
||||||
|
01/05/2022 01:23:10,177,642
|
||||||
|
01/05/2022 01:23:20,177,756
|
||||||
|
01/05/2022 01:23:30,177,87
|
||||||
|
01/05/2022 01:23:40,177,984
|
||||||
|
01/05/2022 01:23:50,178,7
|
||||||
|
01/05/2022 01:24:00,178,15
|
||||||
|
01/05/2022 01:24:10,178,24
|
||||||
|
01/05/2022 01:24:20,178,32
|
||||||
|
01/05/2022 01:24:30,178,4
|
||||||
|
01/05/2022 01:24:40,178,48
|
||||||
|
01/05/2022 01:24:50,178,57
|
||||||
|
01/05/2022 01:25:00,178,65
|
||||||
|
01/05/2022 01:25:10,178,73
|
||||||
|
01/05/2022 01:25:20,178,81
|
||||||
|
01/05/2022 01:25:30,178,9
|
||||||
|
01/05/2022 01:25:40,178,98
|
||||||
|
01/05/2022 01:25:50,178,106
|
||||||
|
01/05/2022 01:26:00,178,114
|
||||||
|
01/05/2022 01:26:10,178,123
|
||||||
|
01/05/2022 01:26:20,178,131
|
||||||
|
01/05/2022 01:26:30,178,139
|
||||||
|
01/05/2022 01:26:40,178,147
|
||||||
|
01/05/2022 01:26:50,178,156
|
||||||
|
01/05/2022 01:27:00,178,164
|
||||||
|
01/05/2022 01:27:10,178,172
|
||||||
|
01/05/2022 01:27:20,178,18
|
||||||
|
01/05/2022 01:27:30,178,189
|
||||||
|
01/05/2022 01:27:40,178,197
|
||||||
|
01/05/2022 01:27:50,178,205
|
||||||
|
01/05/2022 01:28:00,178,213
|
||||||
|
01/05/2022 01:28:10,178,222
|
||||||
|
01/05/2022 01:28:20,178,23
|
||||||
|
01/05/2022 01:28:30,178,238
|
||||||
|
01/05/2022 01:28:40,178,246
|
||||||
|
01/05/2022 01:28:50,178,255
|
||||||
|
01/05/2022 01:29:00,178,263
|
||||||
|
01/05/2022 01:29:10,178,271
|
||||||
|
01/05/2022 01:29:20,178,279
|
||||||
|
01/05/2022 01:29:30,178,288
|
||||||
|
01/05/2022 01:29:40,178,296
|
||||||
|
01/05/2022 01:29:50,178,304
|
||||||
|
01/05/2022 01:30:00,178,312
|
||||||
|
01/05/2022 01:30:10,178,321
|
||||||
|
01/05/2022 01:30:20,178,329
|
||||||
|
01/05/2022 01:30:30,178,337
|
||||||
|
01/05/2022 01:30:40,178,345
|
||||||
|
01/05/2022 01:30:50,178,354
|
||||||
|
01/05/2022 01:31:00,178,362
|
||||||
|
01/05/2022 01:31:10,178,37
|
||||||
|
01/05/2022 01:31:20,178,378
|
||||||
|
01/05/2022 01:31:30,178,387
|
||||||
|
01/05/2022 01:31:40,178,395
|
||||||
|
01/05/2022 01:31:50,178,403
|
||||||
|
01/05/2022 01:32:00,178,411
|
||||||
|
01/05/2022 01:32:10,178,42
|
||||||
|
01/05/2022 01:32:20,178,428
|
||||||
|
01/05/2022 01:32:30,178,436
|
||||||
|
01/05/2022 01:32:40,178,444
|
||||||
|
01/05/2022 01:32:50,178,453
|
||||||
|
01/05/2022 01:33:00,178,461
|
||||||
|
01/05/2022 01:33:10,178,469
|
||||||
|
01/05/2022 01:33:20,178,477
|
||||||
|
01/05/2022 01:33:30,178,486
|
||||||
|
01/05/2022 01:33:40,178,494
|
||||||
|
01/05/2022 01:33:50,178,502
|
||||||
|
01/05/2022 01:34:00,178,51
|
||||||
|
01/05/2022 01:34:10,178,519
|
||||||
|
01/05/2022 01:34:20,178,527
|
||||||
|
01/05/2022 01:34:30,178,535
|
||||||
|
01/05/2022 01:34:40,178,543
|
||||||
|
01/05/2022 01:34:50,178,552
|
||||||
|
01/05/2022 01:35:00,178,56
|
||||||
|
01/05/2022 01:35:10,178,568
|
||||||
|
01/05/2022 01:35:20,178,576
|
||||||
|
01/05/2022 01:35:30,178,585
|
||||||
|
01/05/2022 01:35:40,178,593
|
||||||
|
01/05/2022 01:35:50,178,601
|
||||||
|
01/05/2022 01:36:00,178,609
|
||||||
|
01/05/2022 01:36:10,178,618
|
||||||
|
01/05/2022 01:36:20,178,626
|
||||||
|
01/05/2022 01:36:30,178,634
|
||||||
|
01/05/2022 01:36:40,178,642
|
||||||
|
01/05/2022 01:36:50,178,651
|
||||||
|
01/05/2022 01:37:00,178,659
|
||||||
|
01/05/2022 01:37:10,178,667
|
||||||
|
01/05/2022 01:37:20,178,675
|
||||||
|
01/05/2022 01:37:30,178,684
|
||||||
|
01/05/2022 01:37:40,178,692
|
||||||
|
01/05/2022 01:37:50,178,7
|
||||||
|
01/05/2022 01:38:00,178,708
|
||||||
|
01/05/2022 01:38:10,178,717
|
||||||
|
01/05/2022 01:38:20,178,725
|
||||||
|
01/05/2022 01:38:30,178,733
|
||||||
|
01/05/2022 01:38:40,178,741
|
||||||
|
01/05/2022 01:38:50,178,75
|
||||||
|
01/05/2022 01:39:00,178,758
|
||||||
|
01/05/2022 01:39:10,178,766
|
||||||
|
01/05/2022 01:39:20,178,774
|
||||||
|
01/05/2022 01:39:30,178,783
|
||||||
|
01/05/2022 01:39:40,178,791
|
||||||
|
01/05/2022 01:39:50,178,799
|
||||||
|
01/05/2022 01:40:00,178,807
|
||||||
|
01/05/2022 01:40:10,178,816
|
||||||
|
01/05/2022 01:40:20,178,824
|
||||||
|
01/05/2022 01:40:30,178,832
|
||||||
|
01/05/2022 01:40:40,178,84
|
||||||
|
01/05/2022 01:40:50,178,849
|
||||||
|
01/05/2022 01:41:00,178,857
|
||||||
|
01/05/2022 01:41:10,178,865
|
||||||
|
01/05/2022 01:41:20,178,873
|
||||||
|
01/05/2022 01:41:30,178,882
|
||||||
|
01/05/2022 01:41:40,178,89
|
||||||
|
01/05/2022 01:41:50,178,898
|
||||||
|
01/05/2022 01:42:00,178,906
|
||||||
|
01/05/2022 01:42:10,178,915
|
||||||
|
01/05/2022 01:42:20,178,923
|
||||||
|
01/05/2022 01:42:30,178,931
|
||||||
|
01/05/2022 01:42:40,178,939
|
||||||
|
01/05/2022 01:42:50,178,948
|
||||||
|
01/05/2022 01:43:00,178,956
|
||||||
|
01/05/2022 01:43:10,178,964
|
||||||
|
01/05/2022 01:43:20,178,972
|
||||||
|
01/05/2022 01:43:30,178,981
|
||||||
|
01/05/2022 01:43:40,178,989
|
||||||
|
01/05/2022 01:43:50,178,997
|
||||||
|
01/05/2022 01:44:00,169,147
|
||||||
|
01/05/2022 01:44:10,148,934
|
||||||
|
01/05/2022 01:44:20,119,458
|
||||||
|
01/05/2022 01:44:30,108,326
|
||||||
|
01/05/2022 01:44:40,108,825
|
||||||
|
01/05/2022 01:44:50,109,324
|
||||||
|
01/05/2022 01:45:00,109,824
|
||||||
|
01/05/2022 01:45:10,110,323
|
||||||
|
01/05/2022 01:45:20,110,822
|
||||||
|
01/05/2022 01:45:30,123,3
|
||||||
|
01/05/2022 01:45:40,141,628
|
||||||
|
01/05/2022 01:45:50,160,252
|
||||||
|
01/05/2022 01:46:00,169,863
|
||||||
|
01/05/2022 01:46:10,174,353
|
||||||
|
01/05/2022 01:46:20,176,8
|
||||||
|
01/05/2022 01:46:30,176,19
|
||||||
|
01/05/2022 01:46:40,176,31
|
||||||
|
01/05/2022 01:46:50,176,43
|
||||||
|
01/05/2022 01:47:00,176,55
|
||||||
|
01/05/2022 01:47:10,176,67
|
||||||
|
01/05/2022 01:47:20,176,79
|
||||||
|
01/05/2022 01:47:30,176,91
|
||||||
|
66
docs/scenarios.md
Normal file
66
docs/scenarios.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# E2E Test Scenarios
|
||||||
|
|
||||||
|
This document maps the workflow scenarios tested in the E2E suite to their corresponding JSON input files and expected behaviors.
|
||||||
|
|
||||||
|
## Infrastructure (second-pass review)
|
||||||
|
|
||||||
|
- **Containers:** PostgreSQL, MinIO, MongoDB, and Gitea via testcontainers; real clients and `Activities` code paths.
|
||||||
|
- **MLflow:** `file://` tracking URI (real SDK, no remote server).
|
||||||
|
- **Temporal:** `WorkflowEnvironment.start_time_skipping()` — official temporalio test runtime; workflows and activities are not stubbed.
|
||||||
|
- **Logging/metrics:** `get_logger` + `MetricsController` (sientia_do); no `unittest.mock` for observability in `e2e/conftest.py`.
|
||||||
|
- **Unit tests** under `tests/` may still use mocks where appropriate; that policy is separate from this E2E suite.
|
||||||
|
|
||||||
|
## 1. TrainModel Workflow (`test_train_model_workflow.py`)
|
||||||
|
|
||||||
|
### 1.1 Happy Paths (Successful execution)
|
||||||
|
|
||||||
|
| Test Function | Input JSON | Expected Status | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `test_scenario_1_1_1_linear_regression_basic` | `01-linear-regression-basic.json` | `TRAINING_SUCCESS` | Basic linear regression without scaler. Verifies end-to-end pipeline. |
|
||||||
|
| `test_scenario_1_1_2_linear_regression_with_scaler` | `02-linear-regression-with-scaler.json` | `TRAINING_SUCCESS` | Linear regression with `Standard Scaler`. |
|
||||||
|
| `test_scenario_1_1_3_polynomial_regression_degree2_with_scaler` | `03-polynomial-regression-degree2.json` | `TRAINING_SUCCESS` | Polynomial regression (degree 2) with Standard Scaler. |
|
||||||
|
| `test_scenario_1_1_4_polynomial_regression_degree3_with_scaler` | `04-polynomial-regression-degree3.json` | `TRAINING_SUCCESS` | Polynomial regression (degree 3) with Standard Scaler. |
|
||||||
|
| `test_scenario_1_1_5_linear_regression_with_lags` | `05-linear-regression-with-lags.json` | `TRAINING_SUCCESS` | Linear regression with `lag_train`/`lag_val` per variable. |
|
||||||
|
| `test_scenario_1_1_6_linear_regression_nan_interpolation` | `06-linear-regression-nan-interpolation.json` | `TRAINING_SUCCESS` | Linear regression with `nan_treatment='linear interpolation'`. |
|
||||||
|
| `test_scenario_1_1_7_linear_regression_static_window_removal` | `07-linear-regression-static-window-removal.json` | `TRAINING_SUCCESS` | `rem_static_win=true` with default `static_threshold`. |
|
||||||
|
| `test_scenario_1_1_8_linear_regression_with_limits` | `08-linear-regression-with-limits.json` | `TRAINING_SUCCESS` | `support_filters` with `min`/`max` per variable. |
|
||||||
|
| `test_scenario_1_1_9_polynomial_degree2_scaler_and_lags` | `09-polynomial-degree2-with-scaler-and-lags.json` | `TRAINING_SUCCESS` | Polynomial (degree 2), Standard Scaler, and lags. |
|
||||||
|
| `test_scenario_1_1_10_linear_regression_with_ar_opt_params` | `10-linear-regression-with-ar.json` | `TRAINING_SUCCESS` | `opt_params.include_ar=true` (placeholder for future AR behavior). |
|
||||||
|
| `test_scenario_1_1_11_linear_regression_static_threshold_custom` | `11-linear-regression-static-threshold-custom.json` | `TRAINING_SUCCESS` | `rem_static_win=true` with custom `static_threshold`. |
|
||||||
|
| `test_scenario_1_1_12_alternate_date_format_dd_mm_yyyy` | `12-angular-test-date-format.json` | `TRAINING_SUCCESS` | `date_column=DATA`, `dd/MM/yyyy` format, object `training_data_dd_mm_yyyy.csv`. |
|
||||||
|
| `test_scenario_1_1_13_alternate_csv_narrow_date_window` | `13-angular-test-double-date-column.json` | `TRAINING_SUCCESS` | Same alternate CSV with a bounded `start_date`/`end_date` window. |
|
||||||
|
| `test_scenario_1_1_14_polynomial_with_support_filters` | `14-angular-test-polynomial-support-filters.json` | `TRAINING_SUCCESS` | Polynomial (degree 4), scaler, `upper_line`/`lower_line` support filters. |
|
||||||
|
| `test_scenario_1_1_15_linear_regression_custom_target_column_name` | `15-linear-regression-custom-target-column.json` | `TRAINING_SUCCESS` | Custom `target_variable` column name (not literal ``target``); Evidently/report columns must match. |
|
||||||
|
| `test_scenario_1_1_16_naive_timestamp_header_column` | `16-linear-regression-naive-timestamp-header.json` | `TRAINING_SUCCESS` | `date_column`=`Timestamp`, naive CSV `training_data_timestamp_naive.csv`. |
|
||||||
|
| `test_scenario_1_1_17_linear_regression_blank_timestamp_row_dropped` | `17-linear-regression-blank-timestamp-row.json` | `TRAINING_SUCCESS` | One empty timestamp cell; row dropped before index. |
|
||||||
|
|
||||||
|
### 1.2 Error Paths
|
||||||
|
|
||||||
|
| Test Function | Input JSON | Expected Status | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `test_scenario_1_2_1_minio_file_not_found` | `01-linear-regression-basic.json` | `TRAINING_ERROR` | MinIO file does not exist. Workflow fails during file download. |
|
||||||
|
| `test_scenario_1_2_2_experiment_run_id_not_in_db` | `01-linear-regression-basic.json` | N/A (raises Exception) | `experiment_run_id` does not exist in DB. Workflow fails immediately on status update attempt. |
|
||||||
|
|
||||||
|
## 2. Parameter Validation (`test_train_model_validation.py`)
|
||||||
|
|
||||||
|
These scenarios test the business rule validations inside `validate_train_params`. All are expected to terminate with `ORCHESTRATOR_VALIDATION_ERROR`.
|
||||||
|
|
||||||
|
| Test Function | Modification | Expected Error Substring |
|
||||||
|
|---|---|---|
|
||||||
|
| `test_scenario_2_1_1_train_size_out_of_range` | `train_size = 5` | `'train_size'` |
|
||||||
|
| `test_scenario_2_1_2_empty_variable_columns` | `variable_columns = []` | `'variable_columns'` |
|
||||||
|
| `test_scenario_2_1_3_invalid_date_format` | `date_format = 'INVALID'` | `'date_format'` |
|
||||||
|
| `test_scenario_2_1_4_whitespace_only_model_name` | `model_name = ' '` | `'model_name'` |
|
||||||
|
| `test_scenario_2_1_5_unknown_model_type` | `model_type = 'totally_unknown_model'` | `'totally_unknown_model'` |
|
||||||
|
| `test_scenario_2_1_6_missing_target_variable` | `target_variable = ''` | `'target_variable'` |
|
||||||
|
| `test_scenario_2_1_7_missing_experiment_run_id` | Missing `experiment_run_id` | N/A (raises ValueError immediately) |
|
||||||
|
| `test_scenario_2_1_8_missing_date_column` | Missing `date_column` | N/A (raises ValueError immediately) |
|
||||||
|
| `test_scenario_2_1_9_whitespace_date_column` | `date_column = ' '` | `'date_column'` |
|
||||||
|
|
||||||
|
## 3. CleanupFiles Workflow (`test_cleanup_files_workflow.py`)
|
||||||
|
|
||||||
|
| Test Function | Description |
|
||||||
|
|---|---|
|
||||||
|
| `test_scenario_3_1_1_cleanup_with_no_temp_dirs` | Temp directory is empty. Activity completes without error. |
|
||||||
|
| `test_scenario_3_1_2_cleanup_removes_old_temp_dirs` | Two stale directories matching `name_YYYYMMDD_HHMMSS_microseconds` are removed when older than retention. |
|
||||||
|
| `test_scenario_3_1_3_cleanup_nonexistent_temp_path` | Target path does not exist. Handled gracefully without error. |
|
||||||
40
docs/test-scenarios/01-linear-regression-basic.json
Normal file
40
docs/test-scenarios/01-linear-regression-basic.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"_description": "Cenário básico de regressão linear sem scaler",
|
||||||
|
"experiment_run_id": 1001,
|
||||||
|
"variable_columns": [
|
||||||
|
"303-WIT-200(Value)"
|
||||||
|
],
|
||||||
|
"target_variable": "03CV020/CORRENTE_N_M1_PV(Value)",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training_data.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "timestamp",
|
||||||
|
"date_format": "yyyy-MM-dd HH:mm:ss",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "Linear Regression",
|
||||||
|
"model_type": "linear_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"rem_static_win": false,
|
||||||
|
"static_threshold": null,
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null,
|
||||||
|
"support_filters": {},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 1,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "None"
|
||||||
|
},
|
||||||
|
"opt_params": {}
|
||||||
|
}
|
||||||
40
docs/test-scenarios/02-linear-regression-with-scaler.json
Normal file
40
docs/test-scenarios/02-linear-regression-with-scaler.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"_description": "Regressão linear com Standard Scaler habilitado",
|
||||||
|
"experiment_run_id": 1002,
|
||||||
|
"variable_columns": [
|
||||||
|
"303-WIT-200(Value)"
|
||||||
|
],
|
||||||
|
"target_variable": "03CV020/CORRENTE_N_M1_PV(Value)",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training_data.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "timestamp",
|
||||||
|
"date_format": "yyyy-MM-dd HH:mm:ss",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "Linear Regression",
|
||||||
|
"model_type": "linear_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"rem_static_win": false,
|
||||||
|
"static_threshold": null,
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null,
|
||||||
|
"support_filters": {},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 1,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "Standard Scaler"
|
||||||
|
},
|
||||||
|
"opt_params": {}
|
||||||
|
}
|
||||||
40
docs/test-scenarios/03-polynomial-regression-degree2.json
Normal file
40
docs/test-scenarios/03-polynomial-regression-degree2.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"_description": "Regressão polinomial de grau 2 com scaler (obrigatório para evitar overflow)",
|
||||||
|
"experiment_run_id": 1003,
|
||||||
|
"variable_columns": [
|
||||||
|
"303-WIT-200(Value)"
|
||||||
|
],
|
||||||
|
"target_variable": "03CV020/CORRENTE_N_M1_PV(Value)",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training_data.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "timestamp",
|
||||||
|
"date_format": "yyyy-MM-dd HH:mm:ss",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "Polynomial Regression",
|
||||||
|
"model_type": "polynomial_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"rem_static_win": false,
|
||||||
|
"static_threshold": null,
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null,
|
||||||
|
"support_filters": {},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 2,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "Standard Scaler"
|
||||||
|
},
|
||||||
|
"opt_params": {}
|
||||||
|
}
|
||||||
40
docs/test-scenarios/04-polynomial-regression-degree3.json
Normal file
40
docs/test-scenarios/04-polynomial-regression-degree3.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"_description": "Regressão polinomial de grau 3 com scaler",
|
||||||
|
"experiment_run_id": 1004,
|
||||||
|
"variable_columns": [
|
||||||
|
"303-WIT-200(Value)"
|
||||||
|
],
|
||||||
|
"target_variable": "03CV020/CORRENTE_N_M1_PV(Value)",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training_data.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "timestamp",
|
||||||
|
"date_format": "yyyy-MM-dd HH:mm:ss",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "Polynomial Regression",
|
||||||
|
"model_type": "polynomial_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"rem_static_win": false,
|
||||||
|
"static_threshold": null,
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null,
|
||||||
|
"support_filters": {},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 3,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "Standard Scaler"
|
||||||
|
},
|
||||||
|
"opt_params": {}
|
||||||
|
}
|
||||||
40
docs/test-scenarios/05-linear-regression-with-lags.json
Normal file
40
docs/test-scenarios/05-linear-regression-with-lags.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"_description": "Regressão linear com lags de treino e validação",
|
||||||
|
"experiment_run_id": 1005,
|
||||||
|
"variable_columns": [
|
||||||
|
"303-WIT-200(Value)"
|
||||||
|
],
|
||||||
|
"target_variable": "03CV020/CORRENTE_N_M1_PV(Value)",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training_data.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "timestamp",
|
||||||
|
"date_format": "yyyy-MM-dd HH:mm:ss",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "Linear Regression",
|
||||||
|
"model_type": "linear_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"303-WIT-200(Value)": 5
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"303-WIT-200(Value)": 3
|
||||||
|
},
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"rem_static_win": false,
|
||||||
|
"static_threshold": null,
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null,
|
||||||
|
"support_filters": {},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 1,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "None"
|
||||||
|
},
|
||||||
|
"opt_params": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"_description": "Regressão linear com tratamento de NaN por interpolação linear",
|
||||||
|
"experiment_run_id": 1006,
|
||||||
|
"variable_columns": [
|
||||||
|
"303-WIT-200(Value)"
|
||||||
|
],
|
||||||
|
"target_variable": "03CV020/CORRENTE_N_M1_PV(Value)",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training_data.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "timestamp",
|
||||||
|
"date_format": "yyyy-MM-dd HH:mm:ss",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "Linear Regression",
|
||||||
|
"model_type": "linear_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"nan_treatment": "linear interpolation",
|
||||||
|
"rem_static_win": false,
|
||||||
|
"static_threshold": null,
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null,
|
||||||
|
"support_filters": {},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 1,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "None"
|
||||||
|
},
|
||||||
|
"opt_params": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"_description": "Regressão linear com remoção de janelas estáticas",
|
||||||
|
"experiment_run_id": 1007,
|
||||||
|
"variable_columns": [
|
||||||
|
"303-WIT-200(Value)"
|
||||||
|
],
|
||||||
|
"target_variable": "03CV020/CORRENTE_N_M1_PV(Value)",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training_data.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "timestamp",
|
||||||
|
"date_format": "yyyy-MM-dd HH:mm:ss",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "Linear Regression",
|
||||||
|
"model_type": "linear_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"rem_static_win": true,
|
||||||
|
"static_threshold": null,
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null,
|
||||||
|
"support_filters": {},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 1,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "None"
|
||||||
|
},
|
||||||
|
"opt_params": {}
|
||||||
|
}
|
||||||
45
docs/test-scenarios/08-linear-regression-with-limits.json
Normal file
45
docs/test-scenarios/08-linear-regression-with-limits.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"_description": "Regressão linear com limites inferior e superior para variáveis",
|
||||||
|
"experiment_run_id": 1008,
|
||||||
|
"variable_columns": [
|
||||||
|
"303-WIT-200(Value)"
|
||||||
|
],
|
||||||
|
"target_variable": "03CV020/CORRENTE_N_M1_PV(Value)",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training_data.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "timestamp",
|
||||||
|
"date_format": "yyyy-MM-dd HH:mm:ss",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "Linear Regression",
|
||||||
|
"model_type": "linear_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"rem_static_win": false,
|
||||||
|
"static_threshold": null,
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null,
|
||||||
|
"support_filters": {
|
||||||
|
"303-WIT-200(Value)": {
|
||||||
|
"min": 0.0,
|
||||||
|
"max": 1000.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 1,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "None"
|
||||||
|
},
|
||||||
|
"opt_params": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"_description": "Cenário completo: regressão polinomial grau 2 com scaler e lags",
|
||||||
|
"experiment_run_id": 1009,
|
||||||
|
"variable_columns": [
|
||||||
|
"303-WIT-200(Value)"
|
||||||
|
],
|
||||||
|
"target_variable": "03CV020/CORRENTE_N_M1_PV(Value)",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training_data.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "timestamp",
|
||||||
|
"date_format": "yyyy-MM-dd HH:mm:ss",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "Polynomial Regression",
|
||||||
|
"model_type": "polynomial_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"303-WIT-200(Value)": 3
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"303-WIT-200(Value)": 2
|
||||||
|
},
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"rem_static_win": false,
|
||||||
|
"static_threshold": null,
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null,
|
||||||
|
"support_filters": {},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 2,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "Standard Scaler"
|
||||||
|
},
|
||||||
|
"opt_params": {}
|
||||||
|
}
|
||||||
42
docs/test-scenarios/10-linear-regression-with-ar.json
Normal file
42
docs/test-scenarios/10-linear-regression-with-ar.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"_description": "Linear regression placeholder for autoregressive features; include_ar is reserved for future wrapper support (see opt_params).",
|
||||||
|
"experiment_run_id": 1010,
|
||||||
|
"variable_columns": [
|
||||||
|
"303-WIT-200(Value)"
|
||||||
|
],
|
||||||
|
"target_variable": "03CV020/CORRENTE_N_M1_PV(Value)",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training_data.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "timestamp",
|
||||||
|
"date_format": "yyyy-MM-dd HH:mm:ss",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "Linear Regression",
|
||||||
|
"model_type": "linear_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"rem_static_win": false,
|
||||||
|
"static_threshold": null,
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null,
|
||||||
|
"support_filters": {},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 1,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "None"
|
||||||
|
},
|
||||||
|
"opt_params": {
|
||||||
|
"include_ar": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"_description": "Regressão linear com remoção de janelas estáticas e static_threshold customizado",
|
||||||
|
"experiment_run_id": 1011,
|
||||||
|
"variable_columns": [
|
||||||
|
"303-WIT-200(Value)"
|
||||||
|
],
|
||||||
|
"target_variable": "03CV020/CORRENTE_N_M1_PV(Value)",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training_data.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "timestamp",
|
||||||
|
"date_format": "yyyy-MM-dd HH:mm:ss",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "Linear Regression",
|
||||||
|
"model_type": "linear_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"rem_static_win": true,
|
||||||
|
"static_threshold": 100,
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null,
|
||||||
|
"support_filters": {},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 1,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "None"
|
||||||
|
},
|
||||||
|
"opt_params": {}
|
||||||
|
}
|
||||||
40
docs/test-scenarios/12-angular-test-date-format.json
Normal file
40
docs/test-scenarios/12-angular-test-date-format.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"_description": "Alternate date column (DATA) and dd/MM/yyyy HH:mm:ss format; uses MinIO object training_data_dd_mm_yyyy.csv from E2E fixtures.",
|
||||||
|
"experiment_run_id": 1012,
|
||||||
|
"variable_columns": [
|
||||||
|
"303-WIT-230(Value)"
|
||||||
|
],
|
||||||
|
"target_variable": "03CV022/CORRENTE_N_M1_PV(Value)",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training_data_dd_mm_yyyy.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "DATA",
|
||||||
|
"date_format": "dd/MM/yyyy HH:mm:ss",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "Linear Regression",
|
||||||
|
"model_type": "linear_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"303-WIT-230(Value)": 3
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"303-WIT-230(Value)": 0
|
||||||
|
},
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"rem_static_win": false,
|
||||||
|
"static_threshold": null,
|
||||||
|
"start_date": "01/05/2022",
|
||||||
|
"end_date": "31/07/2022",
|
||||||
|
"support_filters": {},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 1,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "None"
|
||||||
|
},
|
||||||
|
"opt_params": {}
|
||||||
|
}
|
||||||
40
docs/test-scenarios/13-angular-test-double-date-column.json
Normal file
40
docs/test-scenarios/13-angular-test-double-date-column.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"_description": "Same alternate CSV as scenario 12 (DATA + dd/MM/yyyy); narrow date window for regression coverage. Not a multi-date-column dataset.",
|
||||||
|
"experiment_run_id": 1013,
|
||||||
|
"variable_columns": [
|
||||||
|
"303-WIT-230(Value)"
|
||||||
|
],
|
||||||
|
"target_variable": "03CV022/CORRENTE_N_M1_PV(Value)",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training_data_dd_mm_yyyy.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "DATA",
|
||||||
|
"date_format": "dd/MM/yyyy HH:mm:ss",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "Linear Regression",
|
||||||
|
"model_type": "linear_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"303-WIT-230(Value)": 0
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"303-WIT-230(Value)": 0
|
||||||
|
},
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"rem_static_win": false,
|
||||||
|
"static_threshold": null,
|
||||||
|
"start_date": "01/05/2022 00:00:00",
|
||||||
|
"end_date": "31/05/2022 23:59:59",
|
||||||
|
"support_filters": {},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 1,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "None"
|
||||||
|
},
|
||||||
|
"opt_params": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"_description": "Cenário angular-test-01: regressão polinomial degree 4, scaler, support filters em 303-WIT-200",
|
||||||
|
"experiment_run_id": 1014,
|
||||||
|
"variable_columns": [
|
||||||
|
"303-WIT-200(Value)"
|
||||||
|
],
|
||||||
|
"target_variable": "03CV020/CORRENTE_N_M1_PV(Value)",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training_data.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "timestamp",
|
||||||
|
"date_format": "yyyy-MM-dd HH:mm:ss",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "Polynomial Regression",
|
||||||
|
"model_type": "polynomial_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"rem_static_win": false,
|
||||||
|
"static_threshold": null,
|
||||||
|
"start_date": "2025-06-02 00:00:00",
|
||||||
|
"end_date": "2025-06-08 23:59:59",
|
||||||
|
"support_filters": {
|
||||||
|
"303-WIT-200(Value)": {
|
||||||
|
"upper_line": {
|
||||||
|
"intercept": 40.400002,
|
||||||
|
"angle": 0
|
||||||
|
},
|
||||||
|
"lower_line": {
|
||||||
|
"intercept": 30.5,
|
||||||
|
"angle": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 4,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "Standard Scaler"
|
||||||
|
},
|
||||||
|
"opt_params": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"_description": "Target column name is not ``target``; report/Evidently sections must use params.target_variable.",
|
||||||
|
"experiment_run_id": 1015,
|
||||||
|
"variable_columns": [
|
||||||
|
"303-WIT-200(Value)"
|
||||||
|
],
|
||||||
|
"target_variable": "MY_CUSTOM_TARGET_COLUMN",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training_data_custom_target.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "timestamp",
|
||||||
|
"date_format": "yyyy-MM-dd HH:mm:ss",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "Linear Regression",
|
||||||
|
"model_type": "linear_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"rem_static_win": false,
|
||||||
|
"static_threshold": null,
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null,
|
||||||
|
"support_filters": {},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 1,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "None"
|
||||||
|
},
|
||||||
|
"opt_params": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"_description": "Naive Timestamp column header; snake_case date_column/date_format and training_data_timestamp_naive.csv.",
|
||||||
|
"experiment_run_id": 1016,
|
||||||
|
"variable_columns": [
|
||||||
|
"303-WIT-200(Value)"
|
||||||
|
],
|
||||||
|
"target_variable": "03CV020/CORRENTE_N_M1_PV(Value)",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training_data_timestamp_naive.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "Timestamp",
|
||||||
|
"date_format": "yyyy-MM-dd HH:mm:ss",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "Linear Regression",
|
||||||
|
"model_type": "linear_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"rem_static_win": false,
|
||||||
|
"static_threshold": null,
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null,
|
||||||
|
"support_filters": {},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 1,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "None"
|
||||||
|
},
|
||||||
|
"opt_params": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"_description": "One CSV row has an empty timestamp; pipeline should drop it and continue training.",
|
||||||
|
"experiment_run_id": 1017,
|
||||||
|
"variable_columns": [
|
||||||
|
"303-WIT-200(Value)"
|
||||||
|
],
|
||||||
|
"target_variable": "03CV020/CORRENTE_N_M1_PV(Value)",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training_data_blank_timestamp_row.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "timestamp",
|
||||||
|
"date_format": "yyyy-MM-dd HH:mm:ss",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "Linear Regression",
|
||||||
|
"model_type": "linear_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"303-WIT-200(Value)": 0
|
||||||
|
},
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"rem_static_win": false,
|
||||||
|
"static_threshold": null,
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null,
|
||||||
|
"support_filters": {},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 1,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "None"
|
||||||
|
},
|
||||||
|
"opt_params": {}
|
||||||
|
}
|
||||||
417
docs/train-model-workflow-io-diff-main-vs-current-branch.md
Normal file
417
docs/train-model-workflow-io-diff-main-vs-current-branch.md
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
# Train Model Workflow IO Diff (`main` vs current branch)
|
||||||
|
|
||||||
|
Base comparison: `git diff main...HEAD`
|
||||||
|
Workflow analyzed: `train_model`
|
||||||
|
|
||||||
|
## 1) Executive overview
|
||||||
|
|
||||||
|
This branch introduces a structural refactor of the training stack and a contract update for workflow input/output.
|
||||||
|
|
||||||
|
Main impacts:
|
||||||
|
|
||||||
|
- The old in-house training stack (`TrainingRepository`, `ModelRepository`, `StorageRepository`, `model_manager.sientia.models`) was replaced by:
|
||||||
|
- `DataManagerRepository` (data prep + metrics + report generation)
|
||||||
|
- `SientiaModel` wrapper from plugin store (`sientia_model`)
|
||||||
|
- `SientiaMLflowRepository` (MLflow integration)
|
||||||
|
- `MinioRepository` (storage integration)
|
||||||
|
- Input contract moved from many fixed legacy ML params to a plugin/wrapper-oriented schema (`model_type`, `*_kwargs`, `model_metadata`, optional `val_file_name`).
|
||||||
|
- Workflow return changed from `None` to a serializable result object (`dict[str, Any] | None`) containing training execution metadata.
|
||||||
|
- Queue naming and worker bootstrap architecture now depend on runtime (`train_model-<runtime>-queue`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Input contract diff (before vs now)
|
||||||
|
|
||||||
|
### 2.1 Previous contract (`main`)
|
||||||
|
|
||||||
|
`TrainModelParams` in `main` required a large set of explicit fields for the old preprocessing/model pipeline, focused only in linear regression model:
|
||||||
|
|
||||||
|
- Core:
|
||||||
|
- `experiment_run_id`, `variable_columns`, `target_variable`
|
||||||
|
- `bucket_name`, `file_name`, `line_separator`, `decimal_separator`
|
||||||
|
- `train_size`, `shuffle`
|
||||||
|
- Legacy preprocessing/model fields focused only in linear regression model (required in `from_dict`):
|
||||||
|
- `lag_train`, `lag_val`
|
||||||
|
- `rem_static_win`, `low_lim`, `upp_lim`, `window`
|
||||||
|
- `use_scaler`, `include_ar`, `scaler_name`
|
||||||
|
- `removed_intervals`, `start_date`, `end_date`, `nan_treatment`
|
||||||
|
- `degree`, `interaction_only`
|
||||||
|
- `experiment_name`, `model_name`
|
||||||
|
- `support_filters` (optional dict), `static_threshold` (optional int)
|
||||||
|
|
||||||
|
Validation was strongly tied to this structure (lag ranges, limits consistency, polynomial/scaler constraints, etc.).
|
||||||
|
|
||||||
|
### 2.2 Current contract (this branch)
|
||||||
|
|
||||||
|
`TrainModelParams` now supports a plugin-driven schema and wrapper kwargs:
|
||||||
|
|
||||||
|
- Kept/mandatory core fields:
|
||||||
|
- `experiment_run_id` (now accepts numeric string too; coerced to int)
|
||||||
|
- `variable_columns`, `target_variable`
|
||||||
|
- `bucket_name`, `file_name`, `line_separator`, `decimal_separator`
|
||||||
|
- `train_size`, `shuffle`
|
||||||
|
- `model_name`
|
||||||
|
- `model_type`
|
||||||
|
- `data_model_kwargs`, `model_kwargs`, `opt_params` (required as dict by current `from_dict`)
|
||||||
|
- New/updated fields:
|
||||||
|
- `random_state` (default `42`)
|
||||||
|
- `val_file_name` (optional explicit validation file)
|
||||||
|
- `model_id` (currently optional, but needs discussion, since the model metadata in MongoDB should be created before the model training)
|
||||||
|
- Removed from required input contract:
|
||||||
|
- `lag_train`, `lag_val`, `rem_static_win`, `low_lim`, `upp_lim`, `window`
|
||||||
|
- `use_scaler`, `include_ar`
|
||||||
|
- `degree`, `interaction_only`, `nan_treatment`
|
||||||
|
- `start_date`, `end_date`, `scaler_name`
|
||||||
|
- `removed_intervals`, `support_filters`, `static_threshold`
|
||||||
|
- Parameters internally derived:
|
||||||
|
- `model_metadata` model type info from plugin store.
|
||||||
|
- `run_name` is internally derived from experiment name and datetime.
|
||||||
|
- `experiment_name` is internally derived from `model_name`.
|
||||||
|
|
||||||
|
### 2.3 Validation behavior changes
|
||||||
|
|
||||||
|
Before:
|
||||||
|
- Validation was mostly hardcoded business checks tied to legacy linear/polynomial stack.
|
||||||
|
|
||||||
|
Now:
|
||||||
|
- Validation still checks core constraints (`train_size`, non-empty strings, etc.), but model-specific validation moved to JSON Schema driven checks, using OpenAPI/JSON Schema definitions from plugin store:
|
||||||
|
- `model_metadata.schemas.components.schemas.data_model`
|
||||||
|
- `model_metadata.schemas.components.schemas.model`
|
||||||
|
- `model_metadata.schemas.components.schemas.opt_params`
|
||||||
|
- `model_metadata` is now a required semantic dependency for `validate_business_rules()`.
|
||||||
|
- Date format validation remains, but allowed formats are defined locally in `train_model_params.py`.
|
||||||
|
|
||||||
|
### 2.4 Input loading pipeline changes in workflow
|
||||||
|
|
||||||
|
Before:
|
||||||
|
- `validate_train_params` directly consumed workflow input.
|
||||||
|
|
||||||
|
Now:
|
||||||
|
1. `load_model_metadata` runs first (fetches model index/schema from plugin store and injects `model_metadata`).
|
||||||
|
2. `validate_train_params` runs with enriched payload.
|
||||||
|
|
||||||
|
This means IO preprocessing now depends on plugin-store metadata resolution before final validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Output contract diff (before vs now)
|
||||||
|
|
||||||
|
### 3.1 Workflow return (`train_model.run`)
|
||||||
|
|
||||||
|
Before (`main`):
|
||||||
|
- Return type: `None`
|
||||||
|
- Workflow side effects were persisted mainly via DB status updates and MLflow artifacts.
|
||||||
|
|
||||||
|
Now:
|
||||||
|
- Return type: `dict[str, Any] | None`
|
||||||
|
- Workflow returns the training activity summary when successful.
|
||||||
|
|
||||||
|
### 3.2 Activity-level training result payload
|
||||||
|
|
||||||
|
Before (from `Training.train_model` in `main` path):
|
||||||
|
- Returned minimal dict:
|
||||||
|
- `run_name`
|
||||||
|
- `run_dir`
|
||||||
|
|
||||||
|
Now:
|
||||||
|
- Returns extended dict:
|
||||||
|
- `run_name`
|
||||||
|
- `experiment_name`
|
||||||
|
- `run_id`
|
||||||
|
- `run_dir`
|
||||||
|
|
||||||
|
### 3.3 Persistence map by destination (DB, MLflow, MinIO, local filesystem)
|
||||||
|
|
||||||
|
This section maps where each artifact/metadata goes, in which format, and how that changed from `main`.
|
||||||
|
|
||||||
|
#### 3.3.1 PostgreSQL (`experiment_run` table)
|
||||||
|
|
||||||
|
## Before (`main`)
|
||||||
|
|
||||||
|
- Update path: `update_experiment_run` activity with `UpdateType.MODEL_SAVED`.
|
||||||
|
- Persisted on success:
|
||||||
|
- `status` transition to `TRAINING_SUCCESS`
|
||||||
|
- `run_name` (MLflow run identifier used by current implementation)
|
||||||
|
- Persisted on failures:
|
||||||
|
- `status` transition to validation/training error statuses
|
||||||
|
- `error_message`
|
||||||
|
|
||||||
|
## Now (current branch)
|
||||||
|
|
||||||
|
- Same update path and status/error behavior.
|
||||||
|
- Even though train activity now returns more metadata (`run_id`, `experiment_name`), current workflow update for `MODEL_SAVED` still forwards mainly `run_name`.
|
||||||
|
- Practical effect:
|
||||||
|
- DB remains status-centric and run-name-centric
|
||||||
|
- richer identifiers exist in workflow return payload, not fully mirrored to DB columns in current flow
|
||||||
|
|
||||||
|
#### 3.3.2 MLflow (tracking server/artifact store)
|
||||||
|
|
||||||
|
## Before (`main`)
|
||||||
|
|
||||||
|
- Persistence orchestration lived in `ModelRepository.save_model()` + `_save_run()`.
|
||||||
|
- Typical persisted content:
|
||||||
|
- model params (many legacy params such as lags, limits, scaler config, removed intervals)
|
||||||
|
- regression metrics (`MSE`, `R2`, `MAE`)
|
||||||
|
- model objects:
|
||||||
|
- `data_model`
|
||||||
|
- `prediction_model`
|
||||||
|
- artifacts:
|
||||||
|
- `report.html`
|
||||||
|
- `train_data.csv`
|
||||||
|
- `test_data.csv`
|
||||||
|
- optional `model_equation.json`
|
||||||
|
- Run naming:
|
||||||
|
- computed by querying existing runs and appending sequence (`<experiment>-<n>` style)
|
||||||
|
|
||||||
|
## Now (current branch)
|
||||||
|
|
||||||
|
- Persistence orchestrated in `Training._persist_training_artifacts()` and MLflow run context is opened by `SientiaMLflowRepository.start_run(...)`.
|
||||||
|
- Persisted content now:
|
||||||
|
- model wrapper itself via `wrapper.store_model(name=train_params.model_name)`
|
||||||
|
- regression metrics also logged as MLflow params via `mlflow.log_param(...)`:
|
||||||
|
- `mse_val`
|
||||||
|
- `mae_val`
|
||||||
|
- `r2_val`
|
||||||
|
- artifacts explicitly logged with `mlflow.log_artifact(...)`:
|
||||||
|
- `report.html`
|
||||||
|
- `train_data.csv`
|
||||||
|
- `test_data.csv`
|
||||||
|
- metrics are computed before save (`mse_val`, `mae_val`, `r2_val`) and persisted in the run as params
|
||||||
|
- Run identifiers now exposed back to workflow:
|
||||||
|
- `experiment_name`
|
||||||
|
- `run_name`
|
||||||
|
- `run_id`
|
||||||
|
- Notable behavioral change:
|
||||||
|
- `wrapper._input_example` is cleared (`None`) before storing model.
|
||||||
|
|
||||||
|
#### 3.3.3 MinIO object storage
|
||||||
|
|
||||||
|
## Before (`main`)
|
||||||
|
|
||||||
|
- Read path:
|
||||||
|
- single source object downloaded via `StorageRepository.fetch_file(bucket_name, file_name)`
|
||||||
|
- Write path:
|
||||||
|
- training workflow did not write generated outputs to MinIO in this code path
|
||||||
|
- generated artifacts were persisted to MLflow, not uploaded back to MinIO
|
||||||
|
- Location:
|
||||||
|
- source data in input bucket/key provided by workflow input (`bucket_name` + `file_name`)
|
||||||
|
|
||||||
|
## Now (current branch)
|
||||||
|
|
||||||
|
- Read path migrated to `MinioRepository.download_file(...)`.
|
||||||
|
- Supports two input objects:
|
||||||
|
- mandatory training object: `bucket_name` + `file_name`
|
||||||
|
- optional validation object: same `bucket_name` + `val_file_name`
|
||||||
|
- Write path:
|
||||||
|
- still no artifact upload to MinIO in this workflow path
|
||||||
|
- report/CSV outputs continue to flow to MLflow artifacts
|
||||||
|
- Location details:
|
||||||
|
- bucket resolved from payload (`bucket_name`)
|
||||||
|
- object key exactly from payload (`file_name`, optional `val_file_name`)
|
||||||
|
- default bucket in env/config is `MINIO_DEFAULT_BUCKET`, but runtime payload can override via `bucket_name`
|
||||||
|
|
||||||
|
#### 3.3.4 Local filesystem (ephemeral runtime workspace)
|
||||||
|
|
||||||
|
## Before (`main`)
|
||||||
|
|
||||||
|
- Temporary run dir created under reports root using run name + timestamp suffix.
|
||||||
|
- Artifacts generated locally in that directory:
|
||||||
|
- `report.html`
|
||||||
|
- `train_data.csv`
|
||||||
|
- `test_data.csv`
|
||||||
|
- optional `model_equation.json`
|
||||||
|
- After MLflow logging, cleanup activity removed temp directory.
|
||||||
|
|
||||||
|
## Now (current branch)
|
||||||
|
|
||||||
|
- Temporary run dir managed by `DataManagerRepository` under runtime reports root (`.../reports/temp/<run_name>`).
|
||||||
|
- Same artifact family generated locally:
|
||||||
|
- `report.html`
|
||||||
|
- `train_data.csv`
|
||||||
|
- `test_data.csv`
|
||||||
|
- optional `model_equation.json` (for `linear_regression`)
|
||||||
|
- Cleanup behavior is now tolerant:
|
||||||
|
- cleanup runs in guarded `finally`
|
||||||
|
- training success is not reverted if cleanup later fails
|
||||||
|
|
||||||
|
#### 3.3.5 Quick matrix (before vs now)
|
||||||
|
|
||||||
|
- **Postgres**
|
||||||
|
- before: status + run_name + errors
|
||||||
|
- now: same persisted shape; workflow return contains extra IDs
|
||||||
|
- **MLflow**
|
||||||
|
- before: legacy model objects + params/metrics + report/data artifacts
|
||||||
|
- now: wrapper-based model persistence + `mse_val`/`mae_val`/`r2_val` as params + report/data artifacts + run_id exposed
|
||||||
|
- **MinIO**
|
||||||
|
- before: reads 1 CSV input object
|
||||||
|
- now: reads 1 or 2 CSV input objects (train + optional validation), still no output upload
|
||||||
|
- **Local temp**
|
||||||
|
- before: generated artifacts, then cleanup
|
||||||
|
- now: generated artifacts, then best-effort cleanup (non-blocking for success result)
|
||||||
|
|
||||||
|
### 3.4 Cleanup behavior impact on output semantics
|
||||||
|
|
||||||
|
Before:
|
||||||
|
- Cleanup was called directly after training result; failures propagated straightforwardly.
|
||||||
|
|
||||||
|
Now:
|
||||||
|
- Cleanup is in a guarded `finally`.
|
||||||
|
- If training succeeded but cleanup fails, workflow warns and does not rollback success semantics.
|
||||||
|
- Effective output semantics: successful training result can be returned even if temp cleanup fails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Detailed field mapping (old -> new)
|
||||||
|
|
||||||
|
## Kept (or equivalent role)
|
||||||
|
|
||||||
|
- `experiment_run_id` -> kept (broader accepted types: int or numeric string)
|
||||||
|
- `variable_columns` -> kept
|
||||||
|
- `target_variable` -> kept
|
||||||
|
- `bucket_name` -> kept
|
||||||
|
- `file_name` -> kept
|
||||||
|
- `line_separator` -> kept
|
||||||
|
- `decimal_separator` -> kept
|
||||||
|
- `date_column` -> required (snake_case key; must exist in CSV)
|
||||||
|
- `date_format` -> optional in payload; omitted/null/blank resolves to default `yyyy-MM-dd HH:mm:ss`
|
||||||
|
- `train_size` -> kept
|
||||||
|
- `shuffle` -> kept
|
||||||
|
- `model_name` -> kept (now less coupled to legacy model enum)
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- `model_type` (primary selector for plugin wrapper/index lookup)
|
||||||
|
- `data_model_kwargs`
|
||||||
|
- `model_kwargs`
|
||||||
|
- `opt_params`
|
||||||
|
- `val_file_name` (optional second dataset input)
|
||||||
|
- `model_id` (optional metadata)
|
||||||
|
- `model_metadata` (loaded/required for schema validation)
|
||||||
|
- `random_state` (explicit split reproducibility control)
|
||||||
|
|
||||||
|
## Removed from new required contract
|
||||||
|
|
||||||
|
- `lag_train`, `lag_val`
|
||||||
|
- `rem_static_win`, `static_threshold`
|
||||||
|
- `low_lim`, `upp_lim`
|
||||||
|
- `window`
|
||||||
|
- `use_scaler`, `include_ar`
|
||||||
|
- `degree`, `interaction_only`
|
||||||
|
- `nan_treatment`
|
||||||
|
- `start_date`, `end_date`
|
||||||
|
- `scaler_name`
|
||||||
|
- `removed_intervals`
|
||||||
|
- `support_filters`
|
||||||
|
- `experiment_name` (no longer required as top-level client input)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Internal architecture update notes
|
||||||
|
|
||||||
|
### 5.1 Repository layer redesign
|
||||||
|
|
||||||
|
Removed:
|
||||||
|
- `model_manager/utils/repository/model_repository.py`
|
||||||
|
- `model_manager/utils/repository/training_repository.py`
|
||||||
|
- `model_manager/utils/repository/storage_repository.py`
|
||||||
|
|
||||||
|
Added:
|
||||||
|
- `model_manager/utils/repository/data_manager_repository.py`
|
||||||
|
|
||||||
|
Interpretation:
|
||||||
|
- Data preprocessing/report/metrics responsibilities were consolidated into `DataManagerRepository`.
|
||||||
|
- Training/model persistence shifted to wrapper + plugin store + MLflow repository integrations.
|
||||||
|
|
||||||
|
### 5.2 Model engine abstraction migration
|
||||||
|
|
||||||
|
Before:
|
||||||
|
- Strong coupling to local classes in `model_manager.sientia.models` and custom preprocessing/model objects in `TrainModelResult`.
|
||||||
|
|
||||||
|
Now:
|
||||||
|
- Training uses `SientiaModel` wrapper dynamically obtained by `plugin_store.get_model(model_type=...)`.
|
||||||
|
- Contract is wrapper-driven (`train`, `transform`, `predict`, `store_model`).
|
||||||
|
- The codebase removed `model_manager/sientia/models.py`, `model_serving.py`, and `utils.py`, indicating full migration to externalized model runtime abstraction.
|
||||||
|
|
||||||
|
### 5.3 Worker/runtime architecture changes
|
||||||
|
|
||||||
|
- New `prepare_worker.py` centralizes worker setup and autoscaling parameters.
|
||||||
|
- Queue names are now runtime-derived:
|
||||||
|
- `train_model-<runtime>-queue`
|
||||||
|
- `cleanup_files-<runtime>-queue`
|
||||||
|
- `worker.py` now installs runtime via plugin store (`plugin_store.install_runtime(runtime_name=...)`) before starting workers.
|
||||||
|
- This introduces environment/runtime-aware deployment and model packaging behavior.
|
||||||
|
|
||||||
|
### 5.4 Synchronous activity and tracking adjustments
|
||||||
|
|
||||||
|
- `experiment_tracking` migrated from async postgres helper to sync postgres client path (`postgres_sync`).
|
||||||
|
- Several activities switched to sync method signatures.
|
||||||
|
- Error handling in workflow and DB status update paths is more defensive (secondary failures while persisting error status are logged and do not mask primary failure cause).
|
||||||
|
|
||||||
|
### 5.5 `TrainModelResult` shape update
|
||||||
|
|
||||||
|
Before:
|
||||||
|
- Stored classic split artifacts (`x_train`, `x_test`, `y_train`, `y_test`) + concrete preprocessing/model objects (`process_data`, `regr`, `scaler_dict`).
|
||||||
|
|
||||||
|
Now:
|
||||||
|
- Stores `train_data`, `val_data` and prediction DataFrames, plus tracking identifiers (`experiment_name`, `run_id`).
|
||||||
|
- Result object is less tied to internal estimator classes and more aligned with serializable workflow/model-store integration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Net IO compatibility assessment
|
||||||
|
|
||||||
|
## Input compatibility
|
||||||
|
|
||||||
|
Not backward compatible with old payloads without adaptation.
|
||||||
|
|
||||||
|
Key reasons:
|
||||||
|
- Legacy required fields removed/ignored by new path.
|
||||||
|
- New required fields introduced (`model_type`, `*_kwargs` dicts, runtime metadata flow dependency).
|
||||||
|
- Validation pipeline now expects model metadata semantics.
|
||||||
|
|
||||||
|
## Output compatibility
|
||||||
|
|
||||||
|
Behavior changed:
|
||||||
|
- Workflow now returns a result object (previously `None`).
|
||||||
|
- Training summary includes `experiment_name` and `run_id` in addition to `run_name` and `run_dir`.
|
||||||
|
- DB update still centered on `run_name`; callers relying only on DB may not see all new output info unless workflow return is consumed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Practical migration guidance (client side)
|
||||||
|
|
||||||
|
To call `train_model` in this branch:
|
||||||
|
|
||||||
|
1. Send snake_case payload aligned to new `TrainModelParams`.
|
||||||
|
2. Always provide:
|
||||||
|
- `model_name` slugified model name (ex.: `test_model_name or test-model-name`)
|
||||||
|
- `model_type`
|
||||||
|
- `data_model_kwargs` (dict)
|
||||||
|
- `model_kwargs` (dict)
|
||||||
|
- `opt_params` (dict)
|
||||||
|
3. Keep `experiment_run_id` numeric (int or numeric string).
|
||||||
|
4. Use runtime queue naming consistent with worker runtime:
|
||||||
|
- `train_model-<runtime>-queue`
|
||||||
|
5. If you need explicit validation split file, send `val_file_name`; otherwise split uses `train_size`/`shuffle`/`random_state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Source references used for this document
|
||||||
|
|
||||||
|
Primary diffs:
|
||||||
|
- `model_manager/workflows/train_model.py`
|
||||||
|
- `model_manager/utils/models/train_model_params.py`
|
||||||
|
- `model_manager/utils/models/train_model_result.py`
|
||||||
|
- `model_manager/activities/training.py`
|
||||||
|
- `model_manager/activities/activities.py`
|
||||||
|
- `model_manager/activities/experiment_tracking.py`
|
||||||
|
- `model_manager/utils/repository/data_manager_repository.py`
|
||||||
|
- `model_manager/utils/repository/model_repository.py` (removed)
|
||||||
|
- `model_manager/utils/repository/training_repository.py` (removed)
|
||||||
|
- `model_manager/utils/repository/storage_repository.py` (removed)
|
||||||
|
- `model_manager/worker/worker.py`
|
||||||
|
- `model_manager/worker/prepare_worker.py`
|
||||||
|
- `README.md`
|
||||||
|
- `input-sample.md`
|
||||||
|
- `scripts/run_training_test.py`
|
||||||
|
|
||||||
3
e2e/__init__.py
Normal file
3
e2e/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
End-to-end tests for the Model Manager Temporal workflows.
|
||||||
|
"""
|
||||||
697
e2e/conftest.py
Normal file
697
e2e/conftest.py
Normal file
@@ -0,0 +1,697 @@
|
|||||||
|
"""
|
||||||
|
Pytest configuration and fixtures for E2E tests.
|
||||||
|
|
||||||
|
External dependencies use testcontainers or real SDK integrations (no mocks of
|
||||||
|
model_manager or other first-party code):
|
||||||
|
|
||||||
|
- PostgreSQL, MinIO, MongoDB, Gitea: testcontainers.
|
||||||
|
- MLflow: real client with ``file://`` tracking URI (no MLflow server process).
|
||||||
|
- Temporal: ``WorkflowEnvironment.start_time_skipping()`` — official in-process
|
||||||
|
test runtime from temporalio; exercises real workflows and activity code, not
|
||||||
|
stubs of business logic.
|
||||||
|
- Observability: ``Logger`` (``get_logger`` from ``model_manager.utils.logger_helper``)
|
||||||
|
and ``MetricsController`` from sientia_do, same stack as production.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
import base64
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
import mlflow
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
import requests
|
||||||
|
from minio import Minio
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from testcontainers.core.container import DockerContainer # type: ignore[import,import-untyped]
|
||||||
|
from testcontainers.minio import MinioContainer # type: ignore[import-untyped]
|
||||||
|
from testcontainers.mongodb import MongoDbContainer # type: ignore[import-untyped]
|
||||||
|
from testcontainers.postgres import PostgresContainer # type: ignore[import,import-untyped]
|
||||||
|
from temporalio.testing import WorkflowEnvironment
|
||||||
|
from temporalio.worker import Worker
|
||||||
|
|
||||||
|
from model_manager.activities.activities import Activities
|
||||||
|
from model_manager.utils.logger_helper import get_logger
|
||||||
|
from model_manager.workflows.cleanup_files import CleanupFiles
|
||||||
|
from model_manager.workflows.train_model import TrainModel
|
||||||
|
from sientia_do.notifications.handlers import CoreNotificationHandler
|
||||||
|
from sientia_do.observability.metrics_controller import MetricsController
|
||||||
|
from sientia_model.model_repository.plugin_store import PluginStore
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CSV training data: columns must match the variable_columns and target_variable
|
||||||
|
# used across all test scenarios.
|
||||||
|
_TRAIN_CSV_COLUMNS = [
|
||||||
|
'timestamp',
|
||||||
|
'303-WIT-200(Value)',
|
||||||
|
'03CV020/CORRENTE_N_M1_PV(Value)',
|
||||||
|
'303-WIT-230(Value)',
|
||||||
|
'03CV022/CORRENTE_N_M1_PV(Value)',
|
||||||
|
]
|
||||||
|
_MINIO_BUCKET = 'model-training'
|
||||||
|
_MINIO_OBJECT = 'training_data.csv'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers – CSV generation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_training_csv() -> bytes:
|
||||||
|
"""
|
||||||
|
Generate a 150-row CSV with all columns needed by test scenarios.
|
||||||
|
|
||||||
|
The numeric values cycle deterministically so lags and static-window
|
||||||
|
removal always find enough rows in both train and validation splits.
|
||||||
|
"""
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(_TRAIN_CSV_COLUMNS)
|
||||||
|
for i in range(150):
|
||||||
|
ts = f'2025-06-{(i // 24) + 2:02d} {i % 24:02d}:00:00'
|
||||||
|
wit200 = round(30.0 + (i % 20) * 0.5, 2)
|
||||||
|
cv020 = round(100.0 + (i % 15) * 0.3, 2)
|
||||||
|
wit230 = round(25.0 + (i % 18) * 0.4, 2)
|
||||||
|
cv022 = round(90.0 + (i % 12) * 0.25, 2)
|
||||||
|
writer.writerow([ts, wit200, cv020, wit230, cv022])
|
||||||
|
return output.getvalue().encode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def _build_training_csv_dd_mm_yyyy() -> bytes:
|
||||||
|
"""
|
||||||
|
Generate a 150-row CSV with dd/MM/yyyy HH:mm:ss timestamps and
|
||||||
|
a DATA column header, for scenarios 12/13 that use a different date format.
|
||||||
|
"""
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow([
|
||||||
|
'DATA',
|
||||||
|
'303-WIT-230(Value)',
|
||||||
|
'03CV022/CORRENTE_N_M1_PV(Value)',
|
||||||
|
])
|
||||||
|
for i in range(150):
|
||||||
|
day = (i % 30) + 1
|
||||||
|
ts = f'{day:02d}/05/2022 {i % 24:02d}:00:00'
|
||||||
|
wit230 = round(25.0 + (i % 18) * 0.4, 2)
|
||||||
|
cv022 = round(90.0 + (i % 12) * 0.25, 2)
|
||||||
|
writer.writerow([ts, wit230, cv022])
|
||||||
|
return output.getvalue().encode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def _build_training_csv_custom_target_column() -> bytes:
|
||||||
|
"""
|
||||||
|
Same layout as the standard CSV but the target column has a non-default name
|
||||||
|
(not ``target``) to exercise report and metrics paths.
|
||||||
|
"""
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow([
|
||||||
|
'timestamp',
|
||||||
|
'303-WIT-200(Value)',
|
||||||
|
'MY_CUSTOM_TARGET_COLUMN',
|
||||||
|
])
|
||||||
|
for i in range(150):
|
||||||
|
ts = f'2025-06-{(i // 24) + 2:02d} {i % 24:02d}:00:00'
|
||||||
|
wit200 = round(30.0 + (i % 20) * 0.5, 2)
|
||||||
|
target_val = round(100.0 + (i % 15) * 0.3, 2)
|
||||||
|
writer.writerow([ts, wit200, target_val])
|
||||||
|
return output.getvalue().encode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def _build_training_csv_timestamp_header_naive() -> bytes:
|
||||||
|
"""
|
||||||
|
Naive datetimes under column ``Timestamp`` (common UI export) for scenario 16.
|
||||||
|
"""
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow([
|
||||||
|
'Timestamp',
|
||||||
|
'303-WIT-200(Value)',
|
||||||
|
'03CV020/CORRENTE_N_M1_PV(Value)',
|
||||||
|
])
|
||||||
|
for i in range(150):
|
||||||
|
ts = f'2025-06-{(i // 24) + 2:02d} {i % 24:02d}:00:00'
|
||||||
|
wit200 = round(30.0 + (i % 20) * 0.5, 2)
|
||||||
|
cv020 = round(100.0 + (i % 15) * 0.3, 2)
|
||||||
|
writer.writerow([ts, wit200, cv020])
|
||||||
|
return output.getvalue().encode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def _build_training_csv_blank_timestamp_row() -> bytes:
|
||||||
|
"""Standard columns with one row where ``timestamp`` is empty (NaN after parse)."""
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(_TRAIN_CSV_COLUMNS)
|
||||||
|
for i in range(150):
|
||||||
|
wit200 = round(30.0 + (i % 20) * 0.5, 2)
|
||||||
|
cv020 = round(100.0 + (i % 15) * 0.3, 2)
|
||||||
|
wit230 = round(25.0 + (i % 18) * 0.4, 2)
|
||||||
|
cv022 = round(90.0 + (i % 12) * 0.25, 2)
|
||||||
|
if i == 17:
|
||||||
|
writer.writerow(['', wit200, cv020, wit230, cv022])
|
||||||
|
else:
|
||||||
|
ts = f'2025-06-{(i // 24) + 2:02d} {i % 24:02d}:00:00'
|
||||||
|
writer.writerow([ts, wit200, cv020, wit230, cv022])
|
||||||
|
return output.getvalue().encode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers – Gitea seed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _wait_for_gitea(base_url: str, timeout: int = 120) -> None:
|
||||||
|
"""Poll Gitea until it responds to HTTP requests."""
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
last_err = None
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
resp = requests.get(f'{base_url}/', timeout=3)
|
||||||
|
if resp.status_code in (200, 404, 302):
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
time.sleep(2)
|
||||||
|
raise TimeoutError(f'Gitea did not start within {timeout}s at {base_url}. Last error: {last_err}')
|
||||||
|
|
||||||
|
|
||||||
|
def _gitea_api(method: str, url: str, auth: tuple, **kwargs) -> requests.Response:
|
||||||
|
resp = requests.request(method, url, auth=auth, timeout=30, **kwargs)
|
||||||
|
try:
|
||||||
|
resp.raise_for_status()
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
raise RuntimeError(f"Gitea API error {resp.status_code}: {resp.text}") from e
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_gitea(base_url: str, admin_user: str, admin_pass: str) -> None:
|
||||||
|
"""
|
||||||
|
Create a fictitious model-store repository with dummy models.
|
||||||
|
"""
|
||||||
|
auth = (admin_user, admin_pass)
|
||||||
|
api = f'{base_url}/api/v1'
|
||||||
|
|
||||||
|
# Create repository
|
||||||
|
_gitea_api(
|
||||||
|
'POST', f'{api}/user/repos', auth,
|
||||||
|
json={'name': 'model-store', 'private': False, 'auto_init': False},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Root index.yaml
|
||||||
|
root_index = """
|
||||||
|
store_name: "E2E Test Store"
|
||||||
|
version: 1
|
||||||
|
models:
|
||||||
|
- name: "linear_regression"
|
||||||
|
version: 1
|
||||||
|
runtime: "basic"
|
||||||
|
- name: "polynomial_regression"
|
||||||
|
version: 1
|
||||||
|
runtime: "basic"
|
||||||
|
runtimes:
|
||||||
|
basic:
|
||||||
|
version: "1.0.0"
|
||||||
|
libraries:
|
||||||
|
- name: "pandas"
|
||||||
|
- name: "numpy"
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Model index.yaml (shared for all dummies)
|
||||||
|
model_index = """
|
||||||
|
name: "{model_name}"
|
||||||
|
version: 1
|
||||||
|
runtime: "basic"
|
||||||
|
path: "wrapper.py"
|
||||||
|
class: "DummyWrapper"
|
||||||
|
model:
|
||||||
|
class: "DummyModel"
|
||||||
|
path: "model_logic.py"
|
||||||
|
external: false
|
||||||
|
data_model:
|
||||||
|
class: "DummyTransformer"
|
||||||
|
path: "model_logic.py"
|
||||||
|
external: false
|
||||||
|
"""
|
||||||
|
|
||||||
|
# schemas.yaml
|
||||||
|
schemas_yaml = """
|
||||||
|
model:
|
||||||
|
type: object
|
||||||
|
properties: {}
|
||||||
|
data_model:
|
||||||
|
type: object
|
||||||
|
properties: {}
|
||||||
|
opt_params:
|
||||||
|
type: object
|
||||||
|
properties: {}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# wrapper.py
|
||||||
|
wrapper_py = """
|
||||||
|
from sientia_model.wrappers.sientia_model import SientiaModel
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
class DummyWrapper(SientiaModel):
|
||||||
|
def _predict(self, data: pd.DataFrame) -> tuple[pd.DataFrame, dict[str, Any]]:
|
||||||
|
self._log("info", f"Predicting dummy model for {self.model_type}")
|
||||||
|
# Return a simple prediction (mean or 0.5) to allow metrics computation
|
||||||
|
preds = pd.DataFrame({self.target: [0.5] * len(data)}, index=data.index)
|
||||||
|
return preds, {}
|
||||||
|
|
||||||
|
def _transform(self, data: pd.DataFrame) -> tuple[pd.DataFrame, dict[str, Any]]:
|
||||||
|
return data, {}
|
||||||
|
|
||||||
|
def _train_transformer(self, train_data: pd.DataFrame, val_data: pd.DataFrame) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _train_model(self, x: pd.DataFrame, y: pd.DataFrame, x_val: pd.DataFrame | None = None, y_val: pd.DataFrame | None = None) -> None:
|
||||||
|
self.target = y.columns[0]
|
||||||
|
|
||||||
|
def _retrain_transformer(self, data: pd.DataFrame) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _retrain_model(self, x: pd.DataFrame, y: pd.DataFrame | None) -> None:
|
||||||
|
pass
|
||||||
|
"""
|
||||||
|
|
||||||
|
# model_logic.py
|
||||||
|
model_logic_py = """
|
||||||
|
class DummyModel:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DummyTransformer:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
pass
|
||||||
|
"""
|
||||||
|
|
||||||
|
# requirements.txt required by current model packaging path in training activity.
|
||||||
|
requirements_txt = """
|
||||||
|
pandas
|
||||||
|
numpy
|
||||||
|
"""
|
||||||
|
|
||||||
|
def push_file(path: str, content: str):
|
||||||
|
encoded = base64.b64encode(content.encode()).decode()
|
||||||
|
_gitea_api(
|
||||||
|
'POST',
|
||||||
|
f'{api}/repos/{admin_user}/model-store/contents/{path}',
|
||||||
|
auth,
|
||||||
|
json={'message': f'seed: {path}', 'content': encoded},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Push root index
|
||||||
|
push_file('index.yaml', root_index)
|
||||||
|
|
||||||
|
# Push files for both models used in tests
|
||||||
|
for model_name in ['linear_regression', 'polynomial_regression']:
|
||||||
|
prefix = f'models/{model_name}'
|
||||||
|
push_file(f'{prefix}/index.yaml', model_index.format(model_name=model_name))
|
||||||
|
push_file(f'{prefix}/schemas.yaml', schemas_yaml)
|
||||||
|
push_file(f'{prefix}/wrapper.py', wrapper_py)
|
||||||
|
push_file(f'{prefix}/model_logic.py', model_logic_py)
|
||||||
|
push_file(f'{prefix}/requirements.txt', requirements_txt.strip() + '\n')
|
||||||
|
push_file(f'{prefix}/__init__.py', "")
|
||||||
|
|
||||||
|
# Push runtime
|
||||||
|
push_file('runtime/basic.yaml', 'name: basic\nversion: "1.0.0"\nlibraries: []')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session-scoped containers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture(scope='session')
|
||||||
|
def postgres_container():
|
||||||
|
"""PostgreSQL 15 container for experiment_run table."""
|
||||||
|
container = PostgresContainer('postgres:15')
|
||||||
|
container.start()
|
||||||
|
yield container
|
||||||
|
container.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope='session')
|
||||||
|
def minio_container():
|
||||||
|
"""MinIO container for training CSV storage."""
|
||||||
|
container = MinioContainer()
|
||||||
|
container.start()
|
||||||
|
yield container
|
||||||
|
container.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope='session')
|
||||||
|
def mongodb_container():
|
||||||
|
"""MongoDB container for CoreNotificationHandler."""
|
||||||
|
container = MongoDbContainer('mongo:7')
|
||||||
|
container.start()
|
||||||
|
yield container
|
||||||
|
container.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope='session')
|
||||||
|
def gitea_container():
|
||||||
|
"""
|
||||||
|
Gitea container with a ``model-store`` repo seeded via REST API
|
||||||
|
(``index.yaml``, dummy model files, ``_seed_gitea``).
|
||||||
|
|
||||||
|
The container starts with INSTALL_LOCK so no setup wizard is needed.
|
||||||
|
An admin user is created via Gitea's CLI before the HTTP API is used.
|
||||||
|
"""
|
||||||
|
admin_user = 'gitea_admin'
|
||||||
|
admin_pass = 'gitea_admin_pass' # noqa: S105
|
||||||
|
|
||||||
|
container = (
|
||||||
|
DockerContainer('gitea/gitea:latest')
|
||||||
|
.with_env('GITEA__security__INSTALL_LOCK', 'true')
|
||||||
|
.with_env('GITEA__server__HTTP_PORT', '3000')
|
||||||
|
.with_env('GITEA__log__LEVEL', 'Warn')
|
||||||
|
.with_exposed_ports(3000)
|
||||||
|
)
|
||||||
|
container.start()
|
||||||
|
|
||||||
|
port = container.get_exposed_port(3000)
|
||||||
|
base_url = f'http://localhost:{port}'
|
||||||
|
|
||||||
|
_wait_for_gitea(base_url)
|
||||||
|
time.sleep(5) # Wait a bit for DB to fully initialize after HTTP is up
|
||||||
|
|
||||||
|
# Create admin user via Gitea CLI inside the container
|
||||||
|
# Must run after Gitea is fully initialized
|
||||||
|
gitea_cmd = (
|
||||||
|
f'gitea admin user create '
|
||||||
|
f'--username {admin_user} '
|
||||||
|
f'--password {admin_pass} '
|
||||||
|
f'--email admin@test.local '
|
||||||
|
f'--admin '
|
||||||
|
f'--must-change-password=false'
|
||||||
|
)
|
||||||
|
exec_result = container.exec(f"su git -c '{gitea_cmd}'")
|
||||||
|
if exec_result.exit_code != 0:
|
||||||
|
raise RuntimeError(f"Failed to create Gitea admin user: {exec_result.output.decode('utf-8')}")
|
||||||
|
|
||||||
|
_seed_gitea(base_url, admin_user, admin_pass)
|
||||||
|
|
||||||
|
yield {
|
||||||
|
'container': container,
|
||||||
|
'base_url': base_url,
|
||||||
|
'admin_user': admin_user,
|
||||||
|
'admin_pass': admin_pass,
|
||||||
|
}
|
||||||
|
|
||||||
|
container.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope='session')
|
||||||
|
def mlflow_tracking_dir():
|
||||||
|
"""Local MLflow filesystem tracking directory (no network needed)."""
|
||||||
|
tmpdir = tempfile.mkdtemp(prefix='mlflow-e2e-')
|
||||||
|
mlflow.set_tracking_uri(f'file://{tmpdir}')
|
||||||
|
yield tmpdir
|
||||||
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope='session', autouse=True)
|
||||||
|
def e2e_runtime_reports_dir():
|
||||||
|
"""
|
||||||
|
Route runtime report artifacts to a writable temp directory during E2E.
|
||||||
|
|
||||||
|
Production defaults point to /var/lib/model-manager; in local CI/dev runs this
|
||||||
|
path may be unavailable. This fixture keeps the same code paths while avoiding
|
||||||
|
host permission issues.
|
||||||
|
"""
|
||||||
|
import model_manager.runtime_paths as runtime_paths
|
||||||
|
import model_manager.utils.repository.data_manager_repository as data_repo_module
|
||||||
|
|
||||||
|
base_dir = tempfile.mkdtemp(prefix='model-manager-e2e-runtime-')
|
||||||
|
reports_root = f'{base_dir}/reports'
|
||||||
|
reports_temp_dir = f'{reports_root}/temp'
|
||||||
|
project_base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'model_manager'))
|
||||||
|
|
||||||
|
old_runtime_reports_root = runtime_paths.REPORTS_ROOT
|
||||||
|
old_runtime_reports_temp = runtime_paths.REPORTS_TEMP_DIR
|
||||||
|
old_runtime_project_base = runtime_paths.PROJECT_BASE_PATH
|
||||||
|
old_repo_reports_root = data_repo_module.REPORTS_ROOT
|
||||||
|
old_repo_project_base = data_repo_module.PROJECT_BASE_PATH
|
||||||
|
|
||||||
|
runtime_paths.REPORTS_ROOT = reports_root
|
||||||
|
runtime_paths.REPORTS_TEMP_DIR = reports_temp_dir
|
||||||
|
runtime_paths.PROJECT_BASE_PATH = project_base_path
|
||||||
|
data_repo_module.REPORTS_ROOT = reports_root
|
||||||
|
data_repo_module.PROJECT_BASE_PATH = project_base_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield reports_root
|
||||||
|
finally:
|
||||||
|
runtime_paths.REPORTS_ROOT = old_runtime_reports_root
|
||||||
|
runtime_paths.REPORTS_TEMP_DIR = old_runtime_reports_temp
|
||||||
|
runtime_paths.PROJECT_BASE_PATH = old_runtime_project_base
|
||||||
|
data_repo_module.REPORTS_ROOT = old_repo_reports_root
|
||||||
|
data_repo_module.PROJECT_BASE_PATH = old_repo_project_base
|
||||||
|
shutil.rmtree(base_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session-scoped: seed MinIO with training CSV
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture(scope='session', autouse=True)
|
||||||
|
def upload_training_csv(minio_container, mlflow_tracking_dir): # noqa: ARG001
|
||||||
|
"""
|
||||||
|
Upload training CSV files to the MinIO container before any test runs.
|
||||||
|
Depends on mlflow_tracking_dir to ensure the MLflow URI is set at session start.
|
||||||
|
"""
|
||||||
|
port = minio_container.get_exposed_port(9000)
|
||||||
|
client = Minio(
|
||||||
|
f'localhost:{port}',
|
||||||
|
access_key='minioadmin',
|
||||||
|
secret_key='minioadmin',
|
||||||
|
secure=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not client.bucket_exists(_MINIO_BUCKET):
|
||||||
|
client.make_bucket(_MINIO_BUCKET)
|
||||||
|
|
||||||
|
# Standard training CSV
|
||||||
|
csv_bytes = _build_training_csv()
|
||||||
|
client.put_object(
|
||||||
|
_MINIO_BUCKET,
|
||||||
|
_MINIO_OBJECT,
|
||||||
|
io.BytesIO(csv_bytes),
|
||||||
|
length=len(csv_bytes),
|
||||||
|
content_type='text/csv',
|
||||||
|
)
|
||||||
|
|
||||||
|
# dd/MM/yyyy format CSV for scenarios 12/13
|
||||||
|
alt_csv_bytes = _build_training_csv_dd_mm_yyyy()
|
||||||
|
client.put_object(
|
||||||
|
_MINIO_BUCKET,
|
||||||
|
'training_data_dd_mm_yyyy.csv',
|
||||||
|
io.BytesIO(alt_csv_bytes),
|
||||||
|
length=len(alt_csv_bytes),
|
||||||
|
content_type='text/csv',
|
||||||
|
)
|
||||||
|
|
||||||
|
custom_target = _build_training_csv_custom_target_column()
|
||||||
|
client.put_object(
|
||||||
|
_MINIO_BUCKET,
|
||||||
|
'training_data_custom_target.csv',
|
||||||
|
io.BytesIO(custom_target),
|
||||||
|
length=len(custom_target),
|
||||||
|
content_type='text/csv',
|
||||||
|
)
|
||||||
|
|
||||||
|
ts_header = _build_training_csv_timestamp_header_naive()
|
||||||
|
client.put_object(
|
||||||
|
_MINIO_BUCKET,
|
||||||
|
'training_data_timestamp_naive.csv',
|
||||||
|
io.BytesIO(ts_header),
|
||||||
|
length=len(ts_header),
|
||||||
|
content_type='text/csv',
|
||||||
|
)
|
||||||
|
|
||||||
|
blank_ts = _build_training_csv_blank_timestamp_row()
|
||||||
|
client.put_object(
|
||||||
|
_MINIO_BUCKET,
|
||||||
|
'training_data_blank_timestamp_row.csv',
|
||||||
|
io.BytesIO(blank_ts),
|
||||||
|
length=len(blank_ts),
|
||||||
|
content_type='text/csv',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Function-scoped: database engine + schema setup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def postgres_engine(postgres_container):
|
||||||
|
"""SQLAlchemy engine connected to the test PostgreSQL container."""
|
||||||
|
engine = create_engine(postgres_container.get_connection_url())
|
||||||
|
yield engine
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup_experiment_run_table(postgres_engine):
|
||||||
|
"""
|
||||||
|
Create the experiment_run table before each test and drop it afterwards
|
||||||
|
to guarantee full isolation between tests.
|
||||||
|
"""
|
||||||
|
with postgres_engine.begin() as conn:
|
||||||
|
conn.execute(text("""
|
||||||
|
CREATE TABLE IF NOT EXISTS public.experiment_run (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
experiment_name TEXT NOT NULL,
|
||||||
|
run_name TEXT,
|
||||||
|
username TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'ORCHESTRATOR_WAITING_PROC',
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
bucket_name TEXT,
|
||||||
|
file_name TEXT
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
yield
|
||||||
|
with postgres_engine.begin() as conn:
|
||||||
|
conn.execute(text('DROP TABLE IF EXISTS public.experiment_run'))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Observability (real sientia_do implementations)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture(scope='session')
|
||||||
|
def e2e_logger():
|
||||||
|
"""Shared production-style Logger for the whole E2E session."""
|
||||||
|
return get_logger('model-manager-e2e')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def metrics_controller(e2e_logger):
|
||||||
|
"""MetricsController bound to the E2E logger (fresh instance per test)."""
|
||||||
|
return MetricsController(logger=e2e_logger)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Application fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def notification_handler(mongodb_container, e2e_logger):
|
||||||
|
"""
|
||||||
|
Real CoreNotificationHandler connected to the MongoDB testcontainer.
|
||||||
|
"""
|
||||||
|
connection_url = mongodb_container.get_connection_url()
|
||||||
|
handler = CoreNotificationHandler(
|
||||||
|
connection_string=connection_url,
|
||||||
|
database='test_notifications',
|
||||||
|
logger=e2e_logger,
|
||||||
|
project_name='model-manager-e2e',
|
||||||
|
)
|
||||||
|
yield handler
|
||||||
|
handler.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def plugin_store(gitea_container, e2e_logger, metrics_controller, notification_handler):
|
||||||
|
"""
|
||||||
|
Real PluginStore pointed at the Gitea testcontainer.
|
||||||
|
cache_ttl_seconds=0 forces a fresh download every test.
|
||||||
|
"""
|
||||||
|
store = PluginStore(
|
||||||
|
base_url=gitea_container['base_url'],
|
||||||
|
owner=gitea_container['admin_user'],
|
||||||
|
repo='model-store',
|
||||||
|
username=gitea_container['admin_user'],
|
||||||
|
password=gitea_container['admin_pass'],
|
||||||
|
cache_ttl_seconds=0,
|
||||||
|
logger=e2e_logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=metrics_controller,
|
||||||
|
)
|
||||||
|
yield store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_activities(
|
||||||
|
postgres_container,
|
||||||
|
minio_container,
|
||||||
|
mlflow_tracking_dir, # noqa: ARG001 – ensures MLflow URI is set
|
||||||
|
plugin_store,
|
||||||
|
e2e_logger,
|
||||||
|
notification_handler,
|
||||||
|
metrics_controller,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Real Activities instance wired to all testcontainers.
|
||||||
|
"""
|
||||||
|
pg_port = postgres_container.get_exposed_port(5432)
|
||||||
|
minio_port = minio_container.get_exposed_port(9000)
|
||||||
|
|
||||||
|
activities = Activities(
|
||||||
|
postgres_config={
|
||||||
|
'host': 'localhost',
|
||||||
|
'port': int(pg_port),
|
||||||
|
'user': 'test',
|
||||||
|
'password': 'test',
|
||||||
|
'dbname': 'test',
|
||||||
|
'min_connections': 1,
|
||||||
|
'max_connections': 5,
|
||||||
|
},
|
||||||
|
mlflow_config={
|
||||||
|
'url': mlflow.get_tracking_uri(),
|
||||||
|
'username': None,
|
||||||
|
'password': None,
|
||||||
|
},
|
||||||
|
minio_config={
|
||||||
|
'endpoint_url': f'http://localhost:{minio_port}',
|
||||||
|
'access_key': 'minioadmin',
|
||||||
|
'secret_key': 'minioadmin',
|
||||||
|
'use_ssl': False,
|
||||||
|
'default_bucket': _MINIO_BUCKET,
|
||||||
|
},
|
||||||
|
plugin_store=plugin_store,
|
||||||
|
logger=e2e_logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=metrics_controller,
|
||||||
|
)
|
||||||
|
yield activities
|
||||||
|
activities.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def _activity_list(activities: Activities) -> list:
|
||||||
|
return [
|
||||||
|
activities.update_experiment_run,
|
||||||
|
activities.load_model_metadata,
|
||||||
|
activities.validate_train_params,
|
||||||
|
activities.train_model,
|
||||||
|
activities.cleanup_resources,
|
||||||
|
activities.cleanup_temp_directories,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope='function')
|
||||||
|
async def temporal_test_env():
|
||||||
|
"""Temporal SDK test environment (time-skipping); runs real workflow/activity code."""
|
||||||
|
env = await WorkflowEnvironment.start_time_skipping()
|
||||||
|
async with env:
|
||||||
|
yield env
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope='function')
|
||||||
|
async def temporal_worker(temporal_test_env, test_activities):
|
||||||
|
"""Temporal worker registered with all workflows and activities."""
|
||||||
|
with ThreadPoolExecutor() as executor:
|
||||||
|
async with Worker(
|
||||||
|
temporal_test_env.client,
|
||||||
|
task_queue='test-queue',
|
||||||
|
workflows=[TrainModel, CleanupFiles],
|
||||||
|
activities=_activity_list(test_activities),
|
||||||
|
activity_executor=executor,
|
||||||
|
) as worker:
|
||||||
|
yield worker
|
||||||
195
e2e/helpers.py
Normal file
195
e2e/helpers.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""
|
||||||
|
Shared helpers for E2E tests (Temporal workflows + PostgreSQL).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
|
||||||
|
|
||||||
|
async def start_and_await_workflow(
|
||||||
|
client,
|
||||||
|
workflow_run,
|
||||||
|
input_data: dict,
|
||||||
|
workflow_id: str,
|
||||||
|
timeout: float = 600.0,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Start a Temporal workflow and wait for its result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Temporal client from WorkflowEnvironment.
|
||||||
|
workflow_run: Workflow run method (e.g. TrainModel.run).
|
||||||
|
input_data: Workflow input payload.
|
||||||
|
workflow_id: Unique workflow id.
|
||||||
|
timeout: Max seconds to wait for completion (default allows cold testcontainer startup).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Workflow result value.
|
||||||
|
"""
|
||||||
|
handle = await client.start_workflow(
|
||||||
|
workflow_run,
|
||||||
|
input_data,
|
||||||
|
id=workflow_id,
|
||||||
|
task_queue='test-queue',
|
||||||
|
)
|
||||||
|
return await asyncio.wait_for(handle.result(), timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def make_workflow_id(prefix: str) -> str:
|
||||||
|
"""Build a unique workflow id using a prefix and current timestamp."""
|
||||||
|
return f'{prefix}-{datetime.now().timestamp()}'
|
||||||
|
|
||||||
|
|
||||||
|
def insert_experiment_run(
|
||||||
|
engine: Engine,
|
||||||
|
experiment_run_id: int,
|
||||||
|
experiment_name: str = 'test_experiment',
|
||||||
|
status: str = 'ORCHESTRATOR_WAITING_PROC',
|
||||||
|
bucket_name: str = 'model-training',
|
||||||
|
file_name: str = 'training_data.csv',
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Insert a minimal experiment_run row to satisfy foreign-key-style lookups.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
engine: SQLAlchemy engine connected to the test database.
|
||||||
|
experiment_run_id: Primary key for the row.
|
||||||
|
experiment_name: Human-readable experiment name.
|
||||||
|
status: Initial status string.
|
||||||
|
bucket_name: MinIO bucket name.
|
||||||
|
file_name: Training file name inside the bucket.
|
||||||
|
"""
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO public.experiment_run
|
||||||
|
(id, experiment_name, status, bucket_name, file_name)
|
||||||
|
VALUES
|
||||||
|
(:id, :experiment_name, :status, :bucket_name, :file_name)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
'id': experiment_run_id,
|
||||||
|
'experiment_name': experiment_name,
|
||||||
|
'status': status,
|
||||||
|
'bucket_name': bucket_name,
|
||||||
|
'file_name': file_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_experiment_status(
|
||||||
|
engine: Engine,
|
||||||
|
experiment_run_id: int,
|
||||||
|
expected_status: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Assert the final status of an experiment_run row.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
engine: SQLAlchemy engine.
|
||||||
|
experiment_run_id: Row primary key.
|
||||||
|
expected_status: Expected status string.
|
||||||
|
"""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
text('SELECT status FROM public.experiment_run WHERE id = :id'),
|
||||||
|
{'id': experiment_run_id},
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
assert row is not None, (
|
||||||
|
f'No experiment_run row found for id={experiment_run_id}'
|
||||||
|
)
|
||||||
|
assert row[0] == expected_status, (
|
||||||
|
f'Expected status={expected_status!r}, got {row[0]!r} '
|
||||||
|
f'for experiment_run id={experiment_run_id}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_experiment_run_name_set(
|
||||||
|
engine: Engine,
|
||||||
|
experiment_run_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Assert that run_name is not null/empty after a successful training."""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
text('SELECT run_name FROM public.experiment_run WHERE id = :id'),
|
||||||
|
{'id': experiment_run_id},
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
assert row is not None, (
|
||||||
|
f'No experiment_run row found for id={experiment_run_id}'
|
||||||
|
)
|
||||||
|
assert row[0] is not None and row[0].strip() != '', (
|
||||||
|
f'Expected run_name to be set for experiment_run id={experiment_run_id}, got {row[0]!r}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_experiment_error(
|
||||||
|
engine: Engine,
|
||||||
|
experiment_run_id: int,
|
||||||
|
expected_status: str,
|
||||||
|
error_substr: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Assert status and that error_message contains a given substring.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
engine: SQLAlchemy engine.
|
||||||
|
experiment_run_id: Row primary key.
|
||||||
|
expected_status: Expected status string.
|
||||||
|
error_substr: Substring that must appear in error_message.
|
||||||
|
"""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
text(
|
||||||
|
'SELECT status, error_message FROM public.experiment_run WHERE id = :id'
|
||||||
|
),
|
||||||
|
{'id': experiment_run_id},
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
assert row is not None, (
|
||||||
|
f'No experiment_run row found for id={experiment_run_id}'
|
||||||
|
)
|
||||||
|
assert row[0] == expected_status, (
|
||||||
|
f'Expected status={expected_status!r}, got {row[0]!r}'
|
||||||
|
)
|
||||||
|
assert row[1] is not None and error_substr.lower() in row[1].lower(), (
|
||||||
|
f'Expected error_message to contain {error_substr!r}, got {row[1]!r}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_no_experiment_row(engine: Engine, experiment_run_id: int) -> None:
|
||||||
|
"""Assert that no experiment_run row exists for the given id."""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
count = conn.execute(
|
||||||
|
text('SELECT COUNT(*) FROM public.experiment_run WHERE id = :id'),
|
||||||
|
{'id': experiment_run_id},
|
||||||
|
).scalar()
|
||||||
|
assert count == 0, (
|
||||||
|
f'Expected no experiment_run row for id={experiment_run_id}, found {count}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_scenario(scenario_filename: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Load a test scenario JSON file from docs/test-scenarios/.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scenario_filename: Filename without path (e.g. '01-linear-regression-basic.json').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Parsed scenario payload.
|
||||||
|
"""
|
||||||
|
scenario_path = (
|
||||||
|
Path(__file__).parent.parent / 'docs' / 'test-scenarios' / scenario_filename
|
||||||
|
)
|
||||||
|
with open(scenario_path) as f:
|
||||||
|
return json.load(f)
|
||||||
108
e2e/test_cleanup_files_workflow.py
Normal file
108
e2e/test_cleanup_files_workflow.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
End-to-end tests for CleanupFiles workflow.
|
||||||
|
|
||||||
|
Covers scenarios 3.x: cleanup of temporary local directories.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from temporalio.testing import WorkflowEnvironment
|
||||||
|
from temporalio.worker import Worker
|
||||||
|
|
||||||
|
from e2e.helpers import make_workflow_id, start_and_await_workflow
|
||||||
|
from model_manager.workflows.cleanup_files import CleanupFiles
|
||||||
|
|
||||||
|
# Matches Cleanup.dir_timestamp_pattern: name_YYYYMMDD_HHMMSS_microseconds
|
||||||
|
_STALE_DIR_OLD = 'stale_run_20200102_030405_000001'
|
||||||
|
_STALE_DIR_OLDER = 'stale_run_20191231_235959_999999'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_3_1_1_cleanup_with_no_temp_dirs(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
tmp_path,
|
||||||
|
):
|
||||||
|
"""Scenario 3.1.1 – Cleanup when the temp directory is empty.
|
||||||
|
|
||||||
|
The cleanup_temp_directories activity should complete without error
|
||||||
|
and the workflow should finish successfully.
|
||||||
|
"""
|
||||||
|
# Use an empty temp directory as the reports path
|
||||||
|
empty_dir = tmp_path / 'reports_temp'
|
||||||
|
empty_dir.mkdir()
|
||||||
|
|
||||||
|
result = await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
CleanupFiles.run,
|
||||||
|
{'temp_path': str(empty_dir)},
|
||||||
|
make_workflow_id('test-s3-1-1'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Workflow returns None on success
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_3_1_2_cleanup_removes_old_temp_dirs(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
tmp_path,
|
||||||
|
):
|
||||||
|
"""Scenario 3.1.2 – Cleanup removes stale subdirectories from the temp dir.
|
||||||
|
|
||||||
|
Creates two subdirectories with timestamp suffixes inside the reports
|
||||||
|
temp directory and verifies the activity removes them.
|
||||||
|
"""
|
||||||
|
reports_dir = tmp_path / 'reports_temp'
|
||||||
|
reports_dir.mkdir()
|
||||||
|
|
||||||
|
# Create two stale run directories (names must match cleanup activity regex)
|
||||||
|
stale1 = reports_dir / _STALE_DIR_OLD
|
||||||
|
stale2 = reports_dir / _STALE_DIR_OLDER
|
||||||
|
stale1.mkdir()
|
||||||
|
stale2.mkdir()
|
||||||
|
(stale1 / 'model.pkl').write_bytes(b'fake-model-data')
|
||||||
|
(stale2 / 'report.json').write_bytes(b'{"status": "old"}')
|
||||||
|
|
||||||
|
result = await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
CleanupFiles.run,
|
||||||
|
{'temp_path': str(reports_dir)},
|
||||||
|
make_workflow_id('test-s3-1-2'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
# The activity should have cleaned up the stale directories
|
||||||
|
remaining = list(reports_dir.iterdir())
|
||||||
|
assert len(remaining) == 0, (
|
||||||
|
f'Expected all stale dirs to be removed, but found: {remaining}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_3_1_3_cleanup_nonexistent_temp_path(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
tmp_path,
|
||||||
|
):
|
||||||
|
"""Scenario 3.1.3 – Cleanup with a temp_path that does not exist.
|
||||||
|
|
||||||
|
The activity must handle a missing directory gracefully without
|
||||||
|
raising an unhandled exception, since the directory may have already
|
||||||
|
been cleaned by a previous run.
|
||||||
|
"""
|
||||||
|
nonexistent = str(tmp_path / 'does_not_exist' / 'reports')
|
||||||
|
|
||||||
|
# Should not raise — the activity is expected to handle a missing path
|
||||||
|
result = await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
CleanupFiles.run,
|
||||||
|
{'temp_path': nonexistent},
|
||||||
|
make_workflow_id('test-s3-1-3'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
298
e2e/test_train_model_validation.py
Normal file
298
e2e/test_train_model_validation.py
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
"""
|
||||||
|
End-to-end tests for TrainModel parameter validation paths.
|
||||||
|
|
||||||
|
Covers scenarios 2.1.x: workflows that must terminate with
|
||||||
|
ORCHESTRATOR_VALIDATION_ERROR due to invalid parameter values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from temporalio.client import WorkflowFailureError
|
||||||
|
from temporalio.testing import WorkflowEnvironment
|
||||||
|
from temporalio.worker import Worker
|
||||||
|
|
||||||
|
from e2e.helpers import (
|
||||||
|
assert_experiment_error,
|
||||||
|
insert_experiment_run,
|
||||||
|
load_scenario,
|
||||||
|
make_workflow_id,
|
||||||
|
start_and_await_workflow,
|
||||||
|
)
|
||||||
|
from model_manager.workflows.train_model import TrainModel
|
||||||
|
|
||||||
|
# Base experiment_run ids for validation test scenarios (offset to avoid collision)
|
||||||
|
_VALIDATION_ID_BASE = 3000
|
||||||
|
|
||||||
|
|
||||||
|
def _exception_chain_text(exc: BaseException) -> str:
|
||||||
|
"""Concatenate messages from an exception __cause__/__context__ chain."""
|
||||||
|
parts: list[str] = []
|
||||||
|
cur: BaseException | None = exc
|
||||||
|
seen: set[int] = set()
|
||||||
|
while cur is not None and id(cur) not in seen:
|
||||||
|
seen.add(id(cur))
|
||||||
|
text = str(cur).strip()
|
||||||
|
if text:
|
||||||
|
parts.append(text)
|
||||||
|
cur = cur.__cause__ or getattr(cur, '__context__', None)
|
||||||
|
return ' | '.join(parts).lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_2_1_1_train_size_out_of_range(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 2.1.1 – train_size=5 violates the 10–100 business rule.
|
||||||
|
|
||||||
|
Expected: workflow updates status → ORCHESTRATOR_VALIDATION_ERROR
|
||||||
|
and error_message references 'train_size'.
|
||||||
|
"""
|
||||||
|
experiment_run_id = _VALIDATION_ID_BASE + 1
|
||||||
|
scenario = load_scenario('01-linear-regression-basic.json')
|
||||||
|
scenario = {**scenario, 'experiment_run_id': experiment_run_id, 'train_size': 5}
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s2-1-1'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_error(
|
||||||
|
postgres_engine,
|
||||||
|
experiment_run_id,
|
||||||
|
expected_status='ORCHESTRATOR_VALIDATION_ERROR',
|
||||||
|
error_substr='train_size',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_2_1_2_empty_variable_columns(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 2.1.2 – variable_columns=[] → ORCHESTRATOR_VALIDATION_ERROR."""
|
||||||
|
experiment_run_id = _VALIDATION_ID_BASE + 2
|
||||||
|
scenario = load_scenario('01-linear-regression-basic.json')
|
||||||
|
scenario = {**scenario, 'experiment_run_id': experiment_run_id, 'variable_columns': []}
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s2-1-2'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_error(
|
||||||
|
postgres_engine,
|
||||||
|
experiment_run_id,
|
||||||
|
expected_status='ORCHESTRATOR_VALIDATION_ERROR',
|
||||||
|
error_substr='variable_columns',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_2_1_3_invalid_date_format(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 2.1.3 – date_format='INVALID' is not in the allowed list."""
|
||||||
|
experiment_run_id = _VALIDATION_ID_BASE + 3
|
||||||
|
scenario = load_scenario('01-linear-regression-basic.json')
|
||||||
|
scenario = {**scenario, 'experiment_run_id': experiment_run_id, 'date_format': 'INVALID'}
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s2-1-3'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_error(
|
||||||
|
postgres_engine,
|
||||||
|
experiment_run_id,
|
||||||
|
expected_status='ORCHESTRATOR_VALIDATION_ERROR',
|
||||||
|
error_substr='date_format',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_2_1_4_whitespace_only_model_name(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 2.1.4 – model_name=' ' (whitespace) → ORCHESTRATOR_VALIDATION_ERROR."""
|
||||||
|
experiment_run_id = _VALIDATION_ID_BASE + 4
|
||||||
|
scenario = load_scenario('01-linear-regression-basic.json')
|
||||||
|
scenario = {**scenario, 'experiment_run_id': experiment_run_id, 'model_name': ' '}
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s2-1-4'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_error(
|
||||||
|
postgres_engine,
|
||||||
|
experiment_run_id,
|
||||||
|
expected_status='ORCHESTRATOR_VALIDATION_ERROR',
|
||||||
|
error_substr='model_name',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_2_1_5_unknown_model_type(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 2.1.5 – model_type='totally_unknown' → ORCHESTRATOR_VALIDATION_ERROR.
|
||||||
|
|
||||||
|
The PluginStore will not find this model in the Gitea repo, causing
|
||||||
|
load_model_metadata to fail before validate_train_params is even called.
|
||||||
|
"""
|
||||||
|
experiment_run_id = _VALIDATION_ID_BASE + 5
|
||||||
|
scenario = load_scenario('01-linear-regression-basic.json')
|
||||||
|
scenario = {
|
||||||
|
**scenario,
|
||||||
|
'experiment_run_id': experiment_run_id,
|
||||||
|
'model_type': 'totally_unknown_model',
|
||||||
|
}
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s2-1-5'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_error(
|
||||||
|
postgres_engine,
|
||||||
|
experiment_run_id,
|
||||||
|
expected_status='ORCHESTRATOR_VALIDATION_ERROR',
|
||||||
|
error_substr='totally_unknown_model',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_2_1_6_missing_target_variable(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 2.1.6 – target_variable='' (empty string) → ORCHESTRATOR_VALIDATION_ERROR."""
|
||||||
|
experiment_run_id = _VALIDATION_ID_BASE + 6
|
||||||
|
scenario = load_scenario('01-linear-regression-basic.json')
|
||||||
|
scenario = {**scenario, 'experiment_run_id': experiment_run_id, 'target_variable': ''}
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s2-1-6'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_error(
|
||||||
|
postgres_engine,
|
||||||
|
experiment_run_id,
|
||||||
|
expected_status='ORCHESTRATOR_VALIDATION_ERROR',
|
||||||
|
error_substr='target_variable',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_2_1_7_missing_experiment_run_id(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
):
|
||||||
|
"""Scenario 2.1.7 – experiment_run_id missing → workflow raises ValueError immediately.
|
||||||
|
|
||||||
|
No DB row is inserted because experiment_run_id is mandatory to even
|
||||||
|
know which row to update. The workflow should raise before any DB call.
|
||||||
|
"""
|
||||||
|
scenario = load_scenario('01-linear-regression-basic.json')
|
||||||
|
scenario = {k: v for k, v in scenario.items() if k != 'experiment_run_id'}
|
||||||
|
|
||||||
|
with pytest.raises(WorkflowFailureError) as excinfo:
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s2-1-7'),
|
||||||
|
)
|
||||||
|
combined = _exception_chain_text(excinfo.value)
|
||||||
|
assert 'experiment_run_id' in combined
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_2_1_8_missing_date_column(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
):
|
||||||
|
"""Scenario 2.1.8 – date_column missing in payload raises before workflow business validation."""
|
||||||
|
scenario = load_scenario('01-linear-regression-basic.json')
|
||||||
|
scenario = {k: v for k, v in scenario.items() if k != 'date_column'}
|
||||||
|
|
||||||
|
with pytest.raises(WorkflowFailureError) as excinfo:
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s2-1-8'),
|
||||||
|
)
|
||||||
|
combined = _exception_chain_text(excinfo.value)
|
||||||
|
assert 'date_column' in combined
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_2_1_9_whitespace_date_column(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 2.1.9 – date_column=' ' must produce ORCHESTRATOR_VALIDATION_ERROR."""
|
||||||
|
experiment_run_id = _VALIDATION_ID_BASE + 9
|
||||||
|
scenario = load_scenario('01-linear-regression-basic.json')
|
||||||
|
scenario = {**scenario, 'experiment_run_id': experiment_run_id, 'date_column': ' '}
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s2-1-9'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_error(
|
||||||
|
postgres_engine,
|
||||||
|
experiment_run_id,
|
||||||
|
expected_status='ORCHESTRATOR_VALIDATION_ERROR',
|
||||||
|
error_substr='date_column',
|
||||||
|
)
|
||||||
493
e2e/test_train_model_workflow.py
Normal file
493
e2e/test_train_model_workflow.py
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
"""
|
||||||
|
End-to-end tests for TrainModel workflow – main workflow scenarios.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
1.1.x – Happy-path training (various scenarios from docs/test-scenarios/)
|
||||||
|
1.2.x – Error paths (MinIO failure, missing DB row)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from temporalio.testing import WorkflowEnvironment
|
||||||
|
from temporalio.worker import Worker
|
||||||
|
|
||||||
|
from e2e.helpers import (
|
||||||
|
assert_experiment_error,
|
||||||
|
assert_experiment_run_name_set,
|
||||||
|
assert_experiment_status,
|
||||||
|
assert_no_experiment_row,
|
||||||
|
insert_experiment_run,
|
||||||
|
load_scenario,
|
||||||
|
make_workflow_id,
|
||||||
|
start_and_await_workflow,
|
||||||
|
)
|
||||||
|
from model_manager.workflows.train_model import TrainModel
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1.1 – Happy paths
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_1_1_linear_regression_basic(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.1.1 – Linear Regression Basic (cenário 01).
|
||||||
|
|
||||||
|
Validates the complete training pipeline end-to-end:
|
||||||
|
load_model_metadata → validate_train_params → train_model →
|
||||||
|
update_experiment_run (TRAINING_SUCCESS).
|
||||||
|
"""
|
||||||
|
scenario = load_scenario('01-linear-regression-basic.json')
|
||||||
|
experiment_run_id = scenario['experiment_run_id']
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-1-1'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
|
||||||
|
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_1_2_linear_regression_with_scaler(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.1.2 – Linear regression with Standard Scaler (cenário 02)."""
|
||||||
|
scenario = load_scenario('02-linear-regression-with-scaler.json')
|
||||||
|
experiment_run_id = scenario['experiment_run_id']
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-1-2'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
|
||||||
|
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_1_3_polynomial_regression_degree2_with_scaler(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.1.3 – Polynomial Regression Degree 2 with Standard Scaler (cenário 03)."""
|
||||||
|
scenario = load_scenario('03-polynomial-regression-degree2.json')
|
||||||
|
experiment_run_id = scenario['experiment_run_id']
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-1-3'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
|
||||||
|
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_1_4_polynomial_regression_degree3_with_scaler(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.1.4 – Polynomial regression degree 3 with Standard Scaler (cenário 04)."""
|
||||||
|
scenario = load_scenario('04-polynomial-regression-degree3.json')
|
||||||
|
experiment_run_id = scenario['experiment_run_id']
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-1-4'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
|
||||||
|
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_1_5_linear_regression_with_lags(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.1.5 – Linear Regression with lag_train/lag_val per variable (cenário 05)."""
|
||||||
|
scenario = load_scenario('05-linear-regression-with-lags.json')
|
||||||
|
experiment_run_id = scenario['experiment_run_id']
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-1-5'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
|
||||||
|
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_1_6_linear_regression_nan_interpolation(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.1.6 – nan_treatment='linear interpolation' (cenário 06)."""
|
||||||
|
scenario = load_scenario('06-linear-regression-nan-interpolation.json')
|
||||||
|
experiment_run_id = scenario['experiment_run_id']
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-1-6'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_1_7_linear_regression_static_window_removal(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.1.7 – rem_static_win=true with default static_threshold (cenário 07)."""
|
||||||
|
scenario = load_scenario('07-linear-regression-static-window-removal.json')
|
||||||
|
experiment_run_id = scenario['experiment_run_id']
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-1-7'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_1_8_linear_regression_with_limits(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.1.8 – support_filters with min/max limits per variable (cenário 08)."""
|
||||||
|
scenario = load_scenario('08-linear-regression-with-limits.json')
|
||||||
|
experiment_run_id = scenario['experiment_run_id']
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-1-8'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_1_9_polynomial_degree2_scaler_and_lags(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.1.9 – Polynomial degree 2, Standard Scaler and lags (cenário 09)."""
|
||||||
|
scenario = load_scenario('09-polynomial-degree2-with-scaler-and-lags.json')
|
||||||
|
experiment_run_id = scenario['experiment_run_id']
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-1-9'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
|
||||||
|
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_1_10_linear_regression_with_ar_opt_params(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.1.10 – Linear regression with include_ar in opt_params (cenário 10)."""
|
||||||
|
scenario = load_scenario('10-linear-regression-with-ar.json')
|
||||||
|
experiment_run_id = scenario['experiment_run_id']
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-1-10'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
|
||||||
|
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_1_11_linear_regression_static_threshold_custom(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.1.11 – rem_static_win=true with custom static_threshold (cenário 11)."""
|
||||||
|
scenario = load_scenario('11-linear-regression-static-threshold-custom.json')
|
||||||
|
experiment_run_id = scenario['experiment_run_id']
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-1-11'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_1_12_alternate_date_format_dd_mm_yyyy(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.1.12 – DATA column and dd/MM/yyyy format CSV (cenário 12)."""
|
||||||
|
scenario = load_scenario('12-angular-test-date-format.json')
|
||||||
|
experiment_run_id = scenario['experiment_run_id']
|
||||||
|
insert_experiment_run(
|
||||||
|
postgres_engine,
|
||||||
|
experiment_run_id,
|
||||||
|
file_name='training_data_dd_mm_yyyy.csv',
|
||||||
|
)
|
||||||
|
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-1-12'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
|
||||||
|
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_1_13_alternate_csv_narrow_date_window(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.1.13 – Same alternate CSV as 12 with date window (cenário 13)."""
|
||||||
|
scenario = load_scenario('13-angular-test-double-date-column.json')
|
||||||
|
experiment_run_id = scenario['experiment_run_id']
|
||||||
|
insert_experiment_run(
|
||||||
|
postgres_engine,
|
||||||
|
experiment_run_id,
|
||||||
|
file_name='training_data_dd_mm_yyyy.csv',
|
||||||
|
)
|
||||||
|
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-1-13'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_1_14_polynomial_with_support_filters(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.1.14 – Polynomial degree 4, Standard Scaler, line support filters (cenário 14)."""
|
||||||
|
scenario = load_scenario('14-angular-test-polynomial-support-filters.json')
|
||||||
|
experiment_run_id = scenario['experiment_run_id']
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-1-14'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_1_15_linear_regression_custom_target_column_name(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.1.15 – Target column is not named ``target``; report path uses target_variable."""
|
||||||
|
scenario = load_scenario('15-linear-regression-custom-target-column.json')
|
||||||
|
experiment_run_id = scenario['experiment_run_id']
|
||||||
|
insert_experiment_run(
|
||||||
|
postgres_engine,
|
||||||
|
experiment_run_id,
|
||||||
|
file_name='training_data_custom_target.csv',
|
||||||
|
)
|
||||||
|
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-1-15'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
|
||||||
|
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_1_16_naive_timestamp_header_column(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.1.16 – CSV uses ``Timestamp`` header; snake_case date_column/date_format."""
|
||||||
|
scenario = load_scenario('16-linear-regression-naive-timestamp-header.json')
|
||||||
|
experiment_run_id = scenario['experiment_run_id']
|
||||||
|
insert_experiment_run(
|
||||||
|
postgres_engine,
|
||||||
|
experiment_run_id,
|
||||||
|
file_name='training_data_timestamp_naive.csv',
|
||||||
|
)
|
||||||
|
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-1-16'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
|
||||||
|
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_1_17_linear_regression_blank_timestamp_row_dropped(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.1.17 – CSV has one empty timestamp cell; row is dropped and training succeeds."""
|
||||||
|
scenario = load_scenario('17-linear-regression-blank-timestamp-row.json')
|
||||||
|
experiment_run_id = scenario['experiment_run_id']
|
||||||
|
insert_experiment_run(
|
||||||
|
postgres_engine,
|
||||||
|
experiment_run_id,
|
||||||
|
file_name='training_data_blank_timestamp_row.csv',
|
||||||
|
)
|
||||||
|
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-1-17'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
|
||||||
|
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1.2 – Error paths
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_2_1_minio_file_not_found(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.2.1 – Training file does not exist in MinIO → TRAINING_ERROR."""
|
||||||
|
scenario = load_scenario('01-linear-regression-basic.json')
|
||||||
|
scenario = {**scenario, 'experiment_run_id': 2001, 'file_name': 'does_not_exist.csv'}
|
||||||
|
experiment_run_id = 2001
|
||||||
|
insert_experiment_run(postgres_engine, experiment_run_id)
|
||||||
|
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-2-1'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_experiment_error(
|
||||||
|
postgres_engine,
|
||||||
|
experiment_run_id,
|
||||||
|
expected_status='TRAINING_ERROR',
|
||||||
|
error_substr='does_not_exist',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_scenario_1_2_2_experiment_run_id_not_in_db(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker: Worker,
|
||||||
|
postgres_engine,
|
||||||
|
):
|
||||||
|
"""Scenario 1.2.2 – experiment_run_id row absent → update_experiment_run raises."""
|
||||||
|
scenario = load_scenario('01-linear-regression-basic.json')
|
||||||
|
scenario = {**scenario, 'experiment_run_id': 9999}
|
||||||
|
# Intentionally NOT inserting the row
|
||||||
|
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
TrainModel.run,
|
||||||
|
scenario,
|
||||||
|
make_workflow_id('test-s1-2-2'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_no_experiment_row(postgres_engine, 9999)
|
||||||
2
git_requirements_mapping.txt
Normal file
2
git_requirements_mapping.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
sientia_do: git+ssh://git@github.com/Aignosi/sientia-dataops-library.git
|
||||||
|
sientia_model: git+ssh://git@github.com/Aignosi/sientia-model-library.git
|
||||||
38
input-sample.json
Normal file
38
input-sample.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"experiment_run_id": 1001,
|
||||||
|
"variable_columns": ["feature_a", "feature_b"],
|
||||||
|
"target_variable": "target",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training_data.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "timestamp",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "Linear Regression",
|
||||||
|
"model_type": "linear_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"feature_a": 0,
|
||||||
|
"feature_b": 0
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"feature_a": 0,
|
||||||
|
"feature_b": 0
|
||||||
|
},
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"rem_static_win": false,
|
||||||
|
"static_threshold": null,
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null,
|
||||||
|
"support_filters": {},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 1,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "Standard Scaler"
|
||||||
|
},
|
||||||
|
"opt_params": {}
|
||||||
|
}
|
||||||
76
input-sample.md
Normal file
76
input-sample.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
##### Insert a new experiment run
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Optional: remove a previous run with the same id
|
||||||
|
DELETE FROM public.experiment_run WHERE experiment_run_id = 1001;
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO public.experiment_run
|
||||||
|
(experiment_name, run_name, username, status, error_message, created_at,
|
||||||
|
updated_at, bucket_name, file_name, request_data, orchestrator_response_data)
|
||||||
|
VALUES(
|
||||||
|
'test-experiment-name',
|
||||||
|
'test-run-name',
|
||||||
|
'test-username',
|
||||||
|
'ORCHESTRATOR_WAITING_PROC',
|
||||||
|
null,
|
||||||
|
now(),
|
||||||
|
now(),
|
||||||
|
'model-training',
|
||||||
|
'training_data.csv',
|
||||||
|
'{"experiment_run_id":1001,"variable_columns":["feature_a","feature_b"],"target_variable":"target","bucket_name":"model-training","file_name":"training_data.csv","line_separator":",","decimal_separator":".","train_size":80,"shuffle":true,"model_name":"Linear Regression","model_type":"linear_regression","data_model_kwargs":{"lag_train":{"feature_a":0,"feature_b":0},"lag_val":{"feature_a":0,"feature_b":0},"nan_treatment":"drop"},"model_kwargs":{"degree":1,"scaler_name":"Standard Scaler"},"opt_params":{}}',
|
||||||
|
null
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Upload the input dataset to MinIO
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mc cp input_dataset.csv suse/model-training/training-sample-dataset-1001.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Temporal input payload sample
|
||||||
|
|
||||||
|
Keys match `TrainModelParams.from_dict` in `model_manager/utils/models/train_model_params.py`: every field passed to `_check_none` must be present, including **`date_column`**; `model_metadata` must be non-empty for `validate_business_rules()`. You may omit **`date_format`** (defaults to `yyyy-MM-dd HH:mm:ss`). Omit optional keys (`random_state`, `val_file_name`, `model_id`) when defaults or `None` apply.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"experiment_run_id": 1001,
|
||||||
|
"variable_columns": ["feature_a", "feature_b"],
|
||||||
|
"target_variable": "target",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training-sample-dataset-1001.csv",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"date_column": "timestamp",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "test-runtime-linear-regression-model",
|
||||||
|
"model_type": "linear_regression",
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"lag_train": {
|
||||||
|
"feature_a": 0,
|
||||||
|
"feature_b": 0
|
||||||
|
},
|
||||||
|
"lag_val": {
|
||||||
|
"feature_a": 0,
|
||||||
|
"feature_b": 0
|
||||||
|
},
|
||||||
|
"nan_treatment": "drop",
|
||||||
|
"rem_static_win": false,
|
||||||
|
"static_threshold": null,
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null,
|
||||||
|
"support_filters": {},
|
||||||
|
"removed_intervals": []
|
||||||
|
},
|
||||||
|
"model_kwargs": {
|
||||||
|
"degree": 1,
|
||||||
|
"interaction_only": false,
|
||||||
|
"scaler_name": "Standard Scaler"
|
||||||
|
},
|
||||||
|
"opt_params": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
11
input_dataset.csv
Normal file
11
input_dataset.csv
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
timestamp,Counter,Rollout,Square
|
||||||
|
2026-01-01 00:00:00,1.0,2.0,5.1
|
||||||
|
2026-01-01 00:01:00,2.0,2.0,5.9
|
||||||
|
2026-01-01 00:02:00,2.0,3.0,8.3
|
||||||
|
2026-01-01 00:03:00,3.0,3.0,9.2
|
||||||
|
2026-01-01 00:04:00,3.0,4.0,10.8
|
||||||
|
2026-01-01 00:05:00,4.0,4.0,11.7
|
||||||
|
2026-01-01 00:06:00,4.0,5.0,14.1
|
||||||
|
2026-01-01 00:07:00,5.0,5.0,15.15
|
||||||
|
2026-01-01 00:08:00,5.0,6.0,17.2
|
||||||
|
2026-01-01 00:09:00,6.0,6.0,17.9
|
||||||
|
385
model-manager-plugin-store-migration-plan.md
Normal file
385
model-manager-plugin-store-migration-plan.md
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
---
|
||||||
|
tags:
|
||||||
|
- engineering
|
||||||
|
- sientia
|
||||||
|
- runtime-system
|
||||||
|
- model-manager
|
||||||
|
- plugin-store
|
||||||
|
- migration-plan
|
||||||
|
created: 2026-03-02
|
||||||
|
modified: 2026-03-02
|
||||||
|
created_by: Vitor Pimentel
|
||||||
|
modified_by: Vitor Pimentel
|
||||||
|
status: draft
|
||||||
|
---
|
||||||
|
|
||||||
|
# Sientia Model Manager — PluginStore Migration Plan
|
||||||
|
|
||||||
|
> Implementation plan for migrating `sientia-dataops-model-manager` to use the Sientia PluginStore for runtime installation and model retrieval, aligned with the runtime architecture described in `analytics.md`.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
1. [[#Objectives and Scope|Objectives and Scope]] — What this migration must achieve
|
||||||
|
2. [[#Existing State Overview (model-manager)|Existing State Overview]] — Current responsibilities and coupling points
|
||||||
|
3. [[#Requirements Mapping|Requirements Mapping]] — Functional and non-functional requirements
|
||||||
|
4. [[#Target Architecture|Target Architecture]] — Desired runtime and model-loading architecture
|
||||||
|
5. [[#Implementation Plan|Implementation Plan]] — Phased, detailed changes to apply
|
||||||
|
6. [[#Testing Strategy|Testing Strategy]] — How to validate the new behavior
|
||||||
|
7. [[#Rollout and Migration Strategy|Rollout and Migration Strategy]] — How to safely roll out and deprecate old paths
|
||||||
|
8. [[#Potential Model Library Changes|Potential Model Library Changes]] — Expected impact on `sientia-model-library`
|
||||||
|
9. [[#Related Documents|Related Documents]] — Cross-links to supporting documents
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objectives and Scope
|
||||||
|
|
||||||
|
The goal of this work is to evolve `sientia-dataops-model-manager` so that:
|
||||||
|
|
||||||
|
- It **detects the runtime** before the worker starts, using the `RUNTIME` environment variable.
|
||||||
|
- It **installs the selected runtime** using the PluginStore runtime interface from `sientia_model.model_repository.plugin_store`:
|
||||||
|
- `PluginStore.install_runtime(runtime_name)`.
|
||||||
|
- It **uses PluginStore to obtain models from the store**, instead of constructing them from the local ML template / mlops library:
|
||||||
|
- Pipelines call `PluginStore.get_model(...)` to obtain `SientiaModel` instances.
|
||||||
|
- The previous “in-repo model implementation plus mlops library” path is removed.
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
|
||||||
|
- Changing Temporal workflow semantics (queues, retry policies, etc.).
|
||||||
|
- Replacing the existing MLflow-based tracking and reporting; these remain the responsibility of `ModelRepository` and the reporting utilities.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Existing State Overview (model-manager)
|
||||||
|
|
||||||
|
The `sientia-dataops-model-manager` application currently:
|
||||||
|
|
||||||
|
- **Worker orchestration** (`model_manager/worker/worker.py`)
|
||||||
|
- Reads Temporal configuration from env (`TEMPORAL_HOST`, `TEMPORAL_NAMESPACE`, task queues).
|
||||||
|
- Sets up observability (Prometheus, metrics, Sientia logger).
|
||||||
|
- Builds connector configs for Postgres, MLflow, MinIO, MongoDB.
|
||||||
|
- Instantiates `Activities` and schedules, then starts Temporal workers.
|
||||||
|
- Does not validate or install any “runtime” concept before worker startup.
|
||||||
|
|
||||||
|
- **Training pipeline**
|
||||||
|
- `Training` activity (`model_manager/activities/training.py`) coordinates:
|
||||||
|
- Parameter validation (`validate_train_params`).
|
||||||
|
- Training execution via `TrainingRepository`.
|
||||||
|
- Saving trained models and artifacts via `ModelRepository` to MLflow.
|
||||||
|
- `TrainingRepository` (`model_manager/utils/repository/training_repository.py`):
|
||||||
|
- Loads and preprocesses CSV data (via `DataPreprocessor`).
|
||||||
|
- Trains a local `LinearRegressionModel` defined in `model_manager.sientia.models`.
|
||||||
|
- Computes metrics and builds a `TrainModelResult` for downstream steps.
|
||||||
|
- `ModelRepository` (`model_manager/utils/repository/model_repository.py`):
|
||||||
|
- Uses `ModelServing` and `Reports` to:
|
||||||
|
- Generate reports and CSV artifacts.
|
||||||
|
- Log parameters, metrics and models into MLflow.
|
||||||
|
- Today, production models are identified using **stages** (for example `Production`) in the Model Registry; aliases like `@production` are not yet used.
|
||||||
|
- MLflow-related logic here overlaps conceptually with the MLflow interactions implemented in `sientia-dataops-laborious_temporal`, which motivates extracting a **shared MLflow repository** into `sientia-dataops-library` (see `mlflow-shared-repository-migration-plan`).
|
||||||
|
|
||||||
|
- **Coupling to models and runtimes**
|
||||||
|
- Training is tightly coupled to internal classes (`DataPreprocessor`, `LinearRegressionModel`).
|
||||||
|
- No runtime installation; env assumed ready.
|
||||||
|
- The system does not yet use PluginStore’s `get_model` or `install_runtime` capabilities.
|
||||||
|
|
||||||
|
**MLflow:** All MLflow operations (lookup, promotion, etc.) → [[mlflow-shared-repository-migration-plan|shared repository]]. Model Manager uses the interface; it does not implement these concepts.
|
||||||
|
|
||||||
|
### Current vs Target — High-level Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph currentState [Current State — Model Manager]
|
||||||
|
direction TB
|
||||||
|
WorkerMM["Temporal Worker\n(model_manager/worker.py)"]
|
||||||
|
ActivitiesMM["Activities\n(training, cleanup, etc.)"]
|
||||||
|
TrainRepo["TrainingRepository\n(local DataPreprocessor + LinearRegressionModel)"]
|
||||||
|
ModelRepoMM["ModelRepository\n(MLflow + reports)"]
|
||||||
|
|
||||||
|
WorkerMM -->|"Temporal tasks"| ActivitiesMM
|
||||||
|
ActivitiesMM -->|"train_model activity"| TrainRepo
|
||||||
|
TrainRepo -->|"TrainModelResult"| ModelRepoMM
|
||||||
|
ModelRepoMM -->|"experiments, runs, artifacts"| MLflowMM["MLflow Server"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph targetState [Target State — Model Manager]
|
||||||
|
direction TB
|
||||||
|
WorkerMM2["Temporal Worker\n+ Runtime bootstrap\n(read RUNTIME, install runtime)"]
|
||||||
|
ActivitiesMM2["Activities\n(+ ModelProvider)"]
|
||||||
|
PluginStoreNode["PluginStore\n(Gitea model store)"]
|
||||||
|
SientiaWrapper["SientiaModel wrapper\n(from store.get_model)"]
|
||||||
|
ModelRepoMM2["ModelRepository\n(MLflow + reports)"]
|
||||||
|
|
||||||
|
WorkerMM2 -->|"install_runtime(RUNTIME)"| PluginStoreNode
|
||||||
|
WorkerMM2 -->|"Temporal tasks"| ActivitiesMM2
|
||||||
|
ActivitiesMM2 -->|"get_training_model"| PluginStoreNode
|
||||||
|
PluginStoreNode -->|"SientiaModel instance"| SientiaWrapper
|
||||||
|
ActivitiesMM2 -->|"call train(...) on wrapper"| SientiaWrapper
|
||||||
|
SientiaWrapper -->|"TrainModelResult-compatible data"| ModelRepoMM2
|
||||||
|
ModelRepoMM2 -->|"experiments, runs, artifacts"| MLflowMM2["MLflow Server"]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Current vs Target — Training Hot Path (Code Sketch)
|
||||||
|
|
||||||
|
Current hot path in `TrainingRepository.train` (simplified):
|
||||||
|
|
||||||
|
```python
|
||||||
|
data = load_data(uploaded_file, params.line_separator, params.decimal_separator)
|
||||||
|
data = _ensure_date_column_parsed(data, params)
|
||||||
|
data = self._configure_datetime_index(data, params)
|
||||||
|
|
||||||
|
process_data = self._init_data_preprocessor(params)
|
||||||
|
process_data.fit(data)
|
||||||
|
data_view = process_data.transform(data)
|
||||||
|
|
||||||
|
x_train, x_test, y_train, y_test = split_train_test(...)
|
||||||
|
|
||||||
|
regr = LinearRegressionModel(
|
||||||
|
target_variable=params.target_variable,
|
||||||
|
variable_columns=params.variable_columns,
|
||||||
|
degree=params.degree,
|
||||||
|
interaction_only=params.interaction_only,
|
||||||
|
)
|
||||||
|
regr.fit(pd.concat([x_train, y_train], axis=1))
|
||||||
|
```
|
||||||
|
|
||||||
|
Target hot path with PluginStore + `SientiaModel` (conceptual):
|
||||||
|
|
||||||
|
```python
|
||||||
|
data = load_and_preprocess(uploaded_file, params) # keep existing preprocessing
|
||||||
|
train_df, val_df = build_train_val_splits(data, params) # explicit train/val
|
||||||
|
|
||||||
|
wrapper = model_provider.get_training_model(
|
||||||
|
model_name=params.model_name,
|
||||||
|
runtime=os.environ["RUNTIME"],
|
||||||
|
opt_params={"env": params.environment},
|
||||||
|
model_kwargs={},
|
||||||
|
data_model_kwargs={},
|
||||||
|
)
|
||||||
|
|
||||||
|
wrapper.train(
|
||||||
|
train_data=train_df,
|
||||||
|
val_data=val_df,
|
||||||
|
target=params.target_variable,
|
||||||
|
)
|
||||||
|
|
||||||
|
# From here, metrics and artifacts are computed based on wrapper outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements Mapping
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
| ID | Requirement | Description | Impacted Areas |
|
||||||
|
|-------|----------------------------------------------|------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|
|
||||||
|
| FR-01 | Runtime detection via env var | Before starting workers, read `RUNTIME` env var and fail fast if missing or empty | `model_manager/worker/worker.py` |
|
||||||
|
| FR-02 | Runtime installation via PluginStore | Install the runtime defined in `RUNTIME` using `PluginStore.install_runtime(runtime_name)` | `worker.py`, `sientia_model.model_repository.plugin_store.PluginStore` |
|
||||||
|
| FR-03 | PluginStore-based model retrieval | Use `PluginStore.get_model(...)` to obtain `SientiaModel` instances for training | `TrainingRepository` (or new adapter), `Training` activity |
|
||||||
|
| FR-04 | Remove mlops-library-based construction | Do not construct models directly from `model_manager.sientia.models`; remove the mlops path | `TrainingRepository`, `model_manager.sientia.models`, dependency graph |
|
||||||
|
| FR-05 | Use shared MLflow repository | Delegate all MLflow ops (runs, metrics, artifacts, production lookup) to `SientiaMLflowRepository`; keep reports and `TrainModelResult` in Model Manager | `ModelRepository`, [[mlflow-shared-repository-migration-plan]] |
|
||||||
|
| FR-06 | Centralized PluginStore configuration | Configure PluginStore (Gitea URL, repo, auth, PyPI mirror) via env/config | Config helper, `worker.py` |
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
|
||||||
|
| ID | Requirement | Description | Impacted Areas |
|
||||||
|
|--------|------------------------------------------|-------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------|
|
||||||
|
| NFR-01 | Robust runtime bootstrap | If runtime installation fails, the worker must not start; errors must be explicit in logs/metrics | `worker.py`, PluginStore error handling |
|
||||||
|
| NFR-02 | Single path | Use PluginStore + SientiaModel path only; no toggling or parallel old path | `worker.py`, Training activities |
|
||||||
|
| NFR-03 | Observability and debuggability | Logs and metrics must cover runtime detection, install attempts and PluginStore interactions | Logging around runtime and PluginStore |
|
||||||
|
| NFR-04 | Testability | Unit and integration tests must cover runtime detection, install, and model retrieval | `tests/worker`, `tests/activities`, new PluginStore tests |
|
||||||
|
| NFR-05 | Security | No credentials hard-coded in source; rely on env/secret management | Config helpers, deployment manifests |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Target Architecture
|
||||||
|
|
||||||
|
### Runtime bootstrap
|
||||||
|
|
||||||
|
- **New required env var:** `RUNTIME` (name of the runtime; must match a runtime in the store index).
|
||||||
|
- **New PluginStore configuration env vars** (names to be finalized):
|
||||||
|
- `STORE_BASE_URL`, `STORE_OWNER`, `STORE_REPO`
|
||||||
|
- Optional: `STORE_BRANCH`, `STORE_USERNAME`, `STORE_PASSWORD`, `PYPI_INDEX_URL`, `PYPI_USERNAME`, `PYPI_PASSWORD`
|
||||||
|
- **Worker startup sequence** in `main()`:
|
||||||
|
1. Initialize logger and basic metadata as today.
|
||||||
|
2. Read `RUNTIME` from env; if missing/empty → log critical error and exit.
|
||||||
|
3. Build `PluginStore` instance using configuration env vars.
|
||||||
|
4. Call `store.install_runtime(runtime_name=RUNTIME)`; on failure → log error, set `APP_UP` metric to 0 and exit.
|
||||||
|
5. Only after successful runtime installation → build connector configs, instantiate Activities, connect to Temporal.
|
||||||
|
|
||||||
|
### PluginStore usage in training
|
||||||
|
|
||||||
|
- **ModelProvider abstraction** (e.g. `model_manager/utils/model_provider.py`):
|
||||||
|
- Holds a `PluginStore` instance and logger.
|
||||||
|
- Exposes `get_training_model(model_name, runtime, opt_params, model_kwargs, data_model_kwargs) -> SientiaModel` via `store.get_model(...)`.
|
||||||
|
- **TrainingRepository integration:**
|
||||||
|
- Prepare input data using existing preprocessing logic.
|
||||||
|
- Ask ModelProvider for a `SientiaModel` instance (`model_name` from params, `runtime` from `RUNTIME`).
|
||||||
|
- Call the public `train(...)` method of the wrapper; collect outputs and metadata only through the public API.
|
||||||
|
- Build `TrainModelResult` from train/test splits, predictions, and artifacts required by `ModelRepository`.
|
||||||
|
- Model behavior lives in `SientiaModel`; manager focuses on orchestration and reports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 0 — Design Alignment
|
||||||
|
|
||||||
|
- **P0-01**: Confirm with stakeholders:
|
||||||
|
- The expected values and semantics for `RUNTIME` (naming convention, mapping to store runtimes).
|
||||||
|
- Whether the manager will ever need to support more than one runtime per process (current assumption: no).
|
||||||
|
- **P0-02**: Decide the config strategy:
|
||||||
|
- Pure environment variables vs a config file + env overrides.
|
||||||
|
- **P0-03**: Validate how `model_name` will be passed into the training workflow:
|
||||||
|
- Confirm or extend `TrainModelParams` to carry `model_name` and runtime-related fields as needed.
|
||||||
|
|
||||||
|
### Phase 1 — Runtime Detection and Installation
|
||||||
|
|
||||||
|
- **P1-01**: Extend `worker.py` docs and configuration:
|
||||||
|
- Add `RUNTIME` to the environment variables list in the module docstring.
|
||||||
|
- Document failure behavior when `RUNTIME` is missing or empty.
|
||||||
|
- **P1-02**: Implement `build_plugin_store_from_env` helper:
|
||||||
|
- New function that:
|
||||||
|
- Reads `STORE_*` and `PYPI_*` env vars.
|
||||||
|
- Creates and returns a `PluginStore` instance with appropriate logger.
|
||||||
|
- Place it either in `worker.py` or in a dedicated utility module (e.g. `model_manager/utils/plugin_store_config.py`).
|
||||||
|
- **P1-03**: Integrate runtime installation into `main()`:
|
||||||
|
- Before any Temporal client initialization:
|
||||||
|
- Read `runtime_name = os.getenv("RUNTIME")`.
|
||||||
|
- Build PluginStore via the helper.
|
||||||
|
- Call `install_runtime(runtime_name)`.
|
||||||
|
- Log:
|
||||||
|
- Start and end of runtime installation.
|
||||||
|
- List of installed requirements (name and version).
|
||||||
|
- On failure:
|
||||||
|
- Emit a clear message (including runtime name and store repo).
|
||||||
|
- Mark the app as DOWN in metrics.
|
||||||
|
- Exit with non-zero status.
|
||||||
|
|
||||||
|
### Phase 2 — ModelProvider and Training Integration
|
||||||
|
|
||||||
|
- **P2-01**: Introduce `ModelProvider` abstraction:
|
||||||
|
- Implement `ModelProvider` with:
|
||||||
|
- A reference to the shared `PluginStore`.
|
||||||
|
- Methods for retrieving models for training:
|
||||||
|
- `get_training_model(model_name, runtime, opt_params, model_kwargs, data_model_kwargs)`.
|
||||||
|
- Ensure it logs:
|
||||||
|
- Model and runtime names.
|
||||||
|
- Cache hits/misses when appropriate.
|
||||||
|
- **P2-02**: Wire ModelProvider into Activities:
|
||||||
|
- Update the construction of `Activities` in `worker.py` to accept:
|
||||||
|
- A `ModelProvider` or a `PluginStore` that can be wrapped inside the `Training` activity.
|
||||||
|
- Update `Training.__init__` signature to accept this new dependency and store it as an attribute.
|
||||||
|
- **P2-03**: Adjust `TrainingRepository` to use `SientiaModel`:
|
||||||
|
- In `train`:
|
||||||
|
1. Keep or refine current CSV loading and preprocessing pipeline (DataPreprocessor, split into train/test).
|
||||||
|
2. Use `ModelProvider` to retrieve the model:
|
||||||
|
- `model_name` from `TrainModelParams`.
|
||||||
|
- `runtime` from `RUNTIME`.
|
||||||
|
- `opt_params` mirroring how `test_plugin_store.py` interacts with models.
|
||||||
|
3. Call the model’s `train` method and produce the same core artifacts the current code expects:
|
||||||
|
- Train/test splits.
|
||||||
|
- Metrics and predictions for `TrainModelResult`.
|
||||||
|
- In `after_train_calculation`:
|
||||||
|
- Either re-use metrics from the SientiaModel, or continue calculating MSE/MAE/R² as a verification step.
|
||||||
|
|
||||||
|
### Phase 3 — Removing mlops Dependencies
|
||||||
|
|
||||||
|
- **P3-01**: Identify all usage points of:
|
||||||
|
- `LinearRegressionModel`.
|
||||||
|
- `DataPreprocessor` where behavior overlaps with what SientiaModel already does.
|
||||||
|
- **P3-02**: Remove mlops-based code:
|
||||||
|
- Remove unused mlops-library-based training hooks.
|
||||||
|
- Simplify `TrainingRepository` to delegate as much as possible to SientiaModel logic.
|
||||||
|
- Use PluginStore + `SientiaModel` path only (no migration flag or parallel old path).
|
||||||
|
|
||||||
|
### Phase 4 — ModelRepository, Shared MLflow Repository and Reporting Alignment
|
||||||
|
|
||||||
|
- **P4-01**: Ensure `TrainModelResult` is correctly populated:
|
||||||
|
- Confirm that:
|
||||||
|
- `x_train`, `x_test`, `y_train`, `y_test`, `y_pred`, `y_train_pred` are provided by the new flow.
|
||||||
|
- Any fields required by `_generate_report` and `_save_run` remain available.
|
||||||
|
- **P4-02**: Delegate all MLflow operations to `SientiaMLflowRepository` (see [[mlflow-shared-repository-migration-plan]]); keep in Model Manager only reporting orchestration and `TrainModelResult` construction.
|
||||||
|
- **P4-03**: Validate that MLflow reports remain consistent:
|
||||||
|
- Run a side-by-side comparison between:
|
||||||
|
- A run from the previous (mlops-based) pipeline.
|
||||||
|
- A PluginStore-based run for the same dataset/experiment using the shared repository.
|
||||||
|
- Compare:
|
||||||
|
- Logged parameters.
|
||||||
|
- Metrics.
|
||||||
|
- Artifacts (reports, CSVs, equation JSON).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- **T1 — Unit tests**
|
||||||
|
- Worker: Test behavior when `RUNTIME` is missing or empty; test that `install_runtime` is called with the correct runtime name (mock PluginStore).
|
||||||
|
- ModelProvider: Test that it calls `PluginStore.get_model` with the expected arguments.
|
||||||
|
- TrainingRepository: Test that it uses the model returned by PluginStore instead of `LinearRegressionModel`.
|
||||||
|
- **T2 — Integration tests**
|
||||||
|
- Set up a test store with a minimal model and runtime.
|
||||||
|
- Run a full training workflow: verify runtime installation first; confirm model is retrieved and training completes; validate metrics and artifacts via MLflow.
|
||||||
|
- **T3 — Regression tests**
|
||||||
|
- Run the same experiment once with the old pipeline and once with the PluginStore-based pipeline.
|
||||||
|
- Compare key results (metrics and artifacts) to ensure differences are understood and acceptable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollout and Migration Strategy
|
||||||
|
|
||||||
|
- **R1 — Criar modelos com a nova arquitetura**
|
||||||
|
- Garantir que o pipeline de modelagem (Factory/Warehouse/Store) consiga produzir modelos:
|
||||||
|
- Encapsulados em wrappers que estendem `SientiaModel`.
|
||||||
|
- Com interface estável para `train`, `retrain` (quando existir), `predict`/`transform` e `store_model`.
|
||||||
|
- Publicar um conjunto inicial de modelos “pilot” no store que será consumido pelo Model Manager.
|
||||||
|
|
||||||
|
- **R2 — Subir um ou mais runtimes para esses modelos**
|
||||||
|
- Configurar e instalar runtimes específicos para os novos modelos, alinhados ao `RUNTIME` esperado pelo Model Manager:
|
||||||
|
- Verificar que cada runtime contém todas as dependências necessárias (via PluginStore / runtime installer).
|
||||||
|
- Validar que, em um ambiente de teste, o runtime consegue:
|
||||||
|
- Instalar bibliotecas.
|
||||||
|
- Carregar o wrapper via PluginStore e executar pelo menos um ciclo de treino de ponta a ponta.
|
||||||
|
|
||||||
|
- **R3 — Colocar os novos modelos para rodar no Model Manager**
|
||||||
|
- Integrar o uso de `PluginStore.get_model(...)` na pipeline de treino:
|
||||||
|
- Direcionar um subconjunto de fluxos de treinamento para os modelos “pilot” oriundos do store.
|
||||||
|
- Monitorar:
|
||||||
|
- Estabilidade dos workers.
|
||||||
|
- Tempo de treino e consumo de recursos.
|
||||||
|
- Artefatos e métricas geradas no MLflow.
|
||||||
|
- Quando estáveis, coordenar com as equipes de produto/negócio para considerar esses modelos como candidatos a produção e, quando já estiverem usando MLflow 3+ com wrappers, promover esses modelos para produção usando aliases (`@production`).
|
||||||
|
|
||||||
|
- **R4 — Migrar progressivamente os demais modelos**
|
||||||
|
- Definir uma ordem de migração por domínio/família de modelo (por exemplo: modelos de predição de série temporal, modelos de classificação, etc.):
|
||||||
|
- Para cada modelo legado:
|
||||||
|
- Criar/ajustar o wrapper `SientiaModel` correspondente no Factory/Warehouse.
|
||||||
|
- Garantir que o modelo passe a ser entregue pelo store e consumido via PluginStore.
|
||||||
|
- Executar o ciclo de testes (T1–T3) descrito na seção anterior.
|
||||||
|
- Após migrar todas as famílias de modelo:
|
||||||
|
- Remover o caminho de código que instancia diretamente `LinearRegressionModel` e demais classes locais.
|
||||||
|
- Limpar variáveis de configuração relacionadas à pipeline antiga (mlops library local).
|
||||||
|
- Assumir como padrão único: Model Manager treinando apenas modelos vindos do store, via wrappers `SientiaModel` e, quando aplicável, com resolução de produção por aliases em MLflow 3+.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Potential Model Library Changes
|
||||||
|
|
||||||
|
- **Clarifying the SientiaModel training interface:**
|
||||||
|
- Ensure that `SientiaModel` provides a stable way to train (including validation split handling).
|
||||||
|
- A clear contract for returning predictions and metrics.
|
||||||
|
- Access to any internal state needed for `TrainModelResult` construction.
|
||||||
|
- **Improving PluginStore ergonomics:**
|
||||||
|
- Optionally add a helper to build `PluginStore` from environment variables (reusable across applications).
|
||||||
|
- More structured exceptions for missing runtime definitions, missing models, network and authentication issues.
|
||||||
|
- These changes should be coordinated so that model manager and any other consumers can rely on a consistent, documented behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documents
|
||||||
|
|
||||||
|
- [[mlflow-shared-repository-migration-plan|MLflow Shared Repository Migration Plan]] — Concepts implemented in the common interface (production lookup, wrapper loading, promotion, etc.)
|
||||||
|
- [[analytics-implementation-plan|Runtime Analytics Helm Implementation Plan]]
|
||||||
|
- [[analytics|Runtime Analytics Architecture and Analysis]]
|
||||||
|
- [[../model-plugin-system/06-end-to-end-flow|Model Plugin System — End-to-End Flow]]
|
||||||
|
|
||||||
0
model_manager/__init__.py
Normal file
0
model_manager/__init__.py
Normal file
0
model_manager/activities/__init__.py
Normal file
0
model_manager/activities/__init__.py
Normal file
166
model_manager/activities/activities.py
Normal file
166
model_manager/activities/activities.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
from temporalio import workflow
|
||||||
|
|
||||||
|
with workflow.unsafe.imports_passed_through():
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||||
|
from sientia_do.observability.logger import Logger
|
||||||
|
from sientia_do.observability.metrics_controller import MetricsController
|
||||||
|
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||||
|
from sientia_do.repository.minio_repository_sync import MinioRepository
|
||||||
|
from sientia_model.model_repository.mlflow_repository import SientiaMLflowRepository
|
||||||
|
from sientia_model.model_repository.plugin_store import PluginStore
|
||||||
|
|
||||||
|
from model_manager.activities.cleanup import Cleanup
|
||||||
|
from model_manager.activities.experiment_tracking import ExperimentTracking
|
||||||
|
from model_manager.activities.training import Training
|
||||||
|
|
||||||
|
|
||||||
|
class Activities(ExperimentTracking, Training, Cleanup):
|
||||||
|
"""
|
||||||
|
Main activities orchestrator for the Model Manager system.
|
||||||
|
|
||||||
|
This class combines functionality from multiple activity classes to provide
|
||||||
|
a unified interface for all workflow operations. It manages database connections,
|
||||||
|
MLFlow model interactions, MinIO storage operations, and cleanup operations.
|
||||||
|
|
||||||
|
The class implements multiple inheritance to combine specialized functionality:
|
||||||
|
- ExperimentTracking: ML experiment lifecycle tracking and database operations
|
||||||
|
- Training: ML model training operations with MLFlow and MinIO integration
|
||||||
|
- Cleanup: File and directory cleanup operations for MinIO and local filesystem
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
postgres_config (dict): PostgreSQL connection configuration
|
||||||
|
mlflow_config (dict): MLFlow server configuration
|
||||||
|
minio_config (dict): MinIO storage configuration
|
||||||
|
logger (Logger): Logging and observability instance
|
||||||
|
notification_handler (NotificationHandler): Notification management instance
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
postgres_config: dict[str, Any],
|
||||||
|
mlflow_config: dict[str, Any],
|
||||||
|
minio_config: dict[str, Any],
|
||||||
|
plugin_store: PluginStore,
|
||||||
|
logger: Logger,
|
||||||
|
notification_handler: NotificationHandler,
|
||||||
|
metrics_controller: MetricsController,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the Activities orchestrator with all required configurations.
|
||||||
|
|
||||||
|
This constructor initializes all parent classes with their respective
|
||||||
|
configurations and sets up the foundation for all activity operations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
postgres_config: PostgreSQL connection configuration dictionary
|
||||||
|
Required keys: host, port, user, password, dbname, min_connections, max_connections
|
||||||
|
mlflow_config: MLFlow server configuration dictionary
|
||||||
|
Required keys: host, port, username, password
|
||||||
|
minio_config: MinIO storage configuration dictionary
|
||||||
|
Required keys: endpoint_url, access_key, secret_key, region, use_ssl, default_bucket
|
||||||
|
logger: Logger instance for observability and debugging
|
||||||
|
notification_handler: Notification handler for alerts and monitoring
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If any parent class initialization fails
|
||||||
|
"""
|
||||||
|
|
||||||
|
ExperimentTracking.__init__(
|
||||||
|
self,
|
||||||
|
host=postgres_config['host'],
|
||||||
|
port=postgres_config['port'],
|
||||||
|
user=postgres_config['user'],
|
||||||
|
password=postgres_config['password'],
|
||||||
|
dbname=postgres_config['dbname'],
|
||||||
|
min_connections=postgres_config['min_connections'],
|
||||||
|
max_connections=postgres_config['max_connections'],
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.mlflow_repository = SientiaMLflowRepository(
|
||||||
|
host=mlflow_config['url'],
|
||||||
|
username=mlflow_config['username'],
|
||||||
|
password=mlflow_config['password'],
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
# MinIO repository used for all object storage operations
|
||||||
|
endpoint_url = minio_config['endpoint_url']
|
||||||
|
# MinioRepository expects the endpoint without scheme
|
||||||
|
if endpoint_url.startswith('http://'):
|
||||||
|
endpoint = endpoint_url.removeprefix('http://')
|
||||||
|
elif endpoint_url.startswith('https://'):
|
||||||
|
endpoint = endpoint_url.removeprefix('https://')
|
||||||
|
else:
|
||||||
|
endpoint = endpoint_url
|
||||||
|
|
||||||
|
self.minio_repository = MinioRepository(
|
||||||
|
endpoint=endpoint,
|
||||||
|
access_key=minio_config['access_key'],
|
||||||
|
secret_key=minio_config['secret_key'],
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=metrics_controller,
|
||||||
|
secure=minio_config['use_ssl'],
|
||||||
|
bucket=minio_config['default_bucket'],
|
||||||
|
)
|
||||||
|
|
||||||
|
Training.__init__(
|
||||||
|
self,
|
||||||
|
mlflow_repository=self.mlflow_repository,
|
||||||
|
plugin_store=plugin_store,
|
||||||
|
minio_repository=self.minio_repository,
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
Cleanup.__init__(
|
||||||
|
self,
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
"""
|
||||||
|
Destructor to safely handle cleanup during garbage collection.
|
||||||
|
|
||||||
|
This prevents AttributeError when the parent Postgres.__del__ tries to access
|
||||||
|
self.engine in objects with multiple inheritance. Only attempts cleanup if
|
||||||
|
the engine attribute exists.
|
||||||
|
"""
|
||||||
|
# Only call parent __del__ if engine attribute exists
|
||||||
|
# This prevents AttributeError in multiple inheritance scenarios
|
||||||
|
if hasattr(self, 'engine'):
|
||||||
|
try:
|
||||||
|
# Call parent class __del__ if it exists
|
||||||
|
if hasattr(super(), '__del__'): # pragma: no cover
|
||||||
|
super().__del__() # pragma: no cover
|
||||||
|
except Exception: # noqa: S110, BLE001 # pragma: no cover
|
||||||
|
# Silently ignore errors during garbage collection
|
||||||
|
# Logging here could cause issues if logger is already destroyed
|
||||||
|
pass
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
"""
|
||||||
|
Gracefully shutdown all activities and clean up resources.
|
||||||
|
|
||||||
|
This method ensures proper cleanup of all resources including:
|
||||||
|
- PostgreSQL connection pools (via ExperimentTracking)
|
||||||
|
- Any other resources that need explicit cleanup
|
||||||
|
|
||||||
|
The method should be called before the application terminates to ensure
|
||||||
|
proper resource cleanup and prevent resource leaks.
|
||||||
|
|
||||||
|
Prefer calling this method explicitly rather than relying on __del__.
|
||||||
|
"""
|
||||||
|
ExperimentTracking.close(self)
|
||||||
|
self.info('Postgres client closed')
|
||||||
|
SientiaMonitoring.shutdown(self)
|
||||||
177
model_manager/activities/cleanup.py
Normal file
177
model_manager/activities/cleanup.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"""
|
||||||
|
Cleanup activities for removing stale files from local filesystem.
|
||||||
|
|
||||||
|
This module provides activities for cleaning up temporary files and directories
|
||||||
|
that are older than the configured retention period. It operates independently
|
||||||
|
of the database, using timestamps embedded in filenames.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from temporalio import activity, workflow
|
||||||
|
|
||||||
|
with workflow.unsafe.imports_passed_through():
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import traceback
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||||
|
from sientia_do.notifications.models import NotificationLevel
|
||||||
|
from sientia_do.observability.logger import Logger
|
||||||
|
from sientia_do.observability.metrics_controller import MetricsController
|
||||||
|
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||||
|
|
||||||
|
from model_manager.runtime_paths import REPORTS_TEMP_DIR
|
||||||
|
|
||||||
|
RETENTION_HOURS = int(os.getenv('CLEANUP_RETENTION_HOURS', '24'))
|
||||||
|
DRY_RUN = os.getenv('CLEANUP_DRY_RUN', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
|
||||||
|
class Cleanup(SientiaMonitoring):
|
||||||
|
"""
|
||||||
|
Activity for cleaning up stale files and directories.
|
||||||
|
|
||||||
|
This activity extends SientiaMonitoring and handles cleanup of:
|
||||||
|
- Local temporary directories with timestamp suffixes
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
logger: Logger,
|
||||||
|
notification_handler: NotificationHandler,
|
||||||
|
metrics_controller: MetricsController,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize Cleanup activity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logger: Logger instance for observability
|
||||||
|
notification_handler: Handler for sending notifications
|
||||||
|
metrics_controller: Controller for metrics emission
|
||||||
|
"""
|
||||||
|
SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller)
|
||||||
|
|
||||||
|
# Configuration from environment variables
|
||||||
|
self.retention_hours = RETENTION_HOURS
|
||||||
|
self.dry_run = DRY_RUN
|
||||||
|
|
||||||
|
# Regex patterns for timestamp extraction
|
||||||
|
self.dir_timestamp_pattern = re.compile(
|
||||||
|
r'^(.+)_(\d{8}_\d{6}_\d{6})$'
|
||||||
|
) # name_YYYYMMDD_HHMMSS_microseconds
|
||||||
|
|
||||||
|
@activity.defn(name='cleanup_temp_directories')
|
||||||
|
def cleanup_temp_directories(self, input_data: dict[str, Any]) -> None:
|
||||||
|
"""
|
||||||
|
Clean up stale temporary directories based on timestamp in directory name.
|
||||||
|
|
||||||
|
This activity scans the reports/temp directory for subdirectories following
|
||||||
|
the pattern '{name}_{timestamp}' where timestamp is in YYYYMMDD_HHMMSS_microseconds format.
|
||||||
|
Directories older than the retention period are deleted.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_data: Cleanup configuration containing:
|
||||||
|
- metadata (dict): Workflow execution metadata
|
||||||
|
- temp_path (str): Path to temp directory (optional, defaults to reports/temp)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None: Results are logged and tracked via metrics
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If cleanup fails (after sending notification)
|
||||||
|
"""
|
||||||
|
metadata = input_data.get('metadata', {})
|
||||||
|
temp_path = input_data.get('temp_path', REPORTS_TEMP_DIR)
|
||||||
|
|
||||||
|
cutoff_time = datetime.now() - timedelta(hours=self.retention_hours)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.info(
|
||||||
|
f'Starting local directory cleanup - Path: {temp_path}, '
|
||||||
|
f'Retention: {self.retention_hours}h, Dry run: {self.dry_run}',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not os.path.exists(temp_path):
|
||||||
|
self.warning(f'Temp directory does not exist: {temp_path}', metadata)
|
||||||
|
return
|
||||||
|
|
||||||
|
directories_scanned = 0
|
||||||
|
directories_deleted = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for item_name in os.listdir(temp_path):
|
||||||
|
item_path = os.path.join(temp_path, item_name)
|
||||||
|
|
||||||
|
if not os.path.isdir(item_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
directories_scanned += 1
|
||||||
|
|
||||||
|
# Extract timestamp from directory name
|
||||||
|
match = self.dir_timestamp_pattern.match(item_name)
|
||||||
|
if not match:
|
||||||
|
self.debug(
|
||||||
|
f'Skipping directory without timestamp pattern: {item_name}', metadata
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
timestamp_str = match.group(2)
|
||||||
|
try:
|
||||||
|
# Parse YYYYMMDD_HHMMSS_microseconds
|
||||||
|
dir_time = datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S_%f')
|
||||||
|
|
||||||
|
if dir_time < cutoff_time:
|
||||||
|
age_hours = (datetime.now() - dir_time).total_seconds() / 3600
|
||||||
|
|
||||||
|
if self.dry_run:
|
||||||
|
self.info(
|
||||||
|
f'[DRY RUN] Would delete directory: {item_name} (age: {age_hours:.1f}h)',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
directories_deleted += 1
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
shutil.rmtree(item_path)
|
||||||
|
self.info(
|
||||||
|
f'Deleted stale directory: {item_name} (age: {age_hours:.1f}h)',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
directories_deleted += 1
|
||||||
|
except OSError as e:
|
||||||
|
error_msg = f'Failed to delete directory {item_name}: {str(e)}'
|
||||||
|
errors.append(error_msg)
|
||||||
|
self.error(error_msg, metadata)
|
||||||
|
else:
|
||||||
|
age_hours = (datetime.now() - dir_time).total_seconds() / 3600
|
||||||
|
self.debug(
|
||||||
|
f'Keeping recent directory: {item_name} (age: {age_hours:.1f}h)',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
error_msg = f'Invalid timestamp format in directory {item_name}: {str(e)}'
|
||||||
|
errors.append(error_msg)
|
||||||
|
self.error(error_msg, metadata)
|
||||||
|
|
||||||
|
self.info(
|
||||||
|
f'Directory cleanup completed - Scanned: {directories_scanned}, '
|
||||||
|
f'Deleted: {directories_deleted}, Errors: {len(errors)}',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f'Error in directory cleanup: {str(e)}'
|
||||||
|
trace = traceback.format_exc()
|
||||||
|
|
||||||
|
self.send_notification(
|
||||||
|
metadata=metadata,
|
||||||
|
notification_id='CLEANUP_DIRECTORIES_ERROR',
|
||||||
|
message=error_msg,
|
||||||
|
block='cleanup_temp_directories',
|
||||||
|
level=NotificationLevel.ERROR,
|
||||||
|
attachment_content=trace,
|
||||||
|
)
|
||||||
|
|
||||||
|
raise
|
||||||
280
model_manager/activities/experiment_tracking.py
Normal file
280
model_manager/activities/experiment_tracking.py
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"""
|
||||||
|
Experiment tracking activities for managing ML experiment lifecycle.
|
||||||
|
|
||||||
|
This module provides activities for tracking and updating experiment run status
|
||||||
|
in the PostgreSQL database, extending the synchronous Postgres client with specialized
|
||||||
|
methods for experiment management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from temporalio import activity, workflow
|
||||||
|
|
||||||
|
with workflow.unsafe.imports_passed_through():
|
||||||
|
import traceback
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||||
|
from sientia_do.notifications.models import NotificationLevel
|
||||||
|
from sientia_do.observability.logger import Logger
|
||||||
|
from sientia_do.observability.metrics_controller import MetricsController
|
||||||
|
from sientia_do.temporal.activities.postgres_sync import Postgres
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateType(enum.StrEnum):
|
||||||
|
"""Types of experiment run updates."""
|
||||||
|
|
||||||
|
STATUS = 'status'
|
||||||
|
STATUS_WITH_ERROR = 'status_with_error'
|
||||||
|
MODEL_SAVED = 'model_saved'
|
||||||
|
|
||||||
|
|
||||||
|
class ExperimentTracking(Postgres):
|
||||||
|
"""
|
||||||
|
Activity for tracking ML experiment lifecycle and status updates.
|
||||||
|
|
||||||
|
This activity extends the Postgres activity to provide specialized methods
|
||||||
|
for managing experiment runs, including status updates, error tracking, and
|
||||||
|
model registration. It maintains the experiment lifecycle from initialization
|
||||||
|
through training, model saving, and cleanup.
|
||||||
|
|
||||||
|
The activity uses a SQLAlchemy engine / connection pool and adds experiment-specific
|
||||||
|
operations with proper error handling and notifications.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
user: str,
|
||||||
|
password: str,
|
||||||
|
dbname: str,
|
||||||
|
min_connections: int,
|
||||||
|
max_connections: int,
|
||||||
|
logger: Logger,
|
||||||
|
notification_handler: NotificationHandler,
|
||||||
|
metrics_controller: MetricsController,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize ExperimentTracking activity with database configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: PostgreSQL server hostname
|
||||||
|
port: PostgreSQL server port
|
||||||
|
user: Database user
|
||||||
|
password: Database password
|
||||||
|
dbname: Database name
|
||||||
|
min_connections: Minimum connections in pool
|
||||||
|
max_connections: Maximum connections in pool
|
||||||
|
logger: Logger instance for observability
|
||||||
|
notification_handler: Notification handler for alerts
|
||||||
|
metrics_controller: Metrics controller for observability
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConnectionError: If database connection cannot be established
|
||||||
|
"""
|
||||||
|
Postgres.__init__(
|
||||||
|
self,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
dbname=dbname,
|
||||||
|
min_connections=min_connections,
|
||||||
|
max_connections=max_connections,
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.info(f'Postgres client initialized at {host}:{port}')
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
"""
|
||||||
|
Destructor to safely handle cleanup during garbage collection.
|
||||||
|
|
||||||
|
This prevents AttributeError when used in multiple inheritance scenarios
|
||||||
|
where the parent Postgres.__del__ might be called on objects without
|
||||||
|
the engine attribute.
|
||||||
|
"""
|
||||||
|
# Only call parent __del__ if engine attribute exists
|
||||||
|
if hasattr(self, 'engine'):
|
||||||
|
try:
|
||||||
|
if hasattr(super(), '__del__'):
|
||||||
|
super().__del__()
|
||||||
|
except Exception: # noqa: S110, BLE001
|
||||||
|
# Silently ignore errors during garbage collection
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _execute_update(self, query: str, params: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Execute an UPDATE SQL statement.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Parameterized SQL string to execute.
|
||||||
|
params: Mapping of parameters for the SQL query.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: A dictionary containing the affected row count: {'rowcount': int}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
with self.engine.begin() as connection:
|
||||||
|
result = connection.execute(text(query), params)
|
||||||
|
return {'rowcount': result.rowcount}
|
||||||
|
|
||||||
|
def _build_status_update_query(
|
||||||
|
self, status: str | None, experiment_run_id: int
|
||||||
|
) -> tuple[str, dict[str, Any]]:
|
||||||
|
"""Build SQL query for simple status update."""
|
||||||
|
if not isinstance(status, str) or not status:
|
||||||
|
raise ValueError('status is required for STATUS update type')
|
||||||
|
|
||||||
|
sql_query = """
|
||||||
|
UPDATE experiment_run
|
||||||
|
SET status = :status, updated_at = :updated_at
|
||||||
|
WHERE id = :experiment_run_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
query_params = {
|
||||||
|
'status': status,
|
||||||
|
'updated_at': datetime.now(UTC),
|
||||||
|
'experiment_run_id': experiment_run_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
return sql_query, query_params
|
||||||
|
|
||||||
|
def _build_status_with_error_query(
|
||||||
|
self, status: str | None, error_message: str | None, experiment_run_id: int
|
||||||
|
) -> tuple[str, dict[str, Any]]:
|
||||||
|
"""Build SQL query for status update with error message."""
|
||||||
|
if not isinstance(status, str) or not status:
|
||||||
|
raise ValueError('status is required for STATUS_WITH_ERROR update type')
|
||||||
|
|
||||||
|
if not isinstance(error_message, str) or not error_message:
|
||||||
|
raise ValueError('error_message is required for STATUS_WITH_ERROR update type')
|
||||||
|
|
||||||
|
# Truncate error message if too long
|
||||||
|
truncated_error = error_message[:1024] if len(error_message) > 1024 else error_message
|
||||||
|
|
||||||
|
sql_query = """
|
||||||
|
UPDATE experiment_run
|
||||||
|
SET status = :status, error_message = :error_message, updated_at = :updated_at
|
||||||
|
WHERE id = :experiment_run_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
query_params = {
|
||||||
|
'status': status,
|
||||||
|
'error_message': truncated_error,
|
||||||
|
'updated_at': datetime.now(UTC),
|
||||||
|
'experiment_run_id': experiment_run_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
return sql_query, query_params
|
||||||
|
|
||||||
|
def _build_model_saved_query(
|
||||||
|
self, run_name: str | None, status: str | None, experiment_run_id: int
|
||||||
|
) -> tuple[str, dict[str, Any]]:
|
||||||
|
"""Build SQL query for model saved update."""
|
||||||
|
if not isinstance(run_name, str) or not run_name:
|
||||||
|
raise ValueError('run_name is required for MODEL_SAVED update type')
|
||||||
|
|
||||||
|
if not isinstance(status, str) or not status:
|
||||||
|
raise ValueError('status is required for MODEL_SAVED update type')
|
||||||
|
|
||||||
|
sql_query = """
|
||||||
|
UPDATE experiment_run
|
||||||
|
SET run_name = :run_name, status = :status, updated_at = :updated_at
|
||||||
|
WHERE id = :experiment_run_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
query_params = {
|
||||||
|
'run_name': run_name,
|
||||||
|
'status': status,
|
||||||
|
'updated_at': datetime.now(UTC),
|
||||||
|
'experiment_run_id': experiment_run_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
return sql_query, query_params
|
||||||
|
|
||||||
|
def _get_update_query_and_params(
|
||||||
|
self, update_type: str, experiment_run_id: int, input_data: dict[str, Any]
|
||||||
|
) -> tuple[str, dict[str, Any]]:
|
||||||
|
"""Get SQL query and parameters based on update type."""
|
||||||
|
status = input_data.get('status')
|
||||||
|
error_message = input_data.get('error_message')
|
||||||
|
run_name = input_data.get('run_name')
|
||||||
|
|
||||||
|
if update_type == UpdateType.STATUS:
|
||||||
|
return self._build_status_update_query(status, experiment_run_id)
|
||||||
|
|
||||||
|
if update_type == UpdateType.STATUS_WITH_ERROR:
|
||||||
|
return self._build_status_with_error_query(status, error_message, experiment_run_id)
|
||||||
|
|
||||||
|
if update_type == UpdateType.MODEL_SAVED:
|
||||||
|
return self._build_model_saved_query(run_name, status, experiment_run_id)
|
||||||
|
|
||||||
|
raise ValueError(f'Invalid update_type: {update_type}')
|
||||||
|
|
||||||
|
@activity.defn(name='update_experiment_run')
|
||||||
|
def update_experiment_run(self, input_data: dict[str, Any]) -> None:
|
||||||
|
"""
|
||||||
|
Update experiment run with status, errors, or model information.
|
||||||
|
|
||||||
|
This activity provides a unified interface for all experiment run updates,
|
||||||
|
supporting different update types through a single method. It automatically
|
||||||
|
selects the appropriate SQL query based on the update type and parameters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_data: Configuration for experiment run update operation
|
||||||
|
Required keys:
|
||||||
|
- metadata (dict): Workflow execution metadata
|
||||||
|
- experiment_run_id (int): Unique identifier for the experiment run
|
||||||
|
- update_type (str): Type of update (status, status_with_error, model_saved)
|
||||||
|
Optional keys:
|
||||||
|
- status (str): New status for the experiment run
|
||||||
|
- error_message (str): Error message if update failed
|
||||||
|
- run_name (str): MLFlow run name if model was saved
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If required parameters are missing for the update type
|
||||||
|
RuntimeError: If update operation fails
|
||||||
|
"""
|
||||||
|
metadata = input_data.get('metadata')
|
||||||
|
experiment_run_id = input_data['experiment_run_id']
|
||||||
|
update_type = input_data['update_type']
|
||||||
|
status = input_data.get('status')
|
||||||
|
|
||||||
|
try:
|
||||||
|
sql_query, query_params = self._get_update_query_and_params(
|
||||||
|
update_type, experiment_run_id, input_data
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self._execute_update(sql_query, query_params)
|
||||||
|
|
||||||
|
if result.get('rowcount', 0) == 0:
|
||||||
|
error_msg = (
|
||||||
|
f'No experiment_run row updated for id={experiment_run_id} '
|
||||||
|
f'(row missing or id mismatch). update_type={update_type!r}, status={status!r}.'
|
||||||
|
)
|
||||||
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
|
self.info(
|
||||||
|
f'Successfully updated experiment run {experiment_run_id} with status {status}',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
error_msg = f'Error updating experiment run - ID: {experiment_run_id}, Status: {status}, Error: {str(e)}'
|
||||||
|
trace = traceback.format_exc()
|
||||||
|
|
||||||
|
self.send_notification(
|
||||||
|
metadata=metadata or {},
|
||||||
|
notification_id='UPDATE_EXPERIMENT_RUN_ERROR',
|
||||||
|
message=error_msg,
|
||||||
|
block='update_experiment_run',
|
||||||
|
level=NotificationLevel.ERROR,
|
||||||
|
attachment_content=trace,
|
||||||
|
)
|
||||||
|
raise RuntimeError(error_msg) from e
|
||||||
497
model_manager/activities/training.py
Normal file
497
model_manager/activities/training.py
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
"""
|
||||||
|
Training activities for ML model training operations.
|
||||||
|
|
||||||
|
This module provides activities for training machine learning models.
|
||||||
|
The activity extends BaseActivity and receives pre-downloaded files
|
||||||
|
and raises `ModelTrainingError` when training fails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sientia_model.wrappers.sientia_model import SientiaModel
|
||||||
|
from temporalio import activity, workflow
|
||||||
|
|
||||||
|
with workflow.unsafe.imports_passed_through():
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import mlflow
|
||||||
|
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||||
|
from sientia_do.notifications.models import NotificationLevel
|
||||||
|
from sientia_do.observability.logger import Logger
|
||||||
|
from sientia_do.observability.metrics_controller import MetricsController
|
||||||
|
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||||
|
from sientia_do.repository.minio_repository_sync import MinioRepository
|
||||||
|
from sientia_model.model_repository.mlflow_repository import SientiaMLflowRepository
|
||||||
|
from sientia_model.model_repository.plugin_store import PluginStore
|
||||||
|
|
||||||
|
from model_manager import metrics as mm_metrics
|
||||||
|
from model_manager.utils.models.train_model_params import TrainModelParams
|
||||||
|
from model_manager.utils.models.train_model_result import TrainModelResult
|
||||||
|
from model_manager.utils.repository.data_manager_repository import DataManagerRepository
|
||||||
|
|
||||||
|
|
||||||
|
class Training(SientiaMonitoring):
|
||||||
|
"""
|
||||||
|
Activity for ML model training operations.
|
||||||
|
|
||||||
|
This activity extends SientiaMonitoring and handles machine learning model
|
||||||
|
training with comprehensive error handling. It receives pre-downloaded
|
||||||
|
files from the workflow and raises `ModelTrainingError` on failure so the
|
||||||
|
workflow can map the correct experiment status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
mlflow_repository: SientiaMLflowRepository,
|
||||||
|
plugin_store: PluginStore,
|
||||||
|
minio_repository: MinioRepository,
|
||||||
|
logger: Logger,
|
||||||
|
notification_handler: NotificationHandler,
|
||||||
|
metrics_controller: MetricsController,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize Training activity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logger: Logger instance for observability
|
||||||
|
notification_handler: Handler for sending notifications
|
||||||
|
"""
|
||||||
|
SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller)
|
||||||
|
self.data_manager_repository = DataManagerRepository(logger)
|
||||||
|
self.mlflow_repository = mlflow_repository
|
||||||
|
self.plugin_store = plugin_store
|
||||||
|
self.minio_repository = minio_repository
|
||||||
|
|
||||||
|
@activity.defn(name='load_model_metadata')
|
||||||
|
def load_model_metadata(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Load model metadata/schemas from the model store.
|
||||||
|
|
||||||
|
This activity is responsible for fetching model metadata/schemas from the
|
||||||
|
model store index and extracting a serializable `model_metadata` dict that
|
||||||
|
`TrainModelParams.validate_business_rules()` depends on.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_data: Workflow input at the same level as `validate_train_params`,
|
||||||
|
including at least `model_name` and the fields required by
|
||||||
|
`TrainModelParams.from_dict` to build wrapper kwargs.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
dict[str, Any]: Updated `input_data` containing `input_data['model_metadata']`.
|
||||||
|
"""
|
||||||
|
metadata = input_data.get('metadata', {})
|
||||||
|
|
||||||
|
self.info(f'Loading model metadata for {input_data}', metadata)
|
||||||
|
|
||||||
|
try:
|
||||||
|
train_params = TrainModelParams.from_dict(input_data)
|
||||||
|
model_metadata = self.plugin_store.get_model_index(
|
||||||
|
model_type=train_params.model_type,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
train_params.model_metadata = model_metadata
|
||||||
|
self.info(f'Model metadata loaded successfully for {input_data}', metadata)
|
||||||
|
self.debug(f'Model metadata: {model_metadata}', metadata)
|
||||||
|
return train_params.to_dict()
|
||||||
|
except Exception as exc:
|
||||||
|
trace = traceback.format_exc()
|
||||||
|
self.send_notification(
|
||||||
|
metadata=metadata,
|
||||||
|
notification_id='LOAD_MODEL_METADATA_ERROR',
|
||||||
|
message=f'Error loading model metadata: {str(exc)}',
|
||||||
|
block='load_model_metadata',
|
||||||
|
level=NotificationLevel.ERROR,
|
||||||
|
attachment_content=trace,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
@activity.defn(name='validate_train_params')
|
||||||
|
def validate_train_params(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate and convert training parameters from dict to TrainModelParams.
|
||||||
|
|
||||||
|
This activity validates the input training parameters and converts them
|
||||||
|
to a TrainModelParams object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_data: Training parameters and metadata at the same level
|
||||||
|
Required keys:
|
||||||
|
- metadata (dict): Workflow execution metadata
|
||||||
|
- All TrainModelParams fields (experiment_run_id, target_variable, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: Validated and converted training parameters as dictionary
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If validation fails (after sending notification)
|
||||||
|
"""
|
||||||
|
metadata = input_data.get('metadata', {})
|
||||||
|
|
||||||
|
self.info(f'Validating training parameters for {input_data}', metadata)
|
||||||
|
|
||||||
|
try:
|
||||||
|
train_params = TrainModelParams.from_dict(input_data)
|
||||||
|
|
||||||
|
train_params.validate_business_rules()
|
||||||
|
|
||||||
|
self.info(
|
||||||
|
f'Training parameters validated successfully - '
|
||||||
|
f'Target: {train_params.target_variable}, '
|
||||||
|
f'Experiment: {train_params.experiment_name}',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.debug(
|
||||||
|
f'Training parameters validated successfully: {train_params.to_dict()}', metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
return train_params.to_dict()
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f'Error validating training parameters: {str(e)}'
|
||||||
|
trace = traceback.format_exc()
|
||||||
|
|
||||||
|
self.send_notification(
|
||||||
|
metadata=metadata,
|
||||||
|
notification_id='VALIDATE_TRAIN_PARAMS_ERROR',
|
||||||
|
message=error_msg,
|
||||||
|
block='validate_train_params',
|
||||||
|
level=NotificationLevel.ERROR,
|
||||||
|
attachment_content=trace,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
@activity.defn(name='train_model')
|
||||||
|
def train_model(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Train a machine learning model.
|
||||||
|
|
||||||
|
This activity orchestrates the ML training pipeline:
|
||||||
|
1. Validate input parameters.
|
||||||
|
2. Prepare data via DataManagerRepository.
|
||||||
|
3. Train the model and compute metrics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_data: Training configuration containing:
|
||||||
|
- metadata (dict): Workflow execution metadata.
|
||||||
|
- uploaded_file (BytesIO): Training data already downloaded from MinIO.
|
||||||
|
- train_params (dict): Training parameters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: Serializable summary (run identifiers, run_dir for cleanup, regression metrics).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If input validation fails.
|
||||||
|
Exception: If training fails (after sending notification).
|
||||||
|
"""
|
||||||
|
metadata = input_data.get('metadata')
|
||||||
|
train_params = TrainModelParams.from_dict(input_data['train_params'])
|
||||||
|
labels = self._get_training_labels(train_params)
|
||||||
|
|
||||||
|
self.info('Starting train_model process', metadata)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Download training file bytes from MinIO
|
||||||
|
self.info(
|
||||||
|
f'Downloading training file from MinIO for {train_params.file_name}', metadata
|
||||||
|
)
|
||||||
|
train_bytes = self.minio_repository.download_file(
|
||||||
|
object_name=train_params.file_name,
|
||||||
|
bucket=train_params.bucket_name,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Download optional validation file bytes from the same bucket
|
||||||
|
val_bytes: bytes | None = None
|
||||||
|
validation_name = train_params.val_file_name
|
||||||
|
if validation_name is not None:
|
||||||
|
self.info(f'Downloading validation file from MinIO for {validation_name}', metadata)
|
||||||
|
val_bytes = self.minio_repository.download_file(
|
||||||
|
object_name=validation_name,
|
||||||
|
bucket=train_params.bucket_name,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.info(f'Preparing training data for {train_params.file_name}', metadata)
|
||||||
|
train_result = self._prepare_data(train_bytes, val_bytes, train_params, metadata)
|
||||||
|
|
||||||
|
mm_metrics.SIENTIA_TRAINING_DATASET_TRAIN_ROWS.labels(**labels).set(
|
||||||
|
len(train_result.train_data)
|
||||||
|
)
|
||||||
|
mm_metrics.SIENTIA_TRAINING_DATASET_VAL_ROWS.labels(**labels).set(
|
||||||
|
len(train_result.val_data)
|
||||||
|
)
|
||||||
|
mm_metrics.SIENTIA_TRAINING_FEATURE_COUNT.labels(**labels).set(
|
||||||
|
len(train_params.variable_columns)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.info(f'Getting model wrapper for {train_params.model_type}', metadata)
|
||||||
|
wrapper = self.plugin_store.get_model(
|
||||||
|
model_type=train_params.model_type,
|
||||||
|
force_download=False,
|
||||||
|
opt_params=train_params.opt_params or {},
|
||||||
|
model_kwargs=train_params.model_kwargs or {},
|
||||||
|
data_model_kwargs=train_params.data_model_kwargs or {},
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.logger is not None:
|
||||||
|
wrapper.logger = self.logger.base_logger
|
||||||
|
|
||||||
|
self.info(f'Training model for {train_params.model_type}', metadata)
|
||||||
|
train_result = self._fit_model(wrapper, train_result, train_params, metadata)
|
||||||
|
|
||||||
|
self.info(f'Computing regression metrics for {train_params.model_type}', metadata)
|
||||||
|
train_result = self.data_manager_repository.compute_regression_metrics(
|
||||||
|
train_result,
|
||||||
|
wrapper,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if train_result.mse_val is not None:
|
||||||
|
mm_metrics.SIENTIA_TRAINING_MODEL_QUALITY_MSE.labels(**labels).set(
|
||||||
|
train_result.mse_val
|
||||||
|
)
|
||||||
|
if train_result.mae_val is not None:
|
||||||
|
mm_metrics.SIENTIA_TRAINING_MODEL_QUALITY_MAE.labels(**labels).set(
|
||||||
|
train_result.mae_val
|
||||||
|
)
|
||||||
|
if train_result.r2_val is not None:
|
||||||
|
mm_metrics.SIENTIA_TRAINING_MODEL_QUALITY_R2.labels(**labels).set(
|
||||||
|
train_result.r2_val
|
||||||
|
)
|
||||||
|
|
||||||
|
mm_metrics.SIENTIA_TRAINING_INFO.labels(
|
||||||
|
pod_id=labels['pod_id'],
|
||||||
|
model_name=train_params.model_name,
|
||||||
|
model_type=train_params.model_type,
|
||||||
|
dataset_train_rows=str(len(train_result.train_data)),
|
||||||
|
dataset_val_rows=str(len(train_result.val_data)),
|
||||||
|
feature_count=str(len(train_params.variable_columns)),
|
||||||
|
mse=str(train_result.mse_val) if train_result.mse_val is not None else '',
|
||||||
|
mae=str(train_result.mae_val) if train_result.mae_val is not None else '',
|
||||||
|
r2=str(train_result.r2_val) if train_result.r2_val is not None else '',
|
||||||
|
).set(time.time() * 1000)
|
||||||
|
|
||||||
|
self.info(f'Starting MLflow run for {train_params.model_type}', metadata)
|
||||||
|
with self.mlflow_repository.start_run(
|
||||||
|
model_name=train_params.model_name,
|
||||||
|
run_name=train_result.run_name,
|
||||||
|
experiment_name=train_result.experiment_name,
|
||||||
|
tags=None,
|
||||||
|
metadata=metadata,
|
||||||
|
) as run_info:
|
||||||
|
train_result.run_id = run_info.run_id
|
||||||
|
self._persist_training_artifacts(train_result, train_params, wrapper, metadata)
|
||||||
|
|
||||||
|
self.emit_metric_sync(
|
||||||
|
metric_object=mm_metrics.SIENTIA_TRAINING_MODEL_TRAINED_TOTAL,
|
||||||
|
tags=labels,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'run_name': train_result.run_name,
|
||||||
|
'experiment_name': train_result.experiment_name,
|
||||||
|
'run_id': train_result.run_id,
|
||||||
|
'run_dir': train_result.run_dir,
|
||||||
|
}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
error_msg = f'Error training model - error: {str(e)}'
|
||||||
|
|
||||||
|
trace = traceback.format_exc()
|
||||||
|
|
||||||
|
self.send_notification(
|
||||||
|
metadata=metadata or {},
|
||||||
|
notification_id='TRAIN_MODEL_ERROR',
|
||||||
|
message=error_msg,
|
||||||
|
block='train_model',
|
||||||
|
level=NotificationLevel.ERROR,
|
||||||
|
attachment_content=trace,
|
||||||
|
)
|
||||||
|
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _get_training_labels(self, train_params: TrainModelParams) -> dict:
|
||||||
|
return {
|
||||||
|
'pod_id': os.getenv('HOSTNAME', 'localhost'),
|
||||||
|
'model_name': train_params.model_name,
|
||||||
|
'model_type': train_params.model_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _prepare_data(
|
||||||
|
self,
|
||||||
|
train_bytes: bytes,
|
||||||
|
val_bytes: bytes | None,
|
||||||
|
train_params: TrainModelParams,
|
||||||
|
metadata: dict | None,
|
||||||
|
) -> TrainModelResult:
|
||||||
|
labels = self._get_training_labels(train_params)
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
return self.data_manager_repository.prepare_training_data(
|
||||||
|
train_file_bytes=train_bytes,
|
||||||
|
validation_file_bytes=val_bytes,
|
||||||
|
params=train_params,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.emit_metric_sync(
|
||||||
|
metric_object=mm_metrics.SIENTIA_TRAINING_DATA_PREPARATION_ERROR_COUNT_TOTAL,
|
||||||
|
tags=labels,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self.observe_lag_sync(
|
||||||
|
start_time,
|
||||||
|
mm_metrics.SIENTIA_TRAINING_DATA_PREPARATION_LAG,
|
||||||
|
labels,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fit_model(
|
||||||
|
self,
|
||||||
|
wrapper: Any,
|
||||||
|
train_result: TrainModelResult,
|
||||||
|
train_params: TrainModelParams,
|
||||||
|
metadata: dict | None,
|
||||||
|
) -> TrainModelResult:
|
||||||
|
labels = self._get_training_labels(train_params)
|
||||||
|
train_data = train_result.train_data
|
||||||
|
val_data = train_result.val_data
|
||||||
|
|
||||||
|
self.debug(
|
||||||
|
f'train_model prepared data (head 10):\ntrain:\n{train_data.head(10).to_string()}'
|
||||||
|
f'\nval:\n{val_data.head(10).to_string()}',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
wrapper.train(
|
||||||
|
train_data=train_data,
|
||||||
|
val_data=val_data,
|
||||||
|
target=train_params.target_variable,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.info(
|
||||||
|
f'Generating predictions using the trained wrapper for {train_params.model_type}',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
transformed_train, _ = wrapper.transform(train_data)
|
||||||
|
transformed_val, _ = wrapper.transform(val_data)
|
||||||
|
|
||||||
|
self.debug(
|
||||||
|
f'train_model transform (head 10):\ntrain:\n{transformed_train.head(10).to_string()}'
|
||||||
|
f'\nval:\n{transformed_val.head(10).to_string()}',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
y_train_pred_df, _ = wrapper.predict({}, transformed_train)
|
||||||
|
y_val_pred_df, _ = wrapper.predict({}, transformed_val)
|
||||||
|
|
||||||
|
self.debug(
|
||||||
|
f'train_model predict (head 10):\ntrain:\n{y_train_pred_df.head(10).to_string()}'
|
||||||
|
f'\nval:\n{y_val_pred_df.head(10).to_string()}',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
y_train_pred_df.sort_index(inplace=True, ascending=False)
|
||||||
|
y_val_pred_df.sort_index(inplace=True, ascending=False)
|
||||||
|
|
||||||
|
train_result.y_train_pred = y_train_pred_df
|
||||||
|
train_result.y_pred = y_val_pred_df
|
||||||
|
return train_result
|
||||||
|
except Exception:
|
||||||
|
self.emit_metric_sync(
|
||||||
|
metric_object=mm_metrics.SIENTIA_TRAINING_MODEL_FIT_ERROR_COUNT_TOTAL,
|
||||||
|
tags=labels,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self.observe_lag_sync(
|
||||||
|
start_time,
|
||||||
|
mm_metrics.SIENTIA_TRAINING_MODEL_FIT_LAG,
|
||||||
|
labels,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _persist_training_artifacts(
|
||||||
|
self,
|
||||||
|
train_result: TrainModelResult,
|
||||||
|
train_params: TrainModelParams,
|
||||||
|
wrapper: SientiaModel,
|
||||||
|
metadata: dict[str, Any] | None,
|
||||||
|
) -> None:
|
||||||
|
self.info(f'Generating report for {train_params.model_type}', metadata)
|
||||||
|
train_result = self.data_manager_repository.generate_report(
|
||||||
|
train_result,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
train_result.report_path is None
|
||||||
|
or train_result.train_data_path is None
|
||||||
|
or train_result.test_data_path is None
|
||||||
|
):
|
||||||
|
raise ValueError('Report path, train data path, or test data path is not set')
|
||||||
|
|
||||||
|
self.info(f'Storing model for {train_params.model_type}', metadata)
|
||||||
|
wrapper._input_example = None
|
||||||
|
wrapper.store_model(name=train_params.model_name)
|
||||||
|
self._log_regression_metrics_as_params(train_result)
|
||||||
|
self.info(f'Logging artifacts for {train_params.model_type}', metadata)
|
||||||
|
mlflow.log_artifact(train_result.report_path)
|
||||||
|
mlflow.log_artifact(train_result.train_data_path)
|
||||||
|
mlflow.log_artifact(train_result.test_data_path)
|
||||||
|
if train_result.equation_path is not None:
|
||||||
|
mlflow.log_artifact(train_result.equation_path)
|
||||||
|
|
||||||
|
def _log_regression_metrics_as_params(self, train_result: TrainModelResult) -> None:
|
||||||
|
"""
|
||||||
|
Persist computed regression metrics as MLflow params.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
train_result: Training output containing computed regression metrics.
|
||||||
|
"""
|
||||||
|
metric_params = {
|
||||||
|
'mse_val': train_result.mse_val,
|
||||||
|
'mae_val': train_result.mae_val,
|
||||||
|
'r2_val': train_result.r2_val,
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value in metric_params.items():
|
||||||
|
if value is not None:
|
||||||
|
mlflow.log_param(key, value)
|
||||||
|
|
||||||
|
@activity.defn(name='cleanup_resources')
|
||||||
|
def cleanup_resources(self, input_data: dict[str, Any]) -> None:
|
||||||
|
"""
|
||||||
|
Cleanup temporary resources created during training.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_data: Cleanup configuration containing:
|
||||||
|
- metadata (dict): Workflow execution metadata.
|
||||||
|
- run_dir (str): Temporary directory to remove.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If cleanup fails (after sending notification).
|
||||||
|
"""
|
||||||
|
metadata = input_data.get('metadata', {})
|
||||||
|
run_dir = input_data.get('run_dir', '')
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.data_manager_repository.cleanup_run_directory(run_dir, metadata)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
error_msg = f'Error cleaning up resources - Run directory: {run_dir}, Error: {str(e)}'
|
||||||
|
|
||||||
|
trace = traceback.format_exc()
|
||||||
|
|
||||||
|
self.send_notification(
|
||||||
|
metadata=metadata,
|
||||||
|
notification_id='CLEANUP_RESOURCES_ERROR',
|
||||||
|
message=error_msg,
|
||||||
|
block='cleanup_resources',
|
||||||
|
level=NotificationLevel.ERROR,
|
||||||
|
attachment_content=trace,
|
||||||
|
)
|
||||||
|
|
||||||
|
raise
|
||||||
110
model_manager/metrics.py
Normal file
110
model_manager/metrics.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""
|
||||||
|
Model Manager Metrics Module
|
||||||
|
|
||||||
|
This module defines all Prometheus metrics used by the Sientia DataOps Model Manager system
|
||||||
|
for monitoring and observability. The metrics provide insights into system performance,
|
||||||
|
training operations, and operational health.
|
||||||
|
|
||||||
|
The metrics are designed to be scraped by Prometheus and can be visualized in
|
||||||
|
Grafana or other monitoring dashboards to provide real-time visibility into
|
||||||
|
the system's operation.
|
||||||
|
|
||||||
|
Key Metric Categories:
|
||||||
|
- Application Health: Overall system status and availability
|
||||||
|
|
||||||
|
Metric Labels:
|
||||||
|
- pod_id: Kubernetes pod identifier for multi-instance deployments
|
||||||
|
"""
|
||||||
|
|
||||||
|
from prometheus_client import Counter, Gauge, Histogram
|
||||||
|
|
||||||
|
# Application health metric
|
||||||
|
APP_UP = Gauge(
|
||||||
|
'app_up',
|
||||||
|
'Indicates if the application is running (1) or shutting down (0)',
|
||||||
|
['pod_id'],
|
||||||
|
)
|
||||||
|
|
||||||
|
_TRAINING_LABELS = ['pod_id', 'model_name', 'model_type']
|
||||||
|
|
||||||
|
SIENTIA_TRAINING_INFO = Gauge(
|
||||||
|
'sientia_training_info',
|
||||||
|
'Metadata and execution timestamp (ms) of the last successful model training run',
|
||||||
|
[
|
||||||
|
'pod_id',
|
||||||
|
'model_name',
|
||||||
|
'model_type',
|
||||||
|
'dataset_train_rows',
|
||||||
|
'dataset_val_rows',
|
||||||
|
'feature_count',
|
||||||
|
'mse',
|
||||||
|
'mae',
|
||||||
|
'r2',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
SIENTIA_TRAINING_MODEL_TRAINED_TOTAL = Counter(
|
||||||
|
'sientia_training_model_trained_total',
|
||||||
|
'Number of successfully completed model training runs',
|
||||||
|
_TRAINING_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
SIENTIA_TRAINING_DATA_PREPARATION_LAG = Histogram(
|
||||||
|
'sientia_training_data_preparation_lag',
|
||||||
|
'Latency of prepare_training_data() in seconds',
|
||||||
|
_TRAINING_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
SIENTIA_TRAINING_DATA_PREPARATION_ERROR_COUNT_TOTAL = Counter(
|
||||||
|
'sientia_training_data_preparation_error_count_total',
|
||||||
|
'Number of failures in prepare_training_data()',
|
||||||
|
_TRAINING_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
SIENTIA_TRAINING_MODEL_FIT_LAG = Histogram(
|
||||||
|
'sientia_training_model_fit_lag',
|
||||||
|
'Latency of wrapper.train() (model fitting) in seconds',
|
||||||
|
_TRAINING_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
SIENTIA_TRAINING_MODEL_FIT_ERROR_COUNT_TOTAL = Counter(
|
||||||
|
'sientia_training_model_fit_error_count_total',
|
||||||
|
'Number of failures in wrapper.train() (model fitting)',
|
||||||
|
_TRAINING_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
SIENTIA_TRAINING_MODEL_QUALITY_MSE = Gauge(
|
||||||
|
'sientia_training_model_quality_mse',
|
||||||
|
'Mean Squared Error of the last successful model training',
|
||||||
|
_TRAINING_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
SIENTIA_TRAINING_MODEL_QUALITY_MAE = Gauge(
|
||||||
|
'sientia_training_model_quality_mae',
|
||||||
|
'Mean Absolute Error of the last successful model training',
|
||||||
|
_TRAINING_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
SIENTIA_TRAINING_MODEL_QUALITY_R2 = Gauge(
|
||||||
|
'sientia_training_model_quality_r2',
|
||||||
|
'R-squared of the last successful model training',
|
||||||
|
_TRAINING_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
SIENTIA_TRAINING_DATASET_TRAIN_ROWS = Gauge(
|
||||||
|
'sientia_training_dataset_train_rows',
|
||||||
|
'Number of rows in the training dataset after preparation',
|
||||||
|
_TRAINING_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
SIENTIA_TRAINING_DATASET_VAL_ROWS = Gauge(
|
||||||
|
'sientia_training_dataset_val_rows',
|
||||||
|
'Number of rows in the validation dataset after preparation',
|
||||||
|
_TRAINING_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
SIENTIA_TRAINING_FEATURE_COUNT = Gauge(
|
||||||
|
'sientia_training_feature_count',
|
||||||
|
'Number of input feature columns used for training',
|
||||||
|
_TRAINING_LABELS,
|
||||||
|
)
|
||||||
167
model_manager/reports/header.html
Normal file
167
model_manager/reports/header.html
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
|
||||||
|
<title>Report</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
font-family: "Franklin Gothic Medium", "Arial Narrow", Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
padding-top: 70;
|
||||||
|
padding-bottom: 70;
|
||||||
|
position: absolute;
|
||||||
|
margin-left: -48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #fff;
|
||||||
|
position: absolute;
|
||||||
|
margin-left: 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
padding-top: 90px;
|
||||||
|
width: 100%;
|
||||||
|
display: fixed;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: rgb(217, 217, 214, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
|
||||||
|
color: #ffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip text */
|
||||||
|
.tooltiptext {
|
||||||
|
visibility: hidden;
|
||||||
|
background-color: rgb(0, 30, 96, 0.9);
|
||||||
|
padding: 10px;
|
||||||
|
margin-left: -90px;
|
||||||
|
font-size: 16px;
|
||||||
|
position: absolute;
|
||||||
|
top: 85px;
|
||||||
|
border-bottom-left-radius: 12px;
|
||||||
|
border-bottom-right-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show the tooltip text when you mouse over the tooltip container */
|
||||||
|
.material-symbols-outlined:hover .tooltiptext {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 85px;
|
||||||
|
background: rgb(0, 30, 96, 0.95);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 50px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav {
|
||||||
|
display: absolute;
|
||||||
|
margin-left: 80%;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav a {
|
||||||
|
position: relative;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 12px 18px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav a.active {
|
||||||
|
background-color: #001540;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<a href="#" class="logo">
|
||||||
|
<img
|
||||||
|
src="https://aignosi.blob.core.windows.net/sientia/20231016-Aignosi_Logo_WHITE.png"
|
||||||
|
alt="Aignosi Logo"
|
||||||
|
width="247"
|
||||||
|
height="70"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<h1>Report</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="#data_quality" class="active"> Summary </a>
|
||||||
|
<a href="#data_drift"> Drift </a>
|
||||||
|
<a href="#regression"> Regression </a>
|
||||||
|
</nav>
|
||||||
|
<div class="material-symbols-outlined">
|
||||||
|
info
|
||||||
|
<p class="tooltiptext">
|
||||||
|
Note that "current" <br />
|
||||||
|
is related to the test <br />
|
||||||
|
set while "reference" <br />
|
||||||
|
refers to the training <br />
|
||||||
|
set
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="quality_div">
|
||||||
|
<section id="data_quality"></section>
|
||||||
|
</div>
|
||||||
|
<div class="data_drift_div">
|
||||||
|
<section id="data_drift"></section>
|
||||||
|
</div>
|
||||||
|
<div class="regression_div">
|
||||||
|
<section id="regression"></section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let sec = document.querySelectorAll("section");
|
||||||
|
let links = document.querySelectorAll("nav a");
|
||||||
|
|
||||||
|
window.onscroll = () => {
|
||||||
|
sec.forEach((section) => {
|
||||||
|
let top = window.scrollY;
|
||||||
|
let offset = section.offsetTop;
|
||||||
|
let height = section.offsetHeight;
|
||||||
|
let id = section.getAttribute("id");
|
||||||
|
|
||||||
|
if (top >= offset && top < offset + height) {
|
||||||
|
links.forEach((link) => {
|
||||||
|
link.classList.remove("active");
|
||||||
|
document.querySelector("nav a[href*=" + id + "]").classList.add("active");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
0
model_manager/reports/temp/.gitkeep
Normal file
0
model_manager/reports/temp/.gitkeep
Normal file
29
model_manager/runtime_paths.py
Normal file
29
model_manager/runtime_paths.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""Filesystem layout for worker runtime data outside the application package tree."""
|
||||||
|
|
||||||
|
from os import makedirs
|
||||||
|
from os.path import join
|
||||||
|
|
||||||
|
# Root for all mutable runtime data (not under /app; avoids clashing with git clone under /app).
|
||||||
|
RUNTIME_DATA_ROOT = '/var/lib/model-manager'
|
||||||
|
|
||||||
|
# Training reports (HTML, CSV exports, etc.) and related outputs.
|
||||||
|
REPORTS_ROOT = join(RUNTIME_DATA_ROOT, 'reports')
|
||||||
|
|
||||||
|
# project base path
|
||||||
|
PROJECT_BASE_PATH = '/app/model_manager'
|
||||||
|
|
||||||
|
# Per-training run folders (name + timestamp); cleanup cron deletes stale entries here.
|
||||||
|
REPORTS_TEMP_DIR = join(REPORTS_ROOT, 'temp')
|
||||||
|
|
||||||
|
# Worker log files when file logging is wired; stdout remains primary until then.
|
||||||
|
LOGS_DIR = join(RUNTIME_DATA_ROOT, 'logs')
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_runtime_directories() -> None:
|
||||||
|
"""Create runtime directories expected by the worker process."""
|
||||||
|
# REPORTS_ROOT: base directory for report artifacts; remove if all outputs move elsewhere.
|
||||||
|
makedirs(REPORTS_ROOT, exist_ok=True)
|
||||||
|
# REPORTS_TEMP_DIR: transient run subdirs; remove after retention/cleanup is centralized.
|
||||||
|
makedirs(REPORTS_TEMP_DIR, exist_ok=True)
|
||||||
|
# LOGS_DIR: on-disk logs; remove if logging stays stdout-only forever.
|
||||||
|
makedirs(LOGS_DIR, exist_ok=True)
|
||||||
0
model_manager/schedules/__init__.py
Normal file
0
model_manager/schedules/__init__.py
Normal file
160
model_manager/schedules/cleanup_schedule.py
Normal file
160
model_manager/schedules/cleanup_schedule.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"""Schedule configuration for cleanup workflow."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sientia_do.observability.logger import Logger as SientiaLogger
|
||||||
|
from temporalio.client import (
|
||||||
|
Client,
|
||||||
|
Schedule,
|
||||||
|
ScheduleActionStartWorkflow,
|
||||||
|
ScheduleSpec,
|
||||||
|
)
|
||||||
|
|
||||||
|
from model_manager.worker.prepare_worker import build_queue_name
|
||||||
|
|
||||||
|
# Schedule configuration from environment variables
|
||||||
|
CLEANUP_CRON = os.getenv('CLEANUP_CRON', '0 0 * * *') # Default: midnight UTC
|
||||||
|
CLEANUP_TIMEZONE = os.getenv('CLEANUP_TIMEZONE', 'UTC')
|
||||||
|
CLEANUP_EXECUTION_TIMEOUT_HOURS = int(os.getenv('CLEANUP_EXECUTION_TIMEOUT_HOURS', '1'))
|
||||||
|
|
||||||
|
|
||||||
|
def build_cleanup_schedule_id(runtime: str | None) -> str:
|
||||||
|
"""
|
||||||
|
Build cleanup schedule ID using runtime-derived naming.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
runtime: Runtime suffix used by workers
|
||||||
|
|
||||||
|
Return:
|
||||||
|
str: Cleanup schedule ID
|
||||||
|
"""
|
||||||
|
normalized_runtime = runtime.strip() if runtime else ''
|
||||||
|
return f'cleanup-files-{normalized_runtime or "single"}-daily'
|
||||||
|
|
||||||
|
|
||||||
|
async def _needs_schedule_reconcile(
|
||||||
|
schedule_handle: Any,
|
||||||
|
cleanup_task_queue: str,
|
||||||
|
logger: SientiaLogger,
|
||||||
|
metadata: dict[str, str | None],
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Compare configured cleanup schedule against current expected values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_handle: Temporal schedule handle for current schedule ID
|
||||||
|
cleanup_task_queue: Expected cleanup task queue
|
||||||
|
logger: Logger instance for errors
|
||||||
|
metadata: Metadata dictionary for logging context
|
||||||
|
|
||||||
|
Return:
|
||||||
|
bool: True when schedule should be recreated to apply current config
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
schedule_description = await schedule_handle.describe()
|
||||||
|
schedule = getattr(schedule_description, 'schedule', None)
|
||||||
|
action = getattr(schedule, 'action', None)
|
||||||
|
spec = getattr(schedule, 'spec', None)
|
||||||
|
|
||||||
|
current_task_queue = getattr(action, 'task_queue', None)
|
||||||
|
current_execution_timeout = getattr(action, 'execution_timeout', None)
|
||||||
|
current_cron = getattr(spec, 'cron_expressions', None)
|
||||||
|
current_timezone = getattr(spec, 'time_zone_name', None)
|
||||||
|
|
||||||
|
return (
|
||||||
|
current_task_queue != cleanup_task_queue
|
||||||
|
or current_execution_timeout != timedelta(hours=CLEANUP_EXECUTION_TIMEOUT_HOURS)
|
||||||
|
or current_cron != [CLEANUP_CRON]
|
||||||
|
or current_timezone != CLEANUP_TIMEZONE
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.custom_error(f'Error describing cleanup schedule for reconcile: {e}', metadata)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def schedule_exists(
|
||||||
|
client: Client, schedule_id: str, logger: SientiaLogger, metadata: dict[str, str | None]
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a schedule already exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Temporal client instance
|
||||||
|
schedule_id: ID of the schedule to check
|
||||||
|
logger: Logger instance for error logging
|
||||||
|
metadata: Metadata dictionary for logging context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if schedule exists, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async for schedule in await client.list_schedules():
|
||||||
|
if schedule.id == schedule_id:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.custom_error(f'Error checking if schedule exists: {e}', metadata)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def create_cleanup_schedule(
|
||||||
|
client: Client, logger: SientiaLogger, metadata: dict[str, str | None]
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Create or update the cleanup files schedule.
|
||||||
|
|
||||||
|
This function is idempotent and can be called multiple times safely.
|
||||||
|
It will only create the schedule if it doesn't already exist.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Temporal client instance
|
||||||
|
logger: Logger instance for logging schedule operations
|
||||||
|
metadata: Metadata dictionary for logging context
|
||||||
|
"""
|
||||||
|
runtime = (os.getenv('RUNTIME') or 'single').strip()
|
||||||
|
cleanup_task_queue = build_queue_name('CleanupFiles', runtime or 'single')
|
||||||
|
schedule_id = build_cleanup_schedule_id(runtime)
|
||||||
|
updated = False
|
||||||
|
|
||||||
|
if await schedule_exists(client, schedule_id, logger, metadata):
|
||||||
|
handle = client.get_schedule_handle(schedule_id)
|
||||||
|
if await _needs_schedule_reconcile(handle, cleanup_task_queue, logger, metadata):
|
||||||
|
await handle.delete()
|
||||||
|
updated = True
|
||||||
|
else:
|
||||||
|
logger.custom_info(
|
||||||
|
f"Schedule '{schedule_id}' is already up to date, no-op reconcile",
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await client.create_schedule(
|
||||||
|
schedule_id,
|
||||||
|
Schedule(
|
||||||
|
action=ScheduleActionStartWorkflow(
|
||||||
|
'cleanup_files',
|
||||||
|
{}, # Empty input, will use default bucket from environment
|
||||||
|
id=f'cleanup-files-scheduled-{schedule_id}',
|
||||||
|
task_queue=cleanup_task_queue,
|
||||||
|
execution_timeout=timedelta(hours=CLEANUP_EXECUTION_TIMEOUT_HOURS),
|
||||||
|
),
|
||||||
|
spec=ScheduleSpec(
|
||||||
|
cron_expressions=[CLEANUP_CRON],
|
||||||
|
time_zone_name=CLEANUP_TIMEZONE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if updated:
|
||||||
|
logger.custom_info(
|
||||||
|
f"Schedule '{schedule_id}' reconciled successfully. "
|
||||||
|
f'Cleanup will run at: {CLEANUP_CRON} ({CLEANUP_TIMEZONE})',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.custom_info(
|
||||||
|
f"Schedule '{schedule_id}' created successfully. "
|
||||||
|
f'Cleanup will run at: {CLEANUP_CRON} ({CLEANUP_TIMEZONE})',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
0
model_manager/sientia/__init__.py
Normal file
0
model_manager/sientia/__init__.py
Normal file
3
model_manager/sientia/exceptions.py
Normal file
3
model_manager/sientia/exceptions.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from mlflow.exceptions import MlflowException
|
||||||
|
|
||||||
|
SientiaMlException = MlflowException
|
||||||
148
model_manager/sientia/metrics.py
Normal file
148
model_manager/sientia/metrics.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
|
||||||
|
|
||||||
|
|
||||||
|
def mse(real_data: pd.Series, predictions: pd.Series) -> float:
|
||||||
|
"""
|
||||||
|
Calculates the mean squared error between the real data and the predictions.
|
||||||
|
"""
|
||||||
|
return round(
|
||||||
|
mean_squared_error(real_data.astype(np.float64), predictions.astype(np.float64)), 2
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mae(real_data: pd.Series, predictions: pd.Series) -> float:
|
||||||
|
"""
|
||||||
|
Calculates the mean absolute error between the real data and the predictions.
|
||||||
|
"""
|
||||||
|
return round(
|
||||||
|
mean_absolute_error(real_data.astype(np.float64), predictions.astype(np.float64)), 2
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def r2(real_data: pd.Series, predictions: pd.Series) -> float:
|
||||||
|
"""
|
||||||
|
Calculates the R2 score between the real data and the predictions.
|
||||||
|
"""
|
||||||
|
return round(r2_score(real_data.astype(np.float64), predictions.astype(np.float64)), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def silverman_radius(data: np.ndarray) -> float:
|
||||||
|
"""
|
||||||
|
Calculate the Silverman bandwidth (radius) for a given dataset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data (np.ndarray): Input data (1D array)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float: Silverman bandwidth (radius)
|
||||||
|
"""
|
||||||
|
n = len(data)
|
||||||
|
sigma = np.std(data)
|
||||||
|
iqr = np.percentile(data, 75) - np.percentile(data, 25)
|
||||||
|
radius = 0.9 * min(sigma, iqr / 1.34) * n ** (-1 / 5)
|
||||||
|
return radius
|
||||||
|
|
||||||
|
|
||||||
|
def rce_train(training_set: pd.DataFrame, radius: float | None = None) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Get the Reduced Coulomb Energy (RCE) prototypes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
training_set (pd.DataFrame): The training set
|
||||||
|
radius (float | None): The radius of the RCE prototypes. If None, computed using Silverman's rule.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
pd.DataFrame: The RCE prototypes
|
||||||
|
"""
|
||||||
|
train_vectors = training_set.values
|
||||||
|
|
||||||
|
# Vectorized distance computation for the radius calculation
|
||||||
|
diff_vectors = train_vectors[:, np.newaxis] - train_vectors[np.newaxis, :]
|
||||||
|
distances = np.linalg.norm(diff_vectors, axis=-1)
|
||||||
|
|
||||||
|
# Non-parametric radius: Silverman Radius (compute if not provided)
|
||||||
|
effective_radius = radius if radius is not None else silverman_radius(distances.flatten())
|
||||||
|
|
||||||
|
# Initialize prototypes with the first vector
|
||||||
|
prototypes = [train_vectors[0]]
|
||||||
|
|
||||||
|
for vector in train_vectors[1:]:
|
||||||
|
# Vectorized distance check between current vector and all prototypes
|
||||||
|
distances_to_prototypes = np.linalg.norm(prototypes - vector, axis=1)
|
||||||
|
|
||||||
|
# If no prototype is close, add the current vector as a new prototype
|
||||||
|
if np.all(distances_to_prototypes > effective_radius):
|
||||||
|
prototypes.append(vector)
|
||||||
|
|
||||||
|
return pd.DataFrame(prototypes)
|
||||||
|
|
||||||
|
|
||||||
|
def rce_test(test_set: pd.DataFrame, prototypes: pd.DataFrame) -> pd.Series:
|
||||||
|
"""
|
||||||
|
Get the signed Reduced Coulomb Energy (RCE) predictions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
test_set (pd.DataFrame): The test set
|
||||||
|
prototypes (pd.DataFrame): The RCE prototypes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
pd.Series: The signed distances to the closest prototype for each test vector
|
||||||
|
"""
|
||||||
|
test_vectors = test_set.values
|
||||||
|
prototype_vectors = prototypes.values
|
||||||
|
|
||||||
|
# Vectorized computation of distances between test vectors and all prototypes
|
||||||
|
diff_vectors = test_vectors[:, np.newaxis] - prototype_vectors[np.newaxis, :]
|
||||||
|
distances = np.linalg.norm(diff_vectors, axis=-1)
|
||||||
|
|
||||||
|
# Find the closest prototype for each test vector
|
||||||
|
min_distances = np.min(distances, axis=1)
|
||||||
|
closest_prototypes = prototype_vectors[np.argmin(distances, axis=1)]
|
||||||
|
|
||||||
|
# Compute the signed distance for each test vector
|
||||||
|
signed_distances = np.sqrt(min_distances**2) * np.sign(
|
||||||
|
np.mean(test_vectors - closest_prototypes, axis=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
return pd.Series(signed_distances)
|
||||||
|
|
||||||
|
|
||||||
|
def rce_drift(reference_data: pd.DataFrame, real_data: pd.DataFrame, column: str) -> pd.Series:
|
||||||
|
"""
|
||||||
|
Detect drift using the Reduced Coulomb Energy (RCE) method.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reference_data (pd.DataFrame): The reference data
|
||||||
|
real_data (pd.DataFrame): The real data
|
||||||
|
column (str): The target column to be analyzed. 'target' or 'prediction'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
pd.Series: Normalized drift distances
|
||||||
|
"""
|
||||||
|
common_columns = list(set(reference_data.columns).intersection(real_data.columns))
|
||||||
|
reference_data = reference_data[common_columns]
|
||||||
|
real_data = real_data[common_columns]
|
||||||
|
|
||||||
|
# Get prototypes
|
||||||
|
if column == 'target':
|
||||||
|
prototypes = rce_train(reference_data.drop(columns=['prediction']), 0.1)
|
||||||
|
else:
|
||||||
|
prototypes = rce_train(reference_data.drop(columns=['target']), 0.1)
|
||||||
|
|
||||||
|
# Distances to prototypes
|
||||||
|
if column == 'target':
|
||||||
|
distances_train = rce_test(reference_data.drop(columns=['prediction']), prototypes)
|
||||||
|
distances_test = rce_test(real_data.drop(columns=['prediction']), prototypes)
|
||||||
|
else:
|
||||||
|
distances_train = rce_test(reference_data.drop(columns=['target']), prototypes)
|
||||||
|
distances_test = rce_test(real_data.drop(columns=['target']), prototypes)
|
||||||
|
|
||||||
|
# Find the maximum absolute distance in the training set
|
||||||
|
max_abs_distance = max(abs(distances_train.max()), abs(distances_train.min()))
|
||||||
|
|
||||||
|
# Normalize while preserving sign
|
||||||
|
distances = distances_test / max_abs_distance
|
||||||
|
|
||||||
|
return distances
|
||||||
292
model_manager/sientia/reports.py
Normal file
292
model_manager/sientia/reports.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import os
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup, Tag
|
||||||
|
from evidently.metric_preset import DataDriftPreset
|
||||||
|
from evidently.metrics import (
|
||||||
|
ColumnSummaryMetric,
|
||||||
|
ConflictTargetMetric,
|
||||||
|
DatasetCorrelationsMetric,
|
||||||
|
DatasetSummaryMetric,
|
||||||
|
RegressionAbsPercentageErrorPlot,
|
||||||
|
RegressionDummyMetric,
|
||||||
|
RegressionErrorDistribution,
|
||||||
|
RegressionErrorPlot,
|
||||||
|
RegressionPerformanceMetrics,
|
||||||
|
RegressionPredictedVsActualPlot,
|
||||||
|
RegressionPredictedVsActualScatter,
|
||||||
|
)
|
||||||
|
from evidently.metrics.base_metric import generate_column_metrics
|
||||||
|
from evidently.options import ColorOptions
|
||||||
|
from evidently.pipeline.column_mapping import ColumnMapping
|
||||||
|
from evidently.report import Report
|
||||||
|
|
||||||
|
COLOR_DISCRETE_SEQUENCE = (
|
||||||
|
'#ed0400',
|
||||||
|
'#0a5f38',
|
||||||
|
'#6c3461',
|
||||||
|
'#71aa34',
|
||||||
|
'#d8dcd6',
|
||||||
|
'#6b8ba4',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_html_from_file(file_path):
|
||||||
|
with open(file_path, encoding='utf-8') as file:
|
||||||
|
return file.read()
|
||||||
|
|
||||||
|
|
||||||
|
def inject_content(main_html, section_id, content):
|
||||||
|
soup = BeautifulSoup(main_html, 'html.parser')
|
||||||
|
section = soup.find(id=section_id)
|
||||||
|
|
||||||
|
# Verifica se a seção foi encontrada E se ela é uma Tag (não uma string)
|
||||||
|
if section and isinstance(section, Tag):
|
||||||
|
section.clear()
|
||||||
|
# Converte o conteúdo para um fragmento de BeautifulSoup e anexa
|
||||||
|
new_content = BeautifulSoup(content, 'html.parser')
|
||||||
|
section.append(new_content)
|
||||||
|
|
||||||
|
return str(soup)
|
||||||
|
|
||||||
|
|
||||||
|
class Reports:
|
||||||
|
"""
|
||||||
|
Report generator using Evidently library.
|
||||||
|
|
||||||
|
Thread-safety: This class is NOT thread-safe. Multiple threads should not
|
||||||
|
call add_*_section() methods on the same instance simultaneously as they
|
||||||
|
modify shared state (self.metrics, self.sections, self.options).
|
||||||
|
|
||||||
|
For multi-threaded environments:
|
||||||
|
- Create separate Reports instances per thread
|
||||||
|
- Or synchronize access using locks
|
||||||
|
- After generation, instances are safe for read-only operations
|
||||||
|
|
||||||
|
I/O Note: This class relies on Evidently's report.save_html() method
|
||||||
|
for file operations. Ensure Evidently properly manages file handles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
reference_data: Any,
|
||||||
|
current_data: Any,
|
||||||
|
target_name: str,
|
||||||
|
base_path: str | None = None,
|
||||||
|
template_path: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Initializes an instance of the AigReport class.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reference_data: The reference data for the report.
|
||||||
|
current_data: The current data for the report.
|
||||||
|
base_path: The base path for the report.
|
||||||
|
"""
|
||||||
|
self.metrics: list[Any] = []
|
||||||
|
self.options: list[Any] | None = None
|
||||||
|
self.sections: dict[str, Any] = {}
|
||||||
|
self.report: Any = None
|
||||||
|
self.ref_data = reference_data
|
||||||
|
self.cur_data = current_data
|
||||||
|
self.target_name = target_name
|
||||||
|
self.set_color_options(primary_color='#0F4C81', secondary_color='#001E60')
|
||||||
|
self.base_path = base_path
|
||||||
|
self.template_path = template_path
|
||||||
|
|
||||||
|
def add_data_quality_section(self, columns: list[str] | None = None, run: bool = True) -> None:
|
||||||
|
"""
|
||||||
|
Adds a data quality section to the report.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
columns: The list of columns to include in the data quality section. If None, all columns will be included.
|
||||||
|
run: Indicates whether to run the report immediately after adding the section.
|
||||||
|
"""
|
||||||
|
metrics = [
|
||||||
|
DatasetSummaryMetric(),
|
||||||
|
generate_column_metrics(ColumnSummaryMetric, columns=columns, skip_id_column=True),
|
||||||
|
ConflictTargetMetric(),
|
||||||
|
DatasetCorrelationsMetric(),
|
||||||
|
]
|
||||||
|
self.metrics.extend(metrics)
|
||||||
|
if run:
|
||||||
|
mapping = ColumnMapping()
|
||||||
|
mapping.target = self.target_name
|
||||||
|
report = Report(metrics=metrics, options=self.options)
|
||||||
|
report.run(
|
||||||
|
reference_data=self.ref_data,
|
||||||
|
current_data=self.cur_data,
|
||||||
|
column_mapping=mapping,
|
||||||
|
)
|
||||||
|
self.sections['data_quality'] = report.as_dict()
|
||||||
|
if self.base_path:
|
||||||
|
# Note: Relies on Evidently's save_html() to properly manage file I/O
|
||||||
|
report.save_html(os.path.join(self.base_path, 'data_quality.html'))
|
||||||
|
|
||||||
|
def add_data_drift_section(self, columns: list[str] | None = None, run: bool = True) -> None:
|
||||||
|
"""
|
||||||
|
Adds a data drift section to the report.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
columns: The list of columns to include in the data drift section. If None, all columns will be included.
|
||||||
|
run: Indicates whether to run the report immediately after adding the section.
|
||||||
|
"""
|
||||||
|
self.metrics.append(DataDriftPreset(columns=columns))
|
||||||
|
if run:
|
||||||
|
mapping = ColumnMapping()
|
||||||
|
mapping.target = self.target_name
|
||||||
|
report = Report(metrics=[DataDriftPreset(columns=columns)], options=self.options)
|
||||||
|
report.run(
|
||||||
|
reference_data=self.ref_data,
|
||||||
|
current_data=self.cur_data,
|
||||||
|
column_mapping=mapping,
|
||||||
|
)
|
||||||
|
self.sections['data_drift'] = report.as_dict()
|
||||||
|
if self.base_path:
|
||||||
|
# Note: Relies on Evidently's save_html() to properly manage file I/O
|
||||||
|
report.save_html(os.path.join(self.base_path, 'data_drift.html'))
|
||||||
|
|
||||||
|
def add_regression_section(self, run: bool = True) -> None:
|
||||||
|
"""
|
||||||
|
Adds a regression section to the report.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
run: Indicates whether to run the report immediately after adding the section.
|
||||||
|
"""
|
||||||
|
metrics = [
|
||||||
|
RegressionPerformanceMetrics(),
|
||||||
|
RegressionDummyMetric(),
|
||||||
|
RegressionPredictedVsActualScatter(),
|
||||||
|
RegressionPredictedVsActualPlot(),
|
||||||
|
RegressionErrorPlot(),
|
||||||
|
RegressionAbsPercentageErrorPlot(),
|
||||||
|
RegressionErrorDistribution(),
|
||||||
|
]
|
||||||
|
self.metrics.extend(metrics)
|
||||||
|
if run:
|
||||||
|
mapping = ColumnMapping()
|
||||||
|
|
||||||
|
mapping.target = self.target_name
|
||||||
|
mapping.prediction = 'prediction'
|
||||||
|
|
||||||
|
report = Report(metrics=metrics, options=self.options)
|
||||||
|
report.run(
|
||||||
|
reference_data=self.ref_data,
|
||||||
|
current_data=self.cur_data,
|
||||||
|
column_mapping=mapping,
|
||||||
|
)
|
||||||
|
self.sections['regression'] = report.as_dict()
|
||||||
|
if self.base_path:
|
||||||
|
# Note: Relies on Evidently's save_html() to properly manage file I/O
|
||||||
|
report.save_html(os.path.join(self.base_path, 'regression.html'))
|
||||||
|
|
||||||
|
def set_color_options(
|
||||||
|
self,
|
||||||
|
primary_color: str = '#0F4C81',
|
||||||
|
secondary_color: str = '#001E60',
|
||||||
|
current_data_color: str | None = None,
|
||||||
|
reference_data_color: str | None = None,
|
||||||
|
additional_data_color: str = '#0a5f38',
|
||||||
|
color_sequence: Sequence[str] = COLOR_DISCRETE_SEQUENCE,
|
||||||
|
fill_color: str = 'LightGreen',
|
||||||
|
zero_line_color: str = 'green',
|
||||||
|
non_visible_color: str = 'white',
|
||||||
|
underestimation_color: str = '#6574f7',
|
||||||
|
overestimation_color: str = '#ee5540',
|
||||||
|
majority_color: str = '#1acc98',
|
||||||
|
vertical_lines: str = 'green',
|
||||||
|
heatmap: str = 'RdBu_r',
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Sets the color options for the report.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
primary_color: The primary color for the report.
|
||||||
|
secondary_color: The secondary color for the report.
|
||||||
|
current_data_color: The color for the current data.
|
||||||
|
reference_data_color: The color for the reference data.
|
||||||
|
additional_data_color: The color for additional data.
|
||||||
|
color_sequence: The color sequence for discrete values.
|
||||||
|
fill_color: The fill color for visualizations.
|
||||||
|
zero_line_color: The color for the zero line.
|
||||||
|
non_visible_color: The color for non-visible elements.
|
||||||
|
underestimation_color: The color for underestimation.
|
||||||
|
overestimation_color: The color for overestimation.
|
||||||
|
majority_color: The color for majority elements.
|
||||||
|
vertical_lines: The color for vertical lines.
|
||||||
|
heatmap: The color map for heatmaps.
|
||||||
|
"""
|
||||||
|
color_scheme = ColorOptions(
|
||||||
|
primary_color=primary_color,
|
||||||
|
secondary_color=secondary_color,
|
||||||
|
current_data_color=current_data_color,
|
||||||
|
reference_data_color=reference_data_color,
|
||||||
|
additional_data_color=additional_data_color,
|
||||||
|
color_sequence=color_sequence,
|
||||||
|
fill_color=fill_color,
|
||||||
|
zero_line_color=zero_line_color,
|
||||||
|
non_visible_color=non_visible_color,
|
||||||
|
underestimation_color=underestimation_color,
|
||||||
|
overestimation_color=overestimation_color,
|
||||||
|
majority_color=majority_color,
|
||||||
|
vertical_lines=vertical_lines,
|
||||||
|
heatmap=heatmap,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.options is None:
|
||||||
|
self.options = [color_scheme]
|
||||||
|
else:
|
||||||
|
self.options.append(color_scheme)
|
||||||
|
|
||||||
|
def save_all_sections_html(self, report_path):
|
||||||
|
"""
|
||||||
|
Saves the report with all sections as HTML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
report_path: The path to save the report HTML file.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If base_path is not set
|
||||||
|
OSError: If directory creation or file writing fails
|
||||||
|
|
||||||
|
Note:
|
||||||
|
This method uses context manager (with open) to ensure file is properly closed.
|
||||||
|
Creates parent directories if they don't exist.
|
||||||
|
"""
|
||||||
|
if not self.base_path:
|
||||||
|
raise ValueError('base_path is required to save all sections HTML')
|
||||||
|
|
||||||
|
if not self.template_path:
|
||||||
|
raise ValueError('template_path is required to save all sections HTML')
|
||||||
|
|
||||||
|
# Ensure output directory exists
|
||||||
|
output_dir = os.path.dirname(report_path)
|
||||||
|
if output_dir and not os.path.exists(output_dir):
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
print(f'Output directory: {output_dir}')
|
||||||
|
print(f'Report path: {report_path}')
|
||||||
|
print(f'Base path: {self.base_path}')
|
||||||
|
print(f'Template path: {self.template_path}')
|
||||||
|
|
||||||
|
# Load main HTML template
|
||||||
|
main_html_path = os.path.join(self.template_path, 'header.html')
|
||||||
|
main_html = load_html_from_file(main_html_path)
|
||||||
|
|
||||||
|
# Load content from data_drift.html, data_quality.html, and regression.html
|
||||||
|
data_drift_content = load_html_from_file(os.path.join(self.base_path, 'data_drift.html'))
|
||||||
|
data_quality_content = load_html_from_file(
|
||||||
|
os.path.join(self.base_path, 'data_quality.html')
|
||||||
|
)
|
||||||
|
regression_content = load_html_from_file(os.path.join(self.base_path, 'regression.html'))
|
||||||
|
|
||||||
|
# Inject content into the main HTML template
|
||||||
|
main_html = inject_content(main_html, 'data_drift', data_drift_content)
|
||||||
|
main_html = inject_content(main_html, 'data_quality', data_quality_content)
|
||||||
|
main_html = inject_content(main_html, 'regression', regression_content)
|
||||||
|
|
||||||
|
# Save the final HTML to a new file (report.html)
|
||||||
|
# Context manager ensures file is properly closed even if an error occurs
|
||||||
|
with open(report_path, 'w', encoding='utf-8') as report_file:
|
||||||
|
report_file.write(main_html)
|
||||||
0
model_manager/utils/__init__.py
Normal file
0
model_manager/utils/__init__.py
Normal file
166
model_manager/utils/connectors_config.py
Normal file
166
model_manager/utils/connectors_config.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
from os import getenv
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def build_postgres_config() -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Build PostgreSQL database configuration from environment variables.
|
||||||
|
|
||||||
|
This function constructs a PostgreSQL configuration dictionary from
|
||||||
|
environment variables with sensible defaults for local development.
|
||||||
|
It handles connection pool configuration and security parameters.
|
||||||
|
|
||||||
|
Environment Variables:
|
||||||
|
POSTGRES_HOST: Database hostname (default: localhost)
|
||||||
|
POSTGRES_PORT: Database port (default: 5432)
|
||||||
|
POSTGRES_USER: Database username (default: sientia)
|
||||||
|
POSTGRES_PASSWORD: Database password (default: sientia)
|
||||||
|
POSTGRES_DBNAME: Database name (default: sientia)
|
||||||
|
POSTGRES_MIN_CONNECTIONS: Minimum connection pool size (default: 5)
|
||||||
|
POSTGRES_MAX_CONNECTIONS: Maximum connection pool size (default: 20)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: PostgreSQL configuration dictionary with all required parameters
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'host': getenv('POSTGRES_HOST', 'localhost'),
|
||||||
|
'port': int(getenv('POSTGRES_PORT', '5432')),
|
||||||
|
'user': getenv('POSTGRES_USER', 'sientia'),
|
||||||
|
'password': getenv('POSTGRES_PASSWORD', 'sientia'),
|
||||||
|
'dbname': getenv('POSTGRES_DBNAME', 'sientia'),
|
||||||
|
'min_connections': int(getenv('POSTGRES_MIN_CONNECTIONS', '5')),
|
||||||
|
'max_connections': int(getenv('POSTGRES_MAX_CONNECTIONS', '20')),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_mlflow_config() -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Build MLFlow server configuration from environment variables.
|
||||||
|
|
||||||
|
This function constructs an MLFlow configuration dictionary from
|
||||||
|
environment variables with sensible defaults for local development.
|
||||||
|
It handles server connection and authentication parameters.
|
||||||
|
|
||||||
|
Environment Variables:
|
||||||
|
MLFLOW_URL: Full MLflow tracking URL including scheme, host, and port
|
||||||
|
(default: http://localhost:5080)
|
||||||
|
MLFLOW_USERNAME: MLFlow username (default: aignosi)
|
||||||
|
MLFLOW_PASSWORD: MLFlow password (default: aignosi)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: MLFlow configuration dictionary with all required parameters
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'url': getenv('MLFLOW_URL', 'http://localhost:5080'),
|
||||||
|
'username': getenv('MLFLOW_USERNAME', 'aignosi'),
|
||||||
|
'password': getenv('MLFLOW_PASSWORD', 'aignosi'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_mongodb_config() -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Build MongoDB configuration from environment variables.
|
||||||
|
|
||||||
|
This function constructs a MongoDB configuration dictionary from
|
||||||
|
environment variables with sensible defaults for local development.
|
||||||
|
It handles connection string and database name configuration.
|
||||||
|
|
||||||
|
Environment Variables:
|
||||||
|
MONGODB_USERNAME: MongoDB username (default: root)
|
||||||
|
MONGODB_PASSWORD: MongoDB password (default: wKZDbMNU1c)
|
||||||
|
MONGODB_URL: MongoDB connection URI (default: localhost:27018)
|
||||||
|
MONGODB_DATABASE: MongoDB database name (default: sientia)
|
||||||
|
MONGODB_TTL_INDEX_HOURS: TTL index duration in hours (default: 1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: MongoDB configuration dictionary with connection parameters
|
||||||
|
"""
|
||||||
|
username = getenv('MONGODB_USERNAME', 'root')
|
||||||
|
password = getenv('MONGODB_PASSWORD', 'wKZDbMNU1c')
|
||||||
|
uri = getenv('MONGODB_URL', 'localhost:27018')
|
||||||
|
|
||||||
|
connection_string = f'mongodb://{username}:{password}@{uri}'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'connection_string': connection_string,
|
||||||
|
'database_name': getenv('MONGODB_DATABASE', 'sientia'),
|
||||||
|
'ttl_index_seconds': int(getenv('MONGODB_TTL_INDEX_HOURS', '1')) * 3600,
|
||||||
|
'uri': uri,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_minio_config() -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Build MinIO (S3-compatible) configuration from environment variables.
|
||||||
|
|
||||||
|
This function constructs a MinIO configuration dictionary from
|
||||||
|
environment variables with sensible defaults for local development.
|
||||||
|
It handles endpoint URL, authentication, connection parameters, and retry policies.
|
||||||
|
|
||||||
|
Environment Variables:
|
||||||
|
MINIO_ENDPOINT_URL: MinIO server endpoint URL (default: http://localhost:9000)
|
||||||
|
MINIO_ACCESS_KEY: MinIO access key ID (default: minioadmin)
|
||||||
|
MINIO_SECRET_KEY: MinIO secret access key (default: minioadmin)
|
||||||
|
MINIO_REGION: MinIO region name (default: us-east-1)
|
||||||
|
MINIO_SECURE: Whether to use SSL/TLS (default: false)
|
||||||
|
MINIO_MAX_RETRY_ATTEMPTS: Maximum number of retry attempts (default: 3)
|
||||||
|
MINIO_RETRY_MODE: Retry mode - standard, legacy, or adaptive (default: adaptive)
|
||||||
|
MINIO_CONNECT_TIMEOUT: Connection timeout in seconds (default: 10)
|
||||||
|
MINIO_READ_TIMEOUT: Read timeout in seconds (default: 60)
|
||||||
|
MINIO_DEFAULT_BUCKET: Default S3 bucket for MinioRepository (default: model-training)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: MinIO configuration dictionary with all required parameters
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'endpoint_url': getenv('MINIO_ENDPOINT_URL', 'http://localhost:9000'),
|
||||||
|
'access_key': getenv('MINIO_ACCESS_KEY', 'minioadmin'),
|
||||||
|
'secret_key': getenv('MINIO_SECRET_KEY', 'minioadmin'),
|
||||||
|
'region': getenv('MINIO_REGION', 'us-east-1'),
|
||||||
|
'use_ssl': getenv('MINIO_SECURE', 'false').lower() == 'true',
|
||||||
|
'max_retry_attempts': int(getenv('MINIO_MAX_RETRY_ATTEMPTS', '3')),
|
||||||
|
'retry_mode': getenv('MINIO_RETRY_MODE', 'adaptive'),
|
||||||
|
'connect_timeout': int(getenv('MINIO_CONNECT_TIMEOUT', '10')),
|
||||||
|
'read_timeout': int(getenv('MINIO_READ_TIMEOUT', '60')),
|
||||||
|
'default_bucket': getenv('MINIO_DEFAULT_BUCKET', 'model-training'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_plugin_store_config() -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Build PluginStore configuration from environment variables.
|
||||||
|
|
||||||
|
This function constructs a configuration dictionary for the PluginStore
|
||||||
|
client using environment variables with sensible defaults for local
|
||||||
|
development.
|
||||||
|
|
||||||
|
Environment Variables:
|
||||||
|
STORE_BASE_URL: Base URL of the PluginStore backing Git server
|
||||||
|
(default: http://localhost:3000)
|
||||||
|
STORE_OWNER: Repository owner/organization (default: sientia)
|
||||||
|
STORE_REPO: Repository name (default: model-library-store)
|
||||||
|
STORE_BRANCH: Optional branch name
|
||||||
|
STORE_USERNAME: Optional username for Git HTTP authentication
|
||||||
|
STORE_PASSWORD: Optional password/token for Git HTTP authentication
|
||||||
|
PYPI_SERVER: Optional custom PyPI index URL for runtime installation
|
||||||
|
(default: http://localhost:5000)
|
||||||
|
PYPI_USERNAME: Optional username for PyPI authentication
|
||||||
|
PYPI_PASSWORD: Optional password/token for PyPI authentication
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: PluginStore configuration dictionary with all connector parameters
|
||||||
|
"""
|
||||||
|
|
||||||
|
cache_ttl_seconds = getenv('STORE_CACHE_TTL_SECONDS')
|
||||||
|
return {
|
||||||
|
'base_url': getenv('STORE_BASE_URL', 'http://localhost:3000'),
|
||||||
|
'owner': getenv('STORE_OWNER', 'sientia'),
|
||||||
|
'repo': getenv('STORE_REPO', 'model-library-store'),
|
||||||
|
'username': getenv('STORE_USERNAME'),
|
||||||
|
'password': getenv('STORE_PASSWORD'),
|
||||||
|
'branch': getenv('STORE_BRANCH'),
|
||||||
|
'cache_ttl_seconds': int(cache_ttl_seconds) if cache_ttl_seconds else None,
|
||||||
|
'pypi_index_url': getenv('PYPI_SERVER', 'http://localhost:5000'),
|
||||||
|
'pypi_username': getenv('PYPI_USERNAME'),
|
||||||
|
'pypi_password': getenv('PYPI_PASSWORD'),
|
||||||
|
}
|
||||||
27
model_manager/utils/logger_helper.py
Normal file
27
model_manager/utils/logger_helper.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""
|
||||||
|
Logger helper to prevent duplicate logs caused by propagation.
|
||||||
|
|
||||||
|
This module provides a wrapper around sientia_do Logger to disable
|
||||||
|
log propagation and prevent duplicate log entries in the Model Manager.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sientia_do.observability.logger import Logger as SientiaLogger
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> SientiaLogger:
|
||||||
|
"""
|
||||||
|
Create a Logger instance with propagation disabled.
|
||||||
|
|
||||||
|
This prevents duplicate logs caused by hierarchical propagation
|
||||||
|
in Python's logging system.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Logger name (typically __name__ of the calling module).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Logger: Configured logger instance with propagation disabled.
|
||||||
|
"""
|
||||||
|
logger = SientiaLogger(name)
|
||||||
|
# Disable propagation to prevent duplicate logs
|
||||||
|
logger.base_logger.propagate = False
|
||||||
|
return logger
|
||||||
16
model_manager/utils/models/__init__.py
Normal file
16
model_manager/utils/models/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
Models and DTOs for the Model Manager system.
|
||||||
|
|
||||||
|
This module contains data transfer objects (DTOs) and model classes used
|
||||||
|
throughout the Model Manager workflows and activities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from model_manager.utils.models.experiment_status import ExperimentStatus
|
||||||
|
from model_manager.utils.models.train_model_params import TrainModelParams
|
||||||
|
from model_manager.utils.models.train_model_result import TrainModelResult
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'ExperimentStatus',
|
||||||
|
'TrainModelParams',
|
||||||
|
'TrainModelResult',
|
||||||
|
]
|
||||||
26
model_manager/utils/models/experiment_status.py
Normal file
26
model_manager/utils/models/experiment_status.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class ExperimentStatus(StrEnum):
|
||||||
|
"""
|
||||||
|
Status values for experiment run lifecycle.
|
||||||
|
|
||||||
|
This enum defines all possible status values that an experiment run can have
|
||||||
|
throughout its lifecycle, from initialization through training, model saving,
|
||||||
|
and cleanup. These statuses are used to track progress and identify failures
|
||||||
|
in the training pipeline.
|
||||||
|
|
||||||
|
The status values follow the naming convention from the original Mage pipeline
|
||||||
|
to maintain compatibility with existing database records and monitoring systems.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
ORCHESTRATOR_WAITING_PROC: Initial status indicating experiment is registered and waiting for processing.
|
||||||
|
ORCHESTRATOR_VALIDATION_ERROR: Error in the parameters validation.
|
||||||
|
TRAINING_SUCCESS: Training completed successfully with model and metrics calculated.
|
||||||
|
TRAINING_ERROR: Training failed due to data issues, model errors, or other exceptions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ORCHESTRATOR_VALIDATION_ERROR = 'ORCHESTRATOR_VALIDATION_ERROR'
|
||||||
|
ORCHESTRATOR_WAITING_PROC = 'ORCHESTRATOR_WAITING_PROC'
|
||||||
|
TRAINING_SUCCESS = 'TRAINING_SUCCESS'
|
||||||
|
TRAINING_ERROR = 'TRAINING_ERROR'
|
||||||
363
model_manager/utils/models/train_model_params.py
Normal file
363
model_manager/utils/models/train_model_params.py
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from jsonschema import Draft202012Validator, ValidationError # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
# Allowed frontend date formats and their strftime equivalents (single source of truth)
|
||||||
|
FRONTEND_DATE_FORMAT_TO_STRFTIME = {
|
||||||
|
'dd/MM/yyyy HH:mm:ss': '%d/%m/%Y %H:%M:%S',
|
||||||
|
'MM/dd/yyyy HH:mm:ss': '%m/%d/%Y %H:%M:%S',
|
||||||
|
'yyyy/MM/dd HH:mm:ss': '%Y/%m/%d %H:%M:%S',
|
||||||
|
'dd-MM-yyyy HH:mm:ss': '%d-%m-%Y %H:%M:%S',
|
||||||
|
'MM-dd-yyyy HH:mm:ss': '%m-%d-%Y %H:%M:%S',
|
||||||
|
'yyyy-MM-dd HH:mm:ss': '%Y-%m-%d %H:%M:%S',
|
||||||
|
}
|
||||||
|
ALLOWED_FRONTEND_DATE_FORMATS = frozenset(FRONTEND_DATE_FORMAT_TO_STRFTIME.keys())
|
||||||
|
|
||||||
|
# When the client omits date_format (or sends null/blank), parsing uses this frontend format.
|
||||||
|
DEFAULT_TRAIN_DATE_FORMAT = 'yyyy-MM-dd HH:mm:ss'
|
||||||
|
|
||||||
|
|
||||||
|
def validate_frontend_date_format(fmt: str | None) -> None:
|
||||||
|
"""Raise ValueError if fmt is set and not one of the allowed frontend date formats."""
|
||||||
|
if not fmt or not fmt.strip():
|
||||||
|
return
|
||||||
|
if fmt not in ALLOWED_FRONTEND_DATE_FORMATS:
|
||||||
|
allowed = ', '.join(sorted(ALLOWED_FRONTEND_DATE_FORMATS))
|
||||||
|
raise ValueError(f'Invalid date_format "{fmt}". Allowed formats: {allowed}')
|
||||||
|
|
||||||
|
|
||||||
|
# Model name constants
|
||||||
|
MODEL_LINEAR_REGRESSION = 'Linear Regression'
|
||||||
|
MODEL_POLYNOMIAL_REGRESSION = 'Polynomial Regression'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrainModelParams:
|
||||||
|
"""
|
||||||
|
Parameters for machine learning model training.
|
||||||
|
|
||||||
|
This class encapsulates all configuration parameters required for the training
|
||||||
|
pipeline, including data processing settings, model configuration, and experiment
|
||||||
|
tracking information. All parameters are validated upon initialization to ensure
|
||||||
|
data integrity and prevent runtime errors.
|
||||||
|
|
||||||
|
Use the `from_dict()` class method to create instances from dictionaries with
|
||||||
|
automatic validation of all fields.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
variable_columns (list[str]): List of variable column names to use as features.
|
||||||
|
target_variable (str): Name of the target variable to predict.
|
||||||
|
bucket_name (str): Name of the MinIO bucket containing training data.
|
||||||
|
file_name (str): Name of the file in the MinIO bucket.
|
||||||
|
line_separator (str): Line separator used in the CSV file.
|
||||||
|
decimal_separator (str): Decimal separator used in the CSV file.
|
||||||
|
date_column (str): Name of the date/time column in the dataset (required).
|
||||||
|
date_format (str): Format of the date column (allowed frontend strings). If omitted or blank
|
||||||
|
in the input dict, defaults to DEFAULT_TRAIN_DATE_FORMAT.
|
||||||
|
train_size (int): Percentage of data to use for training (0-100).
|
||||||
|
shuffle (bool): Whether to shuffle the data during train/test split.
|
||||||
|
experiment_run_id (int): Unique identifier for the experiment run.
|
||||||
|
model_name (str): Name of the model type ('Linear Regression' or 'Polynomial Regression').
|
||||||
|
val_file_name (str | None): Name of the validation file in the MinIO bucket.
|
||||||
|
data_model_kwargs (dict | None): Keyword arguments for the data model.
|
||||||
|
model_kwargs (dict | None): Keyword arguments for the model.
|
||||||
|
opt_params (dict | None): Keyword optimazation arguments for the wrapper.
|
||||||
|
model_type (str): Type of the model to use (ex.: 'Linear Regression', 'XGBoost').
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Old Parameters (keep)
|
||||||
|
|
||||||
|
variable_columns: list[str]
|
||||||
|
target_variable: str
|
||||||
|
bucket_name: str
|
||||||
|
file_name: str
|
||||||
|
line_separator: str
|
||||||
|
decimal_separator: str
|
||||||
|
date_column: str
|
||||||
|
date_format: str
|
||||||
|
train_size: int
|
||||||
|
shuffle: bool
|
||||||
|
random_state: int
|
||||||
|
experiment_run_id: int
|
||||||
|
model_name: str
|
||||||
|
experiment_name: str
|
||||||
|
|
||||||
|
# New Parameters
|
||||||
|
val_file_name: str | None
|
||||||
|
data_model_kwargs: dict | None # Removed params used in DataPreprocessor here
|
||||||
|
model_kwargs: dict | None # Removed params used in Linear Regression Model here
|
||||||
|
opt_params: dict | None
|
||||||
|
model_type: str
|
||||||
|
model_id: str | None
|
||||||
|
|
||||||
|
# Context Parameters
|
||||||
|
model_metadata: dict | None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> 'TrainModelParams':
|
||||||
|
"""
|
||||||
|
Create TrainModelParams from dictionary with validation.
|
||||||
|
|
||||||
|
This factory method creates a TrainModelParams instance from a dictionary,
|
||||||
|
applying validation to ensure all required fields are present and have
|
||||||
|
the correct types. This is the recommended way to create instances from
|
||||||
|
workflow input data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Dictionary containing training parameters with keys matching the
|
||||||
|
attribute names (e.g. variable_columns, date_column, data_model_kwargs, model_kwargs, opt_params).
|
||||||
|
Unknown keys are ignored by from_dict; missing required snake_case keys raise.
|
||||||
|
model_metadata may be omitted or None until load_model_metadata fills it.
|
||||||
|
experiment_run_id may be an int or numeric string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TrainModelParams: Validated instance with all fields populated
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If any required field is missing or None
|
||||||
|
TypeError: If any field has an incorrect type
|
||||||
|
KeyError: If any required key is missing from the dictionary
|
||||||
|
"""
|
||||||
|
# `from_dict()` should only build the "raw" object from the input dict.
|
||||||
|
# Semantic validation and defaults must be handled by `validate_business_rules()`
|
||||||
|
# (using `model_metadata` JSON Schemas).
|
||||||
|
|
||||||
|
model_name = cls._check_none(data.get('model_name'), str, 'model_name')
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
variable_columns=cls._check_none(
|
||||||
|
data.get('variable_columns'), list, 'variable_columns'
|
||||||
|
),
|
||||||
|
target_variable=cls._check_none(data.get('target_variable'), str, 'target_variable'),
|
||||||
|
bucket_name=cls._check_none(data.get('bucket_name'), str, 'bucket_name'),
|
||||||
|
file_name=cls._check_none(data.get('file_name'), str, 'file_name'),
|
||||||
|
line_separator=cls._check_none(data.get('line_separator'), str, 'line_separator'),
|
||||||
|
decimal_separator=cls._check_none(
|
||||||
|
data.get('decimal_separator'), str, 'decimal_separator'
|
||||||
|
),
|
||||||
|
date_column=cls._check_none(data.get('date_column'), str, 'date_column'),
|
||||||
|
date_format=cls._resolve_date_format(data.get('date_format')),
|
||||||
|
train_size=cls._check_none(data.get('train_size'), int, 'train_size'),
|
||||||
|
shuffle=cls._check_none(data.get('shuffle'), bool, 'shuffle'),
|
||||||
|
random_state=cls._check_none(data.get('random_state', 42), int, 'random_state'),
|
||||||
|
experiment_run_id=cls._coerce_experiment_run_id(data.get('experiment_run_id')),
|
||||||
|
model_name=model_name,
|
||||||
|
experiment_name=model_name,
|
||||||
|
val_file_name=data.get('val_file_name'),
|
||||||
|
data_model_kwargs=cls._check_none(
|
||||||
|
data.get('data_model_kwargs'), dict, 'data_model_kwargs'
|
||||||
|
),
|
||||||
|
model_kwargs=cls._check_none(data.get('model_kwargs'), dict, 'model_kwargs'),
|
||||||
|
opt_params=cls._check_none(data.get('opt_params'), dict, 'opt_params'),
|
||||||
|
model_type=cls._check_none(data.get('model_type'), str, 'model_type'),
|
||||||
|
model_id=data.get('model_id'),
|
||||||
|
model_metadata=cls._parse_optional_model_metadata(data.get('model_metadata')),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_date_format(raw: Any) -> str:
|
||||||
|
"""
|
||||||
|
Resolve date_format from workflow input.
|
||||||
|
|
||||||
|
Omitted, null, or blank values use DEFAULT_TRAIN_DATE_FORMAT. Non-string types raise.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw: Raw date_format from the payload, or None if absent.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
str: Canonical frontend date format string.
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return DEFAULT_TRAIN_DATE_FORMAT
|
||||||
|
if isinstance(raw, str) and not raw.strip():
|
||||||
|
return DEFAULT_TRAIN_DATE_FORMAT
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
raise TypeError(
|
||||||
|
f'date_format must be a string or omitted, but got {type(raw).__name__}.'
|
||||||
|
)
|
||||||
|
return raw.strip()
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convert TrainModelParams to a dictionary.
|
||||||
|
"""
|
||||||
|
return self.__dict__
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check_none(value: Any | None, expected_type: type, field_name: str) -> Any:
|
||||||
|
"""
|
||||||
|
Validate that a value is not None and check its type.
|
||||||
|
|
||||||
|
This method ensures that required parameters are provided and have the
|
||||||
|
correct type, raising descriptive errors if validation fails.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value (Any | None): The value to validate.
|
||||||
|
expected_type (type): The expected type of the value.
|
||||||
|
field_name (str): The name of the field being validated (for error messages).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: The validated value if it is not None and matches the expected type.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the value is None.
|
||||||
|
TypeError: If the value is not of the expected type.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
error = f'{field_name} is required and cannot be None.'
|
||||||
|
raise ValueError(error)
|
||||||
|
|
||||||
|
return TrainModelParams._check_type(value, expected_type, field_name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check_type(value: Any | None, expected_type: type, field_name: str) -> Any:
|
||||||
|
"""
|
||||||
|
Validate that a value matches the expected type.
|
||||||
|
|
||||||
|
This method checks type compatibility and raises a descriptive error
|
||||||
|
if the value does not match the expected type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value (Any | None): The value to validate.
|
||||||
|
expected_type (type): The expected type of the value.
|
||||||
|
field_name (str): The name of the field being validated (for error messages).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: The validated value if it matches the expected type.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If the value is not of the expected type.
|
||||||
|
"""
|
||||||
|
if value is not None and not isinstance(value, expected_type):
|
||||||
|
error = f'{field_name} must be of type {expected_type.__name__}, but got {type(value).__name__}.'
|
||||||
|
raise TypeError(error)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coerce_experiment_run_id(value: Any) -> int:
|
||||||
|
"""
|
||||||
|
Coerce experiment_run_id to int.
|
||||||
|
|
||||||
|
Workflow clients may send numeric strings; this keeps from_dict aligned with
|
||||||
|
workflow validation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Raw experiment_run_id from the payload.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Parsed experiment run id.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the value is None.
|
||||||
|
TypeError: If the value cannot be coerced to a non-boolean integer.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
raise ValueError('experiment_run_id is required and cannot be None.')
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise TypeError('experiment_run_id must be an integer, got bool.')
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str) and value.strip().isdigit():
|
||||||
|
return int(value.strip())
|
||||||
|
if isinstance(value, float) and value.is_integer():
|
||||||
|
return int(value)
|
||||||
|
raise TypeError(
|
||||||
|
f'experiment_run_id must be an integer or numeric string, but got {type(value).__name__}.'
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_optional_model_metadata(value: Any) -> dict | None:
|
||||||
|
"""
|
||||||
|
Parse model_metadata for from_dict before load_model_metadata fills the index.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: model_metadata from the payload, or None if not sent yet.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict | None: Dict when provided; None when absent (filled later by load_model_metadata).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If value is neither None nor a dict.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
raise TypeError(f'model_metadata must be a dict or None, but got {type(value).__name__}.')
|
||||||
|
|
||||||
|
def validate_business_rules(self) -> None:
|
||||||
|
"""
|
||||||
|
Validate business rules and constraints for training parameters.
|
||||||
|
|
||||||
|
This method performs additional validation beyond type checking to ensure
|
||||||
|
that parameter values are within acceptable ranges and logically consistent.
|
||||||
|
It implements defense-in-depth validation to catch configuration errors
|
||||||
|
early in the workflow.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If any business rule is violated
|
||||||
|
"""
|
||||||
|
self._validate_numeric_ranges()
|
||||||
|
self._validate_model_params()
|
||||||
|
self._validate_required_strings()
|
||||||
|
self._validate_date_format()
|
||||||
|
|
||||||
|
def _validate_numeric_ranges(self) -> None:
|
||||||
|
"""Validate numeric parameters are within acceptable ranges."""
|
||||||
|
if not 10 <= self.train_size <= 100:
|
||||||
|
raise ValueError(f'train_size must be between 10 and 100, got {self.train_size}')
|
||||||
|
|
||||||
|
if not self.variable_columns:
|
||||||
|
raise ValueError('variable_columns cannot be empty')
|
||||||
|
|
||||||
|
def _validate_model_params(self) -> None:
|
||||||
|
"""Validate model-related parameters."""
|
||||||
|
if not self.model_metadata:
|
||||||
|
raise ValueError('model_metadata is required')
|
||||||
|
|
||||||
|
schemas = self.model_metadata.get('schemas', {}).get('components', {}).get('schemas')
|
||||||
|
|
||||||
|
if not schemas:
|
||||||
|
return
|
||||||
|
|
||||||
|
data_model_schema = schemas.get('data_model')
|
||||||
|
model_schema = schemas.get('model')
|
||||||
|
opt_params_schema = schemas.get('opt_params')
|
||||||
|
|
||||||
|
if data_model_schema:
|
||||||
|
self._validate_model_param(data_model_schema, self.data_model_kwargs)
|
||||||
|
if model_schema:
|
||||||
|
self._validate_model_param(model_schema, self.model_kwargs)
|
||||||
|
if opt_params_schema:
|
||||||
|
self._validate_model_param(opt_params_schema, self.opt_params)
|
||||||
|
|
||||||
|
def _validate_model_param(self, schema: dict[str, Any], value: Any) -> None:
|
||||||
|
"""Validate model parameter against schema."""
|
||||||
|
try:
|
||||||
|
validator = Draft202012Validator(schema)
|
||||||
|
validator.validate(value)
|
||||||
|
except ValidationError as e:
|
||||||
|
raise ValueError(f'Model parameters validation failed: {e.message}') from e
|
||||||
|
|
||||||
|
def _validate_required_strings(self) -> None:
|
||||||
|
"""Validate required string fields are not empty."""
|
||||||
|
if not self.target_variable.strip():
|
||||||
|
raise ValueError('target_variable cannot be empty or whitespace')
|
||||||
|
|
||||||
|
if not self.bucket_name.strip():
|
||||||
|
raise ValueError('bucket_name cannot be empty or whitespace')
|
||||||
|
|
||||||
|
if not self.file_name.strip():
|
||||||
|
raise ValueError('file_name cannot be empty or whitespace')
|
||||||
|
|
||||||
|
if not self.model_name.strip():
|
||||||
|
raise ValueError('model_name cannot be empty or whitespace')
|
||||||
|
|
||||||
|
if not self.date_column.strip():
|
||||||
|
raise ValueError('date_column cannot be empty or whitespace')
|
||||||
|
|
||||||
|
def _validate_date_format(self) -> None:
|
||||||
|
"""Validate date_format is one of the allowed frontend formats."""
|
||||||
|
validate_frontend_date_format(self.date_format)
|
||||||
53
model_manager/utils/models/train_model_result.py
Normal file
53
model_manager/utils/models/train_model_result.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from model_manager.utils.models.train_model_params import TrainModelParams
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrainModelResult:
|
||||||
|
"""
|
||||||
|
A data container for storing the results of a machine learning training process.
|
||||||
|
|
||||||
|
This dataclass encapsulates all outputs from the training pipeline, including
|
||||||
|
the prepared datasets, evaluation metrics, and paths to generated artifacts.
|
||||||
|
It is used to pass results between activities in the training workflow.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
params (TrainModelParams): The parameters used to train the model.
|
||||||
|
x_train (pd.DataFrame): The training dataset features.
|
||||||
|
x_test (pd.DataFrame): The testing dataset features.
|
||||||
|
y_train (pd.DataFrame): The training dataset target values.
|
||||||
|
y_test (pd.DataFrame): The testing dataset target values.
|
||||||
|
y_pred (pd.Series | None): The predicted target values for the testing dataset. Default is None.
|
||||||
|
y_train_pred (pd.Series | None): The predicted target values for the training dataset. Default is None.
|
||||||
|
mse_val (float | None): The Mean Squared Error (MSE) of the predictions. Default is None.
|
||||||
|
mae_val (float | None): The Mean Absolute Error (MAE) of the predictions. Default is None.
|
||||||
|
r2_val (float | None): The R-squared (R²) value of the predictions. Default is None.
|
||||||
|
equation (dict | None): The equation of the model. Default is None.
|
||||||
|
equation_path (str | None): The path to the equation file. Default is None.
|
||||||
|
run_name (str | None): The name of the MLFlow run. Default is None.
|
||||||
|
report_path (str | None): The path to the generated HTML report file. Default is None.
|
||||||
|
train_data_path (str | None): The path to the training dataset CSV file. Default is None.
|
||||||
|
test_data_path (str | None): The path to the testing dataset CSV file. Default is None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
params: TrainModelParams
|
||||||
|
train_data: pd.DataFrame
|
||||||
|
val_data: pd.DataFrame
|
||||||
|
y_pred: pd.DataFrame | None = None
|
||||||
|
y_train_pred: pd.DataFrame | None = None
|
||||||
|
mse_val: float | None = None
|
||||||
|
mae_val: float | None = None
|
||||||
|
r2_val: float | None = None
|
||||||
|
equation: dict | None = None
|
||||||
|
equation_path: str | None = None
|
||||||
|
run_name: str | None = None
|
||||||
|
experiment_name: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
report_path: str | None = None
|
||||||
|
train_data_path: str | None = None
|
||||||
|
test_data_path: str | None = None
|
||||||
|
|
||||||
|
run_dir: str | None = None
|
||||||
636
model_manager/utils/repository/data_manager_repository.py
Normal file
636
model_manager/utils/repository/data_manager_repository.py
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
"""
|
||||||
|
Data management repository for the training pipeline.
|
||||||
|
|
||||||
|
This module provides the core data loading and preprocessing logic for the
|
||||||
|
training pipeline, including:
|
||||||
|
- CSV loading from in-memory bytes
|
||||||
|
- datetime parsing and index configuration
|
||||||
|
- optional support filters
|
||||||
|
- train/test split management (when no explicit validation dataset is provided)
|
||||||
|
|
||||||
|
It is intentionally decoupled from any specific model implementation or MLflow
|
||||||
|
integration. Models are trained elsewhere (e.g., via SientiaModel wrappers),
|
||||||
|
and this repository focuses solely on preparing data structures for them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from io import BytesIO
|
||||||
|
from os import makedirs, path
|
||||||
|
from shutil import rmtree
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from sientia_do.observability.logger import Logger
|
||||||
|
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||||
|
from sientia_model.wrappers.sientia_model import SientiaModel
|
||||||
|
|
||||||
|
from model_manager.runtime_paths import PROJECT_BASE_PATH, REPORTS_ROOT
|
||||||
|
from model_manager.sientia.metrics import mae, mse, r2
|
||||||
|
from model_manager.sientia.reports import Reports # type: ignore[import-untyped]
|
||||||
|
from model_manager.utils.models.train_model_params import (
|
||||||
|
FRONTEND_DATE_FORMAT_TO_STRFTIME,
|
||||||
|
TrainModelParams,
|
||||||
|
)
|
||||||
|
from model_manager.utils.models.train_model_result import TrainModelResult
|
||||||
|
|
||||||
|
|
||||||
|
def train_test_split(
|
||||||
|
data: pd.DataFrame,
|
||||||
|
train_size: float,
|
||||||
|
random_state: int | None = None,
|
||||||
|
shuffle: bool = True,
|
||||||
|
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||||
|
# 1. Definir a semente (seed) para reprodutibilidade
|
||||||
|
if random_state is not None:
|
||||||
|
np.random.seed(random_state)
|
||||||
|
|
||||||
|
# 2. Gerar índices e embaralhar se necessário
|
||||||
|
indices = np.arange(len(data))
|
||||||
|
|
||||||
|
if shuffle:
|
||||||
|
np.random.shuffle(indices)
|
||||||
|
|
||||||
|
# 3. Calcular o ponto de corte (split point)
|
||||||
|
# Cálculo: N_treino = tamanho_total * proporcao_treino
|
||||||
|
n_train = int(len(data) * train_size)
|
||||||
|
|
||||||
|
# 4. Dividir os índices
|
||||||
|
train_indices = indices[:n_train]
|
||||||
|
test_indices = indices[n_train:]
|
||||||
|
|
||||||
|
# 5. Retornar os dados fatiados
|
||||||
|
return data.iloc[train_indices], data.iloc[test_indices]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_date_column_parsed(data: pd.DataFrame, params: TrainModelParams) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Parse params.date_column using the frontend date_format mapping only.
|
||||||
|
|
||||||
|
date_column must exist in ``data`` (callers validate before prepare). No broad
|
||||||
|
pandas inference or alternate timezone formats here—clients must send a supported
|
||||||
|
date_format or rely on the TrainModelParams default.
|
||||||
|
"""
|
||||||
|
if params.date_column not in data.columns:
|
||||||
|
raise ValueError(
|
||||||
|
f'date_column "{params.date_column}" not found in dataset columns: {list(data.columns)}'
|
||||||
|
)
|
||||||
|
data = data.copy()
|
||||||
|
col = data[params.date_column]
|
||||||
|
if params.date_format not in FRONTEND_DATE_FORMAT_TO_STRFTIME:
|
||||||
|
raise ValueError(
|
||||||
|
f'date_format "{params.date_format}" is not mapped to a strftime pattern '
|
||||||
|
'(must be one of the allowed frontend formats).'
|
||||||
|
)
|
||||||
|
strf = FRONTEND_DATE_FORMAT_TO_STRFTIME[params.date_format]
|
||||||
|
try:
|
||||||
|
parsed = pd.to_datetime(col, format=strf, errors='raise')
|
||||||
|
data[params.date_column] = parsed
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(
|
||||||
|
f'Failed to parse date column "{params.date_column}" with format "{params.date_format}": {e}'
|
||||||
|
) from e
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class DataManagerRepository(SientiaMonitoring):
|
||||||
|
"""
|
||||||
|
Repository for data preparation in the training pipeline.
|
||||||
|
|
||||||
|
This class encapsulates the core logic for preparing ML training data:
|
||||||
|
loading CSV bytes, applying date/index configuration, support filters, and
|
||||||
|
constructing train/test splits (or using an explicit validation dataset).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
logger (Logger): Logger instance for observability and debugging
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, logger: Logger):
|
||||||
|
"""
|
||||||
|
Initialize DataManagerRepository with logger.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logger: Logger instance for observability
|
||||||
|
"""
|
||||||
|
SientiaMonitoring.__init__(
|
||||||
|
self,
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=None,
|
||||||
|
metrics_controller=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _drop_rows_with_missing_timestamp(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
params: TrainModelParams,
|
||||||
|
metadata: dict[str, Any] | None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Remove rows where the configured date_column is missing (NaN/NaT/blank string).
|
||||||
|
|
||||||
|
Empty timestamp cells cannot be placed on a DatetimeIndex and break
|
||||||
|
downstream joins and metrics.
|
||||||
|
"""
|
||||||
|
if params.date_column not in df.columns:
|
||||||
|
return df
|
||||||
|
series = df[params.date_column]
|
||||||
|
mask = series.notna()
|
||||||
|
if series.dtype == object:
|
||||||
|
stripped = series.astype(str).str.strip()
|
||||||
|
mask &= stripped.ne('')
|
||||||
|
mask &= stripped.str.lower().ne('nan')
|
||||||
|
n_drop = int((~mask).sum())
|
||||||
|
if n_drop:
|
||||||
|
self.info(
|
||||||
|
f'Dropping {n_drop} row(s) with missing or blank timestamp column '
|
||||||
|
f'"{params.date_column}"',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
return df.loc[mask].copy()
|
||||||
|
|
||||||
|
def _coerce_non_timestamp_columns_to_numeric(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
params: TrainModelParams,
|
||||||
|
metadata: dict[str, Any] | None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Coerce all non-timestamp columns to numeric dtype.
|
||||||
|
|
||||||
|
The timestamp column defined by params.date_column is excluded from coercion.
|
||||||
|
Non-numeric values are coerced to NaN.
|
||||||
|
"""
|
||||||
|
out = df.copy()
|
||||||
|
for col in out.columns:
|
||||||
|
if col == params.date_column:
|
||||||
|
continue
|
||||||
|
original_na = int(out[col].isna().sum())
|
||||||
|
out[col] = pd.to_numeric(out[col], errors='coerce')
|
||||||
|
new_na = int(out[col].isna().sum())
|
||||||
|
introduced_na = new_na - original_na
|
||||||
|
if introduced_na > 0:
|
||||||
|
self.warning(
|
||||||
|
f'Column "{col}" had {introduced_na} non-numeric value(s) coerced to NaN',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def prepare_training_data(
|
||||||
|
self,
|
||||||
|
train_file_bytes: bytes,
|
||||||
|
validation_file_bytes: bytes | None,
|
||||||
|
params: TrainModelParams,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> TrainModelResult:
|
||||||
|
"""
|
||||||
|
Build TrainModelResult from raw CSV bytes for train (and optional validation) data.
|
||||||
|
|
||||||
|
This method orchestrates the data pipeline:
|
||||||
|
1. Load training data from in-memory bytes
|
||||||
|
2. Optionally load validation data from in-memory bytes
|
||||||
|
3. Parse and configure datetime index
|
||||||
|
4. Apply optional support filters
|
||||||
|
5. Split into train/test sets when no explicit validation dataset is provided
|
||||||
|
|
||||||
|
Args:
|
||||||
|
train_file_bytes: Raw bytes of the training CSV.
|
||||||
|
validation_file_bytes: Raw bytes of the validation CSV, or None when
|
||||||
|
validation should be derived via train/test split.
|
||||||
|
params: Training parameters (TrainModelParams).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TrainModelResult: Object containing processed data, train/test splits,
|
||||||
|
and scaler dictionary.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If transformed data is empty.
|
||||||
|
Exception: If data loading or preprocessing fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
train_df = pd.read_csv(
|
||||||
|
BytesIO(train_file_bytes),
|
||||||
|
sep=params.line_separator,
|
||||||
|
decimal=params.decimal_separator,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise ValueError(
|
||||||
|
'Failed to load training CSV data from MinIO object. '
|
||||||
|
'Check file encoding, line separator and decimal separator.'
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
train_df = self._drop_rows_with_missing_timestamp(train_df, params, metadata)
|
||||||
|
train_df = _ensure_date_column_parsed(train_df, params)
|
||||||
|
train_df = self._configure_datetime_index(train_df, params, metadata)
|
||||||
|
train_df = self._set_timezone_on_index(train_df, metadata)
|
||||||
|
train_df = self._coerce_non_timestamp_columns_to_numeric(train_df, params, metadata)
|
||||||
|
|
||||||
|
if len(train_df) <= 0:
|
||||||
|
raise ValueError('Training data view is empty after transformation')
|
||||||
|
|
||||||
|
# Explicit validation dataset path
|
||||||
|
train_data = pd.DataFrame(train_df[params.variable_columns + [params.target_variable]])
|
||||||
|
if validation_file_bytes is not None:
|
||||||
|
try:
|
||||||
|
val_df = pd.read_csv(
|
||||||
|
BytesIO(validation_file_bytes),
|
||||||
|
sep=params.line_separator,
|
||||||
|
decimal=params.decimal_separator,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise ValueError(
|
||||||
|
'Failed to load validation CSV data from MinIO object. '
|
||||||
|
'Check file encoding, line separator and decimal separator.'
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
val_df = self._drop_rows_with_missing_timestamp(val_df, params, metadata)
|
||||||
|
val_df = _ensure_date_column_parsed(val_df, params)
|
||||||
|
val_df = self._configure_datetime_index(val_df, params, metadata)
|
||||||
|
val_df = self._set_timezone_on_index(val_df, metadata)
|
||||||
|
val_df = self._coerce_non_timestamp_columns_to_numeric(val_df, params, metadata)
|
||||||
|
|
||||||
|
if len(val_df) <= 0:
|
||||||
|
raise ValueError('Validation data view is empty after transformation')
|
||||||
|
|
||||||
|
val_data = pd.DataFrame(val_df[params.variable_columns + [params.target_variable]])
|
||||||
|
else:
|
||||||
|
# Fallback path: derive validation via train/test split from a single dataset.
|
||||||
|
train_data, val_data = train_test_split(
|
||||||
|
train_data,
|
||||||
|
train_size=params.train_size / 100,
|
||||||
|
shuffle=params.shuffle,
|
||||||
|
random_state=params.random_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.info(
|
||||||
|
f'Data preprocessed and split successfully - experiment run id: {params.experiment_run_id}',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
experiment_name = f'{params.experiment_name}'
|
||||||
|
run_name = f'{experiment_name}_{datetime.now().strftime("%Y%m%d_%H%M%S")}'
|
||||||
|
|
||||||
|
return TrainModelResult(
|
||||||
|
params=params,
|
||||||
|
train_data=train_data,
|
||||||
|
val_data=val_data,
|
||||||
|
run_name=run_name,
|
||||||
|
experiment_name=experiment_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _as_series(self, pred: pd.DataFrame | pd.Series) -> pd.Series:
|
||||||
|
if isinstance(pred, pd.Series):
|
||||||
|
return pred
|
||||||
|
# If the wrapper returns a single-column DataFrame, take its first column.
|
||||||
|
if pred.shape[1] == 1:
|
||||||
|
return pred.iloc[:, 0]
|
||||||
|
raise ValueError('y_pred/y_train_pred must be a Series or single-column DataFrame')
|
||||||
|
|
||||||
|
def _extract_model_equation(self, regr: Any, params: TrainModelParams) -> dict:
|
||||||
|
"""
|
||||||
|
Extract the linear regression equation coefficients and create equation metadata.
|
||||||
|
|
||||||
|
This method extracts the coefficients and intercept from the trained model
|
||||||
|
and creates a structured dictionary containing the equation information
|
||||||
|
for serialization as JSON artifact.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
regr: Trained LinearRegressionModel object
|
||||||
|
params: Training parameters containing variable information
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Equation metadata containing:
|
||||||
|
- target_variable: Name of the target variable
|
||||||
|
- coefficients: Dictionary mapping variable names to coefficients
|
||||||
|
- intercept: Model intercept value
|
||||||
|
- equation_string: Human-readable equation string
|
||||||
|
- latex_equation: LaTeX formatted equation
|
||||||
|
"""
|
||||||
|
coefficients = regr.regr.coef_
|
||||||
|
intercept = regr.regr.intercept_
|
||||||
|
|
||||||
|
# Get feature names - for polynomial models, use poly_feature_names
|
||||||
|
model_kwargs = params.model_kwargs or {}
|
||||||
|
degree = model_kwargs.get('degree', 1)
|
||||||
|
poly_feature_names = model_kwargs.get('poly_feature_names', None)
|
||||||
|
|
||||||
|
if degree > 1 and poly_feature_names:
|
||||||
|
feature_names = poly_feature_names
|
||||||
|
else:
|
||||||
|
feature_names = params.variable_columns
|
||||||
|
|
||||||
|
# Create coefficients dictionary
|
||||||
|
coefficients_dict = {}
|
||||||
|
for i, var in enumerate(feature_names):
|
||||||
|
if i < len(coefficients):
|
||||||
|
coefficients_dict[var] = float(coefficients[i])
|
||||||
|
|
||||||
|
# Create equation string
|
||||||
|
equation_parts = [f'{coef:.6f} * {var}' for var, coef in coefficients_dict.items()]
|
||||||
|
equation_string = f'{params.target_variable} = {intercept:.6f} + ' + ' + '.join(
|
||||||
|
equation_parts
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create LaTeX equation
|
||||||
|
latex_parts = [f'{coef:.6f} \\cdot {var}' for var, coef in coefficients_dict.items()]
|
||||||
|
latex_equation = f'{params.target_variable} = {intercept:.6f} + ' + ' + '.join(latex_parts)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'target_variable': params.target_variable,
|
||||||
|
'coefficients': coefficients_dict,
|
||||||
|
'intercept': float(intercept),
|
||||||
|
'equation_string': equation_string,
|
||||||
|
'latex_equation': latex_equation,
|
||||||
|
'model_type': params.model_name,
|
||||||
|
'degree': degree,
|
||||||
|
'interaction_only': model_kwargs.get('interaction_only', False),
|
||||||
|
'original_features': feature_names,
|
||||||
|
}
|
||||||
|
|
||||||
|
def compute_regression_metrics(
|
||||||
|
self,
|
||||||
|
tmr: TrainModelResult,
|
||||||
|
wrapper: SientiaModel,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> TrainModelResult:
|
||||||
|
"""
|
||||||
|
Compute regression metrics for training results.
|
||||||
|
|
||||||
|
This helper mirrors the previous TrainingRepository.after_train_calculation
|
||||||
|
behavior, assuming that predictions (y_pred/y_train_pred) are already on the
|
||||||
|
correct scale for metric calculation (any scaling is handled inside the
|
||||||
|
model wrapper).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tmr: Training result containing:
|
||||||
|
- train_data/val_data DataFrames with a target column
|
||||||
|
- y_train_pred/y_pred populated (model predictions for train/val)
|
||||||
|
wrapper: Trained model wrapper (used for linear equation extraction).
|
||||||
|
metadata: Optional workflow metadata for debug logging.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
TrainModelResult: Same object with mse_val, mae_val and r2_val set.
|
||||||
|
"""
|
||||||
|
if tmr.y_pred is None:
|
||||||
|
raise ValueError('y_pred must be set before computing regression metrics')
|
||||||
|
|
||||||
|
params = tmr.params
|
||||||
|
target = params.target_variable
|
||||||
|
|
||||||
|
# True values are expected to come from val_data.
|
||||||
|
y_true_val = tmr.val_data[target]
|
||||||
|
|
||||||
|
y_pred_val = self._as_series(tmr.y_pred).sort_index()
|
||||||
|
y_true_val = y_true_val.sort_index()
|
||||||
|
|
||||||
|
# Align by index to avoid metric calculation errors if ordering differs.
|
||||||
|
common_index = y_true_val.index.intersection(y_pred_val.index)
|
||||||
|
head = min(5, len(y_true_val), len(y_pred_val))
|
||||||
|
self.debug(
|
||||||
|
'compute_regression_metrics index alignment: '
|
||||||
|
f'val_n={len(y_true_val)} pred_n={len(y_pred_val)} common_n={len(common_index)}; '
|
||||||
|
f'val_index_dtype={y_true_val.index.dtype} '
|
||||||
|
f'pred_index_dtype={y_pred_val.index.dtype}; '
|
||||||
|
f'val_index_sample={list(y_true_val.index[:head])} '
|
||||||
|
f'pred_index_sample={list(y_pred_val.index[:head])}',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(common_index) == 0:
|
||||||
|
raise ValueError(
|
||||||
|
'No overlapping indices between val_data and y_pred. '
|
||||||
|
f'val_n={len(y_true_val)} pred_n={len(y_pred_val)} '
|
||||||
|
f'val_index_sample={list(y_true_val.index[:head])} '
|
||||||
|
f'pred_index_sample={list(y_pred_val.index[:head])}'
|
||||||
|
)
|
||||||
|
|
||||||
|
y_true_val = y_true_val.loc[common_index]
|
||||||
|
y_pred_val = y_pred_val.loc[common_index]
|
||||||
|
|
||||||
|
# Metrics helpers already round to 2 decimals.
|
||||||
|
tmr.mse_val = mse(y_true_val, y_pred_val)
|
||||||
|
tmr.mae_val = mae(y_true_val, y_pred_val)
|
||||||
|
tmr.r2_val = r2(y_true_val, y_pred_val)
|
||||||
|
|
||||||
|
if params.model_type == 'linear_regression':
|
||||||
|
inner = getattr(wrapper, 'model', None)
|
||||||
|
regr = getattr(inner, 'regr', None) if inner is not None else None
|
||||||
|
if regr is not None and hasattr(regr, 'coef_') and hasattr(regr, 'intercept_'):
|
||||||
|
tmr.equation = self._extract_model_equation(inner, params)
|
||||||
|
|
||||||
|
return tmr
|
||||||
|
|
||||||
|
def _configure_datetime_index(
|
||||||
|
self,
|
||||||
|
data: pd.DataFrame | None,
|
||||||
|
params: TrainModelParams,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Configure datetime index for the DataFrame.
|
||||||
|
|
||||||
|
Guards against None to avoid 'NoneType' object has no attribute 'index' downstream.
|
||||||
|
Uses only params.date_column and assumes it was already parsed exactly once by
|
||||||
|
_ensure_date_column_parsed.
|
||||||
|
Args:
|
||||||
|
data: The DataFrame to configure the datetime index for.
|
||||||
|
params: The training parameters.
|
||||||
|
metadata: The metadata for the training run.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The DataFrame with the datetime index configured.
|
||||||
|
"""
|
||||||
|
if data is None:
|
||||||
|
raise ValueError(
|
||||||
|
'Data is None after load_data. '
|
||||||
|
'Check file format, line separator and decimal separator.'
|
||||||
|
)
|
||||||
|
|
||||||
|
if params.date_column not in data.columns:
|
||||||
|
raise ValueError(
|
||||||
|
f'date_column "{params.date_column}" not found in dataset columns: {list(data.columns)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
if not pd.api.types.is_datetime64_any_dtype(data[params.date_column]):
|
||||||
|
raise ValueError(
|
||||||
|
f'date_column "{params.date_column}" must be datetime before index configuration'
|
||||||
|
)
|
||||||
|
|
||||||
|
data = data.set_index(params.date_column)
|
||||||
|
data = data.sort_index()
|
||||||
|
self.info(f'Configured datetime index from column: {params.date_column}', metadata)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _set_timezone_on_index(
|
||||||
|
self, data: pd.DataFrame, metadata: dict[str, Any] | None = None
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Check if the index has a timezone and if not, set it to UTC timezone.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: The DataFrame to set the timezone on.
|
||||||
|
metadata: The metadata for the training run.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The DataFrame with the timezone set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if isinstance(data.index, pd.DatetimeIndex):
|
||||||
|
if data.index.tz is None:
|
||||||
|
data.index = data.index.tz_localize('UTC')
|
||||||
|
else:
|
||||||
|
data.index = data.index.tz_convert('UTC')
|
||||||
|
else:
|
||||||
|
raise ValueError('Index is not a DatetimeIndex')
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _get_reports_directory(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the absolute path to the reports directory.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Absolute path to the runtime reports root.
|
||||||
|
"""
|
||||||
|
return REPORTS_ROOT
|
||||||
|
|
||||||
|
def _create_run_directory(
|
||||||
|
self, base_path: str, run_name: str, metadata: dict[str, Any] | None = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Creates a directory inside the 'reports' folder with the run name and a timestamp.
|
||||||
|
|
||||||
|
Uses microsecond precision in timestamp to minimize collision probability
|
||||||
|
in high-concurrency scenarios.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_path (str): The path to the 'reports' folder.
|
||||||
|
run_name (str): The name of the run.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The path to the created directory.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PermissionError: If there are insufficient permissions to create the directory.
|
||||||
|
OSError: If directory creation fails for any other reason.
|
||||||
|
"""
|
||||||
|
# Use microsecond precision to reduce collision probability
|
||||||
|
run_dir = path.join(base_path, 'temp', f'{run_name}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
makedirs(run_dir, exist_ok=True)
|
||||||
|
return run_dir
|
||||||
|
except PermissionError as e:
|
||||||
|
error_msg = f'Permission denied when creating directory: {run_dir}'
|
||||||
|
self.error(error_msg, metadata)
|
||||||
|
raise PermissionError(error_msg) from e
|
||||||
|
except OSError as e:
|
||||||
|
error_msg = f'Failed to create directory {run_dir}: {str(e)}'
|
||||||
|
self.error(error_msg, metadata)
|
||||||
|
raise OSError(error_msg) from e
|
||||||
|
|
||||||
|
def generate_report(
|
||||||
|
self, data: TrainModelResult, metadata: dict[str, Any] | None = None
|
||||||
|
) -> TrainModelResult:
|
||||||
|
"""
|
||||||
|
Generates a comprehensive report summarizing data quality, data drift, and regression analysis.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reference_data (pd.DataFrame): The training dataset with predictions added.
|
||||||
|
current_data (pd.DataFrame): The testing dataset with predictions added.
|
||||||
|
data: The training model result containing the datasets, model, and parameters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated result object with paths to the generated report and data files.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If data conversion to float64 fails or DataFrames are invalid.
|
||||||
|
PermissionError: If there are insufficient permissions to write files.
|
||||||
|
OSError: If file writing fails for any other reason.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if data.run_name is None:
|
||||||
|
raise ValueError('run_name is not set, cannot generate report')
|
||||||
|
|
||||||
|
if data.y_train_pred is None or data.y_pred is None:
|
||||||
|
raise ValueError('y_train_pred or y_pred is not set, cannot generate report')
|
||||||
|
|
||||||
|
y_train_pred = data.y_train_pred.rename(columns={data.params.target_variable: 'prediction'})
|
||||||
|
y_val_pred = data.y_pred.rename(columns={data.params.target_variable: 'prediction'})
|
||||||
|
|
||||||
|
# Join the predictions to the data
|
||||||
|
reference_data = y_train_pred[['prediction']].join(data.train_data, how='inner')
|
||||||
|
reference_data_float = reference_data.astype(np.float64)
|
||||||
|
|
||||||
|
current_data = y_val_pred[['prediction']].join(data.val_data, how='inner')
|
||||||
|
current_data_float = current_data.astype(np.float64)
|
||||||
|
|
||||||
|
# Evidently's ConflictTargetMetric expects a literal `target` column name.
|
||||||
|
# Keep the original target column and provide this alias for report metrics.
|
||||||
|
target_col = data.params.target_variable
|
||||||
|
|
||||||
|
reference_data_float['target'] = reference_data_float[target_col]
|
||||||
|
current_data_float['target'] = current_data_float[target_col]
|
||||||
|
|
||||||
|
# Initialize report generator
|
||||||
|
base_path = self._get_reports_directory()
|
||||||
|
data.run_dir = self._create_run_directory(base_path, data.run_name)
|
||||||
|
|
||||||
|
# Template path is the code path of the model_manager package
|
||||||
|
template_path = path.join(PROJECT_BASE_PATH, 'reports')
|
||||||
|
|
||||||
|
report = Reports(
|
||||||
|
reference_data=reference_data_float,
|
||||||
|
current_data=current_data_float,
|
||||||
|
base_path=data.run_dir,
|
||||||
|
template_path=template_path,
|
||||||
|
target_name=data.params.target_variable,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate report sections
|
||||||
|
feature_and_target_cols = data.params.variable_columns + [target_col]
|
||||||
|
report.add_data_quality_section(columns=feature_and_target_cols)
|
||||||
|
report.add_data_drift_section(columns=feature_and_target_cols)
|
||||||
|
report.add_regression_section()
|
||||||
|
|
||||||
|
# Save HTML report
|
||||||
|
data.report_path = path.join(data.run_dir, 'report.html')
|
||||||
|
report.save_all_sections_html(data.report_path)
|
||||||
|
|
||||||
|
# Save training / validation CSVs using the same frames as the report (includes
|
||||||
|
# literal `target` alias for Evidently, plus predictions and float-cast features).
|
||||||
|
data.train_data_path = path.join(data.run_dir, 'train_data.csv')
|
||||||
|
reference_data_float.to_csv(data.train_data_path, index=False)
|
||||||
|
|
||||||
|
# Save test data CSV
|
||||||
|
data.test_data_path = path.join(data.run_dir, 'test_data.csv')
|
||||||
|
current_data_float.to_csv(data.test_data_path, index=False)
|
||||||
|
|
||||||
|
# Save equation as JSON
|
||||||
|
if data.equation is not None and data.params.model_type == 'linear_regression':
|
||||||
|
data.equation_path = path.join(data.run_dir, 'model_equation.json')
|
||||||
|
with open(data.equation_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data.equation, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def cleanup_run_directory(self, run_dir: str, metadata: dict[str, Any] | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Clean up temporary run directory after model training.
|
||||||
|
|
||||||
|
This activity deletes the temporary directory created during model training
|
||||||
|
and artifact generation. It implements idempotent cleanup to handle cases
|
||||||
|
where the directory may have already been deleted.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
run_dir (str): Path to the run directory to delete
|
||||||
|
"""
|
||||||
|
if not run_dir:
|
||||||
|
self.info('No run directory specified, skipping cleanup')
|
||||||
|
return
|
||||||
|
|
||||||
|
if path.exists(run_dir):
|
||||||
|
rmtree(run_dir)
|
||||||
|
self.info(f'Run directory deleted successfully: {run_dir}')
|
||||||
|
else:
|
||||||
|
self.info(f'Run directory already deleted: {run_dir}')
|
||||||
0
model_manager/worker/__init__.py
Normal file
0
model_manager/worker/__init__.py
Normal file
119
model_manager/worker/prepare_worker.py
Normal file
119
model_manager/worker/prepare_worker.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sientia_do.observability.logger import Logger
|
||||||
|
from temporalio.client import Client
|
||||||
|
from temporalio.worker import PollerBehaviorAutoscaling, Worker
|
||||||
|
|
||||||
|
# Worker configuration parameters with default values.
|
||||||
|
parameters = [
|
||||||
|
('MAX_CONCURRENT_WORKFLOW_TASKS', '200'),
|
||||||
|
('MAX_CONCURRENT_ACTIVITIES', '200'),
|
||||||
|
('MAX_CONCURRENT_LOCAL_ACTIVITIES', '200'),
|
||||||
|
('MAX_CACHED_WORKFLOWS', '200'),
|
||||||
|
('WORKFLOW_POLLER_BEHAVIOUR_MINIMUM', '10'),
|
||||||
|
('WORKFLOW_POLLER_BEHAVIOUR_INITIAL', '100'),
|
||||||
|
('WORKFLOW_POLLER_BEHAVIOUR_MAXIMUM', '200'),
|
||||||
|
('ACTIVITY_POLLER_BEHAVIOUR_MINIMUM', '10'),
|
||||||
|
('ACTIVITY_POLLER_BEHAVIOUR_INITIAL', '100'),
|
||||||
|
('ACTIVITY_POLLER_BEHAVIOUR_MAXIMUM', '200'),
|
||||||
|
('ACTIVITY_EXECUTOR_MAX_WORKERS', '200'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def camel_to_snake(text: str) -> str:
|
||||||
|
"""
|
||||||
|
Convert a CamelCase or camelCase string into snake_case.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- text: str, original string in CamelCase or camelCase format
|
||||||
|
|
||||||
|
Return:
|
||||||
|
str: converted string in snake_case format
|
||||||
|
"""
|
||||||
|
text = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', text)
|
||||||
|
text = re.sub('([a-z0-9])([A-Z])', r'\1_\2', text)
|
||||||
|
return text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def build_queue_name(workflow_name: str, runtime: str | None = None) -> str:
|
||||||
|
"""
|
||||||
|
Build Temporal queue name from workflow name and runtime.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- workflow_name: str, workflow class name in CamelCase format
|
||||||
|
- runtime: str | None, runtime suffix for environment-specific queues
|
||||||
|
|
||||||
|
Return:
|
||||||
|
str: queue name in the format <workflow>-<runtime>-queue or <workflow>-queue
|
||||||
|
"""
|
||||||
|
snake_workflow_name = camel_to_snake(workflow_name)
|
||||||
|
if runtime:
|
||||||
|
return f'{snake_workflow_name}-{runtime}-queue'
|
||||||
|
return f'{snake_workflow_name}-queue'
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_worker(
|
||||||
|
main_workflow: type,
|
||||||
|
other_workflows: Sequence[type],
|
||||||
|
activities: Sequence[Any],
|
||||||
|
temporal_client: Client,
|
||||||
|
logger: Logger,
|
||||||
|
runtime: str | None = None,
|
||||||
|
) -> Worker:
|
||||||
|
"""
|
||||||
|
Build and configure a Temporal worker for the given workflow and activities.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- main_workflow: type, main workflow class used as worker entry point
|
||||||
|
- other_workflows: Sequence[type], additional workflows in the same worker
|
||||||
|
- activities: Sequence[Any], activity callables registered in this worker
|
||||||
|
- temporal_client: Client, Temporal client used by the worker
|
||||||
|
- logger: Logger, logger instance used during worker preparation
|
||||||
|
- runtime: str | None, runtime suffix appended to queue name when present
|
||||||
|
|
||||||
|
Return:
|
||||||
|
Worker: fully configured Temporal worker instance ready to run
|
||||||
|
"""
|
||||||
|
main_workflow_name = main_workflow.__name__.upper()
|
||||||
|
queue_name = build_queue_name(main_workflow.__name__, runtime)
|
||||||
|
|
||||||
|
local_workflow_parameters: dict[str, int] = {}
|
||||||
|
for parameter_name, default_value in parameters:
|
||||||
|
local_workflow_parameters[parameter_name] = int(
|
||||||
|
os.getenv(f'{main_workflow_name}_{parameter_name}', default_value)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f'Preparing worker for {main_workflow_name} with queue {queue_name}')
|
||||||
|
|
||||||
|
activity_executor = ThreadPoolExecutor(
|
||||||
|
max_workers=local_workflow_parameters['ACTIVITY_EXECUTOR_MAX_WORKERS'],
|
||||||
|
thread_name_prefix=f'{queue_name}-activity',
|
||||||
|
)
|
||||||
|
|
||||||
|
return Worker(
|
||||||
|
temporal_client,
|
||||||
|
task_queue=queue_name,
|
||||||
|
workflows=[main_workflow, *other_workflows],
|
||||||
|
activities=[*activities],
|
||||||
|
activity_executor=activity_executor,
|
||||||
|
max_concurrent_workflow_tasks=local_workflow_parameters['MAX_CONCURRENT_WORKFLOW_TASKS'],
|
||||||
|
max_concurrent_activities=local_workflow_parameters['MAX_CONCURRENT_ACTIVITIES'],
|
||||||
|
max_concurrent_local_activities=local_workflow_parameters[
|
||||||
|
'MAX_CONCURRENT_LOCAL_ACTIVITIES'
|
||||||
|
],
|
||||||
|
max_cached_workflows=local_workflow_parameters['MAX_CACHED_WORKFLOWS'],
|
||||||
|
workflow_task_poller_behavior=PollerBehaviorAutoscaling(
|
||||||
|
minimum=local_workflow_parameters['WORKFLOW_POLLER_BEHAVIOUR_MINIMUM'],
|
||||||
|
initial=local_workflow_parameters['WORKFLOW_POLLER_BEHAVIOUR_INITIAL'],
|
||||||
|
maximum=local_workflow_parameters['WORKFLOW_POLLER_BEHAVIOUR_MAXIMUM'],
|
||||||
|
),
|
||||||
|
activity_task_poller_behavior=PollerBehaviorAutoscaling(
|
||||||
|
minimum=local_workflow_parameters['ACTIVITY_POLLER_BEHAVIOUR_MINIMUM'],
|
||||||
|
initial=local_workflow_parameters['ACTIVITY_POLLER_BEHAVIOUR_INITIAL'],
|
||||||
|
maximum=local_workflow_parameters['ACTIVITY_POLLER_BEHAVIOUR_MAXIMUM'],
|
||||||
|
),
|
||||||
|
)
|
||||||
259
model_manager/worker/worker.py
Normal file
259
model_manager/worker/worker.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
"""Model Manager Worker Module
|
||||||
|
|
||||||
|
This module provides the main worker implementation for the Sientia DataOps Model Manager system.
|
||||||
|
It orchestrates Temporal workers, manages task queues, and handles the lifecycle of
|
||||||
|
model training and cleanup workflows.
|
||||||
|
|
||||||
|
The worker supports two task queues:
|
||||||
|
- train_model-<runtime>-queue: For ML model training workflows
|
||||||
|
- cleanup_files-<runtime>-queue: For file cleanup workflows
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Automatic scaling with PollerBehaviorAutoscaling
|
||||||
|
- Prometheus metrics integration
|
||||||
|
- Comprehensive error handling and logging
|
||||||
|
- Graceful shutdown with cleanup
|
||||||
|
- ML model training pipeline orchestration
|
||||||
|
- Automated cleanup schedule management
|
||||||
|
|
||||||
|
Environment Variables:
|
||||||
|
- TEMPORAL_HOST: Temporal server address (default: localhost:7233)
|
||||||
|
- TEMPORAL_NAMESPACE: Temporal namespace (default: model-manager)
|
||||||
|
- TEMPORAL_USE_TLS: Enable TLS for Temporal connection (default: false)
|
||||||
|
- RUNTIME: Runtime identifier used in queue naming (default: single)
|
||||||
|
- POD_ID: Kubernetes pod identifier for metrics
|
||||||
|
- HTTP_METRICS_PORT: Prometheus metrics server port (default: 9090)
|
||||||
|
- HTTP_SDK_METRICS_PORT: Temporal SDK metrics port (default: 9091)
|
||||||
|
- PROJECT_NAME: Project name for notifications (default: model-manager)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from temporalio import client, workflow
|
||||||
|
from temporalio.runtime import PrometheusConfig, Runtime, TelemetryConfig
|
||||||
|
|
||||||
|
with workflow.unsafe.imports_passed_through():
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from prometheus_client import start_http_server
|
||||||
|
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||||
|
from sientia_do.observability.logger import Logger as SientiaLogger
|
||||||
|
from sientia_do.observability.metrics_controller import MetricsController
|
||||||
|
from sientia_model.model_repository.plugin_store import PluginStore
|
||||||
|
|
||||||
|
from model_manager import metrics
|
||||||
|
from model_manager.activities.activities import Activities
|
||||||
|
from model_manager.runtime_paths import ensure_runtime_directories
|
||||||
|
from model_manager.schedules.cleanup_schedule import create_cleanup_schedule
|
||||||
|
from model_manager.utils.connectors_config import (
|
||||||
|
build_minio_config,
|
||||||
|
build_mlflow_config,
|
||||||
|
build_mongodb_config,
|
||||||
|
build_plugin_store_config,
|
||||||
|
build_postgres_config,
|
||||||
|
)
|
||||||
|
from model_manager.utils.logger_helper import get_logger
|
||||||
|
from model_manager.worker.prepare_worker import prepare_worker
|
||||||
|
from model_manager.workflows.cleanup_files import CleanupFiles
|
||||||
|
from model_manager.workflows.train_model import TrainModel
|
||||||
|
|
||||||
|
POD_ID = os.getenv('POD_ID')
|
||||||
|
RUNTIME = os.getenv('RUNTIME')
|
||||||
|
SDK_METRICS_PORT = int(os.getenv('HTTP_SDK_METRICS_PORT', '9091'))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_runtime(runtime: str | None) -> str:
|
||||||
|
"""
|
||||||
|
Resolve runtime using fallback when missing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- runtime: str | None, runtime value from environment
|
||||||
|
|
||||||
|
Return:
|
||||||
|
str: normalized runtime value
|
||||||
|
"""
|
||||||
|
normalized_runtime = runtime.strip() if runtime else ''
|
||||||
|
return normalized_runtime or 'single'
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""
|
||||||
|
Main entry point for the Model Manager worker application.
|
||||||
|
|
||||||
|
This function initializes and starts all components of the worker:
|
||||||
|
1. Sets up logging and metadata
|
||||||
|
2. Starts Prometheus metrics server
|
||||||
|
3. Initializes notification handler
|
||||||
|
4. Creates and configures activities
|
||||||
|
5. Starts Temporal client and workers
|
||||||
|
6. Manages worker lifecycle and graceful shutdown
|
||||||
|
|
||||||
|
The function runs indefinitely until interrupted or an error occurs.
|
||||||
|
On error, it performs cleanup and exits with a non-zero status code.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: Any unhandled exception during worker execution
|
||||||
|
SystemExit: On graceful shutdown or error conditions
|
||||||
|
"""
|
||||||
|
|
||||||
|
runtime = _get_runtime(RUNTIME)
|
||||||
|
|
||||||
|
ensure_runtime_directories()
|
||||||
|
|
||||||
|
host = os.getenv('TEMPORAL_HOST', 'localhost:7233')
|
||||||
|
use_tls = os.getenv('TEMPORAL_USE_TLS', 'false').lower() == 'true'
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
'pod_id': POD_ID,
|
||||||
|
'runtime': runtime,
|
||||||
|
}
|
||||||
|
|
||||||
|
start_prometheus_server(logger, metadata)
|
||||||
|
mongo_config = build_mongodb_config()
|
||||||
|
|
||||||
|
notification_handler = NotificationHandler(
|
||||||
|
connection_string=mongo_config['connection_string'],
|
||||||
|
database=mongo_config['database_name'],
|
||||||
|
logger=logger,
|
||||||
|
project_name=os.getenv('PROJECT_NAME', 'model-manager'),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.custom_info(f'MongoDB client initialized at {mongo_config["uri"]}', metadata)
|
||||||
|
|
||||||
|
logger.custom_info('Initializing metrics controller', metadata)
|
||||||
|
|
||||||
|
metrics_controller = MetricsController(logger=logger)
|
||||||
|
|
||||||
|
logger.custom_info(f'Installing runtime {runtime}', metadata)
|
||||||
|
|
||||||
|
plugin_store_parameters = build_plugin_store_config()
|
||||||
|
plugin_store = PluginStore(
|
||||||
|
base_url=plugin_store_parameters['base_url'],
|
||||||
|
owner=plugin_store_parameters['owner'],
|
||||||
|
repo=plugin_store_parameters['repo'],
|
||||||
|
username=plugin_store_parameters['username'],
|
||||||
|
password=plugin_store_parameters['password'],
|
||||||
|
branch=plugin_store_parameters['branch'],
|
||||||
|
cache_ttl_seconds=plugin_store_parameters['cache_ttl_seconds'],
|
||||||
|
pypi_index_url=plugin_store_parameters['pypi_index_url'],
|
||||||
|
pypi_username=plugin_store_parameters['pypi_username'],
|
||||||
|
pypi_password=plugin_store_parameters['pypi_password'],
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
await plugin_store.install_runtime(runtime_name=runtime)
|
||||||
|
|
||||||
|
activities = Activities(
|
||||||
|
postgres_config=build_postgres_config(),
|
||||||
|
mlflow_config=build_mlflow_config(),
|
||||||
|
minio_config=build_minio_config(),
|
||||||
|
plugin_store=plugin_store,
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
new_runtime = Runtime(
|
||||||
|
telemetry=TelemetryConfig(
|
||||||
|
metrics=PrometheusConfig(bind_address=f'0.0.0.0:{SDK_METRICS_PORT}')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.custom_info(f'SDK metrics server initialized on port {SDK_METRICS_PORT}', metadata)
|
||||||
|
|
||||||
|
namespace = os.getenv('TEMPORAL_NAMESPACE', 'model-manager')
|
||||||
|
temporal_client = await client.Client.connect(
|
||||||
|
target_host=host,
|
||||||
|
namespace=namespace,
|
||||||
|
runtime=new_runtime,
|
||||||
|
tls=use_tls,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.custom_info(f'Temporal client initialized at {host}/{namespace}', metadata)
|
||||||
|
|
||||||
|
# Create cleanup schedule (idempotent - only creates if doesn't exist)
|
||||||
|
try:
|
||||||
|
await create_cleanup_schedule(temporal_client, logger, metadata)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.custom_error(f'Failed to configure cleanup schedule: {e}', metadata)
|
||||||
|
# Don't fail the worker startup if schedule creation fails
|
||||||
|
# The schedule can be created manually if needed
|
||||||
|
|
||||||
|
workers = [
|
||||||
|
prepare_worker(
|
||||||
|
main_workflow=TrainModel,
|
||||||
|
other_workflows=[],
|
||||||
|
activities=[
|
||||||
|
activities.update_experiment_run,
|
||||||
|
activities.load_model_metadata,
|
||||||
|
activities.validate_train_params,
|
||||||
|
activities.train_model,
|
||||||
|
activities.cleanup_resources,
|
||||||
|
],
|
||||||
|
temporal_client=temporal_client,
|
||||||
|
logger=logger,
|
||||||
|
runtime=runtime,
|
||||||
|
),
|
||||||
|
prepare_worker(
|
||||||
|
main_workflow=CleanupFiles,
|
||||||
|
other_workflows=[],
|
||||||
|
activities=[
|
||||||
|
activities.cleanup_temp_directories,
|
||||||
|
],
|
||||||
|
temporal_client=temporal_client,
|
||||||
|
logger=logger,
|
||||||
|
runtime=runtime,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
handlers = [w.run() for w in workers]
|
||||||
|
|
||||||
|
logger.custom_info('Model manager workers initialized', metadata)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# This will run the workers and wait for them to complete.
|
||||||
|
# If an exception occurs in any of the worker handlers, it will be propagated here.
|
||||||
|
await asyncio.gather(*handlers)
|
||||||
|
except BaseException as e: # noqa: BLE001
|
||||||
|
logger.custom_error(f'An unhandled exception occurred: {e}', metadata)
|
||||||
|
finally:
|
||||||
|
notification_handler.shutdown()
|
||||||
|
logger.custom_info('MongoDB client closed', metadata)
|
||||||
|
activities.shutdown()
|
||||||
|
# Exit with a non-zero status code to indicate failure to Kubernetes
|
||||||
|
metrics.APP_UP.labels(pod_id=POD_ID).set(0) # Mark app as DOWN
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def start_prometheus_server(logger: SientiaLogger, metadata: dict[str, str | None]):
|
||||||
|
"""
|
||||||
|
Starts the Prometheus metrics server for monitoring and observability.
|
||||||
|
|
||||||
|
This function initializes the Prometheus HTTP server on the configured port
|
||||||
|
and sets the application health metric to indicate the service is running.
|
||||||
|
|
||||||
|
The server exposes metrics that can be scraped by Prometheus for monitoring
|
||||||
|
the health and performance of the Model Manager worker.
|
||||||
|
|
||||||
|
Environment Variables:
|
||||||
|
HTTP_METRICS_PORT: Port for the metrics server (default: 9090)
|
||||||
|
POD_ID: Pod identifier for metrics labeling
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: If the metrics server fails to start
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
port = int(os.getenv('HTTP_METRICS_PORT', 9090))
|
||||||
|
start_http_server(port)
|
||||||
|
logger.custom_info(f'Prometheus server initialized on port {port}.', metadata)
|
||||||
|
metrics.APP_UP.labels(pod_id=POD_ID).set(1) # Mark app as UP
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.custom_critical(f'Failed to start Prometheus server: {e}', metadata)
|
||||||
|
os._exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
asyncio.run(main())
|
||||||
0
model_manager/workflows/__init__.py
Normal file
0
model_manager/workflows/__init__.py
Normal file
64
model_manager/workflows/cleanup_files.py
Normal file
64
model_manager/workflows/cleanup_files.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""
|
||||||
|
Cleanup workflow for removing local filesystem.
|
||||||
|
|
||||||
|
This module provides a Temporal cron workflow that runs daily to clean up
|
||||||
|
temporary files and directories older than the configured retention period.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from temporalio import workflow
|
||||||
|
|
||||||
|
with workflow.unsafe.imports_passed_through():
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from model_manager.activities.activities import Activities
|
||||||
|
from model_manager.runtime_paths import REPORTS_TEMP_DIR
|
||||||
|
from model_manager.workflows.train_model import no_retry_policy
|
||||||
|
|
||||||
|
TIMEOUT_CLEANUP_LOCAL = int(os.getenv('TIMEOUT_CLEANUP_LOCAL', '120'))
|
||||||
|
POD_ID = os.getenv('POD_ID')
|
||||||
|
|
||||||
|
|
||||||
|
@workflow.defn(name='cleanup_files')
|
||||||
|
class CleanupFiles:
|
||||||
|
"""
|
||||||
|
Cleanup workflow for removing stale files.
|
||||||
|
|
||||||
|
This workflow cleans up:
|
||||||
|
- Local temporary directories with timestamp suffixes
|
||||||
|
|
||||||
|
The workflow is designed to be simple and robust, with error handling
|
||||||
|
delegated to the individual activities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@workflow.run
|
||||||
|
async def run(self, input_data: dict[str, Any] | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Execute the cleanup workflow.
|
||||||
|
|
||||||
|
This method orchestrates the cleanup of local directories
|
||||||
|
in sequence. No exception handling is needed as activities handle their
|
||||||
|
own errors and notifications.
|
||||||
|
"""
|
||||||
|
payload = input_data or {}
|
||||||
|
temp_path = payload.get('temp_path') or REPORTS_TEMP_DIR
|
||||||
|
|
||||||
|
# Metadata for tracking
|
||||||
|
metadata = {
|
||||||
|
'metadata': {
|
||||||
|
'pod_id': POD_ID,
|
||||||
|
'workflow_name': 'cleanup_files',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute local directory cleanup
|
||||||
|
await workflow.execute_activity_method(
|
||||||
|
Activities.cleanup_temp_directories,
|
||||||
|
{
|
||||||
|
**metadata,
|
||||||
|
'temp_path': temp_path,
|
||||||
|
},
|
||||||
|
retry_policy=no_retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=TIMEOUT_CLEANUP_LOCAL),
|
||||||
|
)
|
||||||
400
model_manager/workflows/train_model.py
Normal file
400
model_manager/workflows/train_model.py
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
"""
|
||||||
|
Train Model Workflow for ML model training pipeline.
|
||||||
|
|
||||||
|
This workflow orchestrates the complete ML model training process, including:
|
||||||
|
- Parameter validation and conversion
|
||||||
|
- Data download from MinIO
|
||||||
|
- Model training
|
||||||
|
- Model saving to MLFlow
|
||||||
|
- Experiment tracking and status updates
|
||||||
|
"""
|
||||||
|
|
||||||
|
from temporalio import workflow
|
||||||
|
|
||||||
|
with workflow.unsafe.imports_passed_through():
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from temporalio.common import RetryPolicy
|
||||||
|
from temporalio.exceptions import ApplicationError
|
||||||
|
|
||||||
|
from model_manager.activities.activities import Activities
|
||||||
|
from model_manager.activities.experiment_tracking import UpdateType
|
||||||
|
from model_manager.utils.models.experiment_status import ExperimentStatus
|
||||||
|
|
||||||
|
# Activity timeouts (seconds). Tune per environment (large uploads, long training).
|
||||||
|
# Training uses no_retry_policy: extend TIMEOUT_TRAIN_MODEL instead of adding retries
|
||||||
|
# to avoid duplicate MLflow side effects. Cleanup/delete uses network_retry_policy.
|
||||||
|
TIMEOUT_VALIDATE_PARAMS = int(os.getenv('TIMEOUT_VALIDATE_PARAMS', '30'))
|
||||||
|
TIMEOUT_TRAIN_MODEL = int(os.getenv('TIMEOUT_TRAIN_MODEL', '2700'))
|
||||||
|
TIMEOUT_DELETE_FILE = int(os.getenv('TIMEOUT_DELETE_FILE', '120'))
|
||||||
|
TIMEOUT_UPDATE_DATABASE = int(os.getenv('TIMEOUT_UPDATE_DATABASE', '30'))
|
||||||
|
|
||||||
|
# Retry Policies - Granular strategies for different operation types
|
||||||
|
# Fast retry for transient network errors (MinIO operations)
|
||||||
|
network_retry_policy = RetryPolicy(
|
||||||
|
initial_interval=timedelta(seconds=1),
|
||||||
|
maximum_interval=timedelta(seconds=10),
|
||||||
|
backoff_coefficient=2.0,
|
||||||
|
maximum_attempts=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
# No retry for training - data errors are permanent
|
||||||
|
no_retry_policy = RetryPolicy(
|
||||||
|
maximum_attempts=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Database retry with exponential backoff
|
||||||
|
database_retry_policy = RetryPolicy(
|
||||||
|
initial_interval=timedelta(seconds=2),
|
||||||
|
maximum_interval=timedelta(seconds=20),
|
||||||
|
backoff_coefficient=2.0,
|
||||||
|
maximum_attempts=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@workflow.defn(name='train_model')
|
||||||
|
class TrainModel:
|
||||||
|
"""
|
||||||
|
Complete ML model training workflow.
|
||||||
|
|
||||||
|
This workflow implements the full training pipeline from parameter validation
|
||||||
|
through model training and saving to MLFlow. It provides comprehensive error
|
||||||
|
handling with database status updates at each stage.
|
||||||
|
|
||||||
|
The workflow ensures:
|
||||||
|
- Proper parameter validation before training starts
|
||||||
|
- Status tracking in database for monitoring
|
||||||
|
- Error handling with detailed error messages
|
||||||
|
- Cleanup and proper resource management
|
||||||
|
"""
|
||||||
|
|
||||||
|
@workflow.run
|
||||||
|
async def run(self, input_data: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
Execute the complete model training workflow.
|
||||||
|
|
||||||
|
This method orchestrates all steps of the training pipeline:
|
||||||
|
1. Validate and convert training parameters
|
||||||
|
2. Download training data from MinIO
|
||||||
|
3. Train the model
|
||||||
|
4. Save model to MLFlow
|
||||||
|
5. Update experiment tracking status
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_data: Complete configuration for the training workflow
|
||||||
|
All TrainModelParams fields at the same level:
|
||||||
|
- experiment_run_id (int): Unique identifier for the experiment run (REQUIRED)
|
||||||
|
- target_variable (str): Target variable to predict
|
||||||
|
- variable_columns (list[str]): Feature columns
|
||||||
|
- train_size (int): Training data percentage
|
||||||
|
- ... (all other TrainModelParams fields)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If experiment_run_id is missing or invalid
|
||||||
|
"""
|
||||||
|
workflow.logger.info(f'Starting train_model workflow for {input_data}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
experiment_run_id = self._validate_experiment_run_id(input_data)
|
||||||
|
except ValueError as exc:
|
||||||
|
# Prevent workflow-task retries on deterministic input contract violations.
|
||||||
|
raise ApplicationError(str(exc), non_retryable=True) from exc
|
||||||
|
input_data = {**input_data, 'experiment_run_id': experiment_run_id}
|
||||||
|
|
||||||
|
model_name = input_data.get('model_name')
|
||||||
|
model_id = input_data.get('model_id')
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
'metadata': {
|
||||||
|
'experiment_run_id': experiment_run_id,
|
||||||
|
'workflow_name': 'train_model',
|
||||||
|
'model_name': model_name,
|
||||||
|
'model_id': model_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
train_params = await self._validate_training_parameters(
|
||||||
|
input_data, experiment_run_id, metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
training_succeeded = False
|
||||||
|
train_result: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
train_result = await self._train_model(
|
||||||
|
train_params=train_params,
|
||||||
|
experiment_run_id=experiment_run_id,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
training_succeeded = True
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if train_result is not None:
|
||||||
|
await self._cleanup_resources(
|
||||||
|
run_dir=train_result.get('run_dir'),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pass
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
# If cleanup fails after training failed, there is nothing extra to log (DB not committed).
|
||||||
|
if training_succeeded: # pragma: no branch
|
||||||
|
workflow.logger.warning(
|
||||||
|
'cleanup_resources failed after successful training; model and DB status '
|
||||||
|
'are already committed. Temp files may remain until scheduled cleanup.',
|
||||||
|
)
|
||||||
|
return train_result
|
||||||
|
|
||||||
|
def _validate_experiment_run_id(self, input_data: dict[str, Any]) -> int:
|
||||||
|
"""
|
||||||
|
Validate experiment_run_id from input data.
|
||||||
|
|
||||||
|
This method ensures that experiment_run_id is present and valid.
|
||||||
|
Without a valid experiment_run_id, we cannot update database status,
|
||||||
|
so this validation must happen before any other operation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_data: Input data dictionary containing experiment_run_id
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Validated experiment_run_id
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If experiment_run_id is missing or not an integer
|
||||||
|
"""
|
||||||
|
experiment_run_id = input_data.get('experiment_run_id')
|
||||||
|
|
||||||
|
if experiment_run_id is None:
|
||||||
|
raise ValueError('experiment_run_id is required but was not provided')
|
||||||
|
|
||||||
|
if isinstance(experiment_run_id, int):
|
||||||
|
return experiment_run_id
|
||||||
|
|
||||||
|
if isinstance(experiment_run_id, str) and experiment_run_id.strip().isdigit():
|
||||||
|
return int(experiment_run_id.strip())
|
||||||
|
|
||||||
|
raise ValueError(
|
||||||
|
f'experiment_run_id must be an integer or numeric string, got {type(experiment_run_id).__name__}'
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _validate_training_parameters(
|
||||||
|
self,
|
||||||
|
input_data: dict[str, Any],
|
||||||
|
experiment_run_id: int,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate and convert training parameters from dict to TrainModelParams.
|
||||||
|
|
||||||
|
This method calls the validate_train_params activity to convert and validate
|
||||||
|
the input parameters. On success, updates DB status to ORCHESTRATOR_WAITING_PROC.
|
||||||
|
On error, updates DB status to ORCHESTRATOR_VALIDATION_ERROR.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_data: Input data dictionary containing all training parameters
|
||||||
|
experiment_run_id: Validated experiment run ID
|
||||||
|
metadata: Workflow execution metadata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: Validated training parameters
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If validation fails (after updating DB status)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
input_data = await workflow.execute_activity_method(
|
||||||
|
Activities.load_model_metadata,
|
||||||
|
{
|
||||||
|
**input_data,
|
||||||
|
**metadata,
|
||||||
|
},
|
||||||
|
retry_policy=no_retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=TIMEOUT_VALIDATE_PARAMS),
|
||||||
|
)
|
||||||
|
|
||||||
|
train_params = await workflow.execute_activity_method(
|
||||||
|
Activities.validate_train_params,
|
||||||
|
{
|
||||||
|
**input_data,
|
||||||
|
**metadata,
|
||||||
|
},
|
||||||
|
retry_policy=no_retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=TIMEOUT_VALIDATE_PARAMS),
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._update_experiment_run(
|
||||||
|
metadata=metadata,
|
||||||
|
experiment_run_id=experiment_run_id,
|
||||||
|
update_type=UpdateType.STATUS,
|
||||||
|
status=ExperimentStatus.ORCHESTRATOR_WAITING_PROC,
|
||||||
|
)
|
||||||
|
|
||||||
|
return train_params
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
await self._update_experiment_run(
|
||||||
|
metadata=metadata,
|
||||||
|
experiment_run_id=experiment_run_id,
|
||||||
|
update_type=UpdateType.STATUS_WITH_ERROR,
|
||||||
|
status=ExperimentStatus.ORCHESTRATOR_VALIDATION_ERROR,
|
||||||
|
error_message=self._extract_error_message(e),
|
||||||
|
)
|
||||||
|
except Exception as secondary: # noqa: BLE001
|
||||||
|
workflow.logger.warning(
|
||||||
|
'Failed to persist ORCHESTRATOR_VALIDATION_ERROR to experiment_run: %s',
|
||||||
|
secondary,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _train_model(
|
||||||
|
self,
|
||||||
|
train_params: dict[str, Any],
|
||||||
|
experiment_run_id: int,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Download file from MinIO and train model.
|
||||||
|
|
||||||
|
This method orchestrates the download and training steps using proper
|
||||||
|
resource management with try/catch/finally. On success, updates DB status
|
||||||
|
to TRAINING_SUCCESS. On error, updates DB status to TRAINING_ERROR.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
train_params: TrainModelParams object with training configuration
|
||||||
|
experiment_run_id: Validated experiment run ID
|
||||||
|
metadata: Workflow execution metadata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: Serializable training summary from the train_model activity
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If download or training fails (after updating DB status)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
train_result = await workflow.execute_activity_method(
|
||||||
|
Activities.train_model,
|
||||||
|
{
|
||||||
|
**metadata,
|
||||||
|
'train_params': train_params,
|
||||||
|
},
|
||||||
|
retry_policy=no_retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=TIMEOUT_TRAIN_MODEL),
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._update_experiment_run(
|
||||||
|
metadata=metadata,
|
||||||
|
experiment_run_id=experiment_run_id,
|
||||||
|
update_type=UpdateType.MODEL_SAVED,
|
||||||
|
status=ExperimentStatus.TRAINING_SUCCESS,
|
||||||
|
run_name=train_result.get('run_name'),
|
||||||
|
)
|
||||||
|
|
||||||
|
return train_result
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
await self._update_experiment_run(
|
||||||
|
metadata=metadata,
|
||||||
|
experiment_run_id=experiment_run_id,
|
||||||
|
update_type=UpdateType.STATUS_WITH_ERROR,
|
||||||
|
status=ExperimentStatus.TRAINING_ERROR,
|
||||||
|
error_message=self._extract_error_message(e),
|
||||||
|
)
|
||||||
|
except Exception as secondary: # noqa: BLE001
|
||||||
|
workflow.logger.warning(
|
||||||
|
'Failed to persist TRAINING_ERROR status to experiment_run: %s',
|
||||||
|
secondary,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _cleanup_resources(
|
||||||
|
self,
|
||||||
|
run_dir: str | None,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Cleanup resources.
|
||||||
|
|
||||||
|
This method removes the temporary run directory via activity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
run_dir: Temporary directory to remove
|
||||||
|
metadata: Workflow execution metadata
|
||||||
|
"""
|
||||||
|
if run_dir is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
await workflow.execute_activity_method(
|
||||||
|
Activities.cleanup_resources,
|
||||||
|
{
|
||||||
|
**metadata,
|
||||||
|
'run_dir': run_dir,
|
||||||
|
},
|
||||||
|
retry_policy=network_retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=TIMEOUT_DELETE_FILE),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _update_experiment_run(
|
||||||
|
self,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
experiment_run_id: int,
|
||||||
|
update_type: UpdateType,
|
||||||
|
status: ExperimentStatus,
|
||||||
|
error_message: str | None = None,
|
||||||
|
run_name: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Update experiment run status in the database.
|
||||||
|
|
||||||
|
This is a helper method to simplify calls to the update_experiment_run activity.
|
||||||
|
It handles both success and error status updates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metadata: Workflow execution metadata
|
||||||
|
experiment_run_id: Unique identifier for the experiment run
|
||||||
|
update_type: Type of update (STATUS, STATUS_WITH_ERROR, or MODEL_SAVED)
|
||||||
|
status: Status to set in the database
|
||||||
|
error_message: Error message (required if update_type is STATUS_WITH_ERROR)
|
||||||
|
run_name: MLFlow run name (required if update_type is MODEL_SAVED)
|
||||||
|
"""
|
||||||
|
update_input = {
|
||||||
|
**metadata,
|
||||||
|
'experiment_run_id': experiment_run_id,
|
||||||
|
'update_type': update_type,
|
||||||
|
'status': status,
|
||||||
|
}
|
||||||
|
|
||||||
|
if error_message is not None:
|
||||||
|
update_input['error_message'] = error_message
|
||||||
|
|
||||||
|
if run_name is not None:
|
||||||
|
update_input['run_name'] = run_name
|
||||||
|
|
||||||
|
await workflow.execute_activity_method(
|
||||||
|
Activities.update_experiment_run,
|
||||||
|
update_input,
|
||||||
|
retry_policy=database_retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=TIMEOUT_UPDATE_DATABASE),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _extract_error_message(self, exc: Exception) -> str:
|
||||||
|
message_parts: list[str] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
current: Exception | None = exc
|
||||||
|
|
||||||
|
while current and id(current) not in seen:
|
||||||
|
seen.add(id(current))
|
||||||
|
text = str(current).strip()
|
||||||
|
|
||||||
|
if text and text not in message_parts:
|
||||||
|
message_parts.append(text)
|
||||||
|
|
||||||
|
cause = getattr(current, '__cause__', None)
|
||||||
|
context = getattr(current, '__context__', None)
|
||||||
|
current = cause if isinstance(cause, Exception) else context
|
||||||
|
|
||||||
|
if not message_parts:
|
||||||
|
return repr(exc)
|
||||||
|
|
||||||
|
return ' | '.join(message_parts)
|
||||||
203
pyproject.toml
Normal file
203
pyproject.toml
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "model-manager"
|
||||||
|
version = "1.2.0"
|
||||||
|
description = "Sientia DataOps Model Manager - ML Model Orchestration System"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
authors = [
|
||||||
|
{name = "Aignosi", email = "dev@aignosi.com"}
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
exclude = [
|
||||||
|
".git",
|
||||||
|
".venv",
|
||||||
|
"venv",
|
||||||
|
"__pycache__",
|
||||||
|
"*.pyc",
|
||||||
|
".pytest_cache",
|
||||||
|
"htmlcov",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", # pycodestyle errors
|
||||||
|
"W", # pycodestyle warnings
|
||||||
|
"F", # pyflakes
|
||||||
|
"I", # isort
|
||||||
|
"B", # flake8-bugbear
|
||||||
|
"C4", # flake8-comprehensions
|
||||||
|
"UP", # pyupgrade
|
||||||
|
"N", # pep8-naming
|
||||||
|
"YTT", # flake8-2020
|
||||||
|
"S", # flake8-bandit
|
||||||
|
"BLE", # flake8-blind-except
|
||||||
|
"A", # flake8-builtins
|
||||||
|
"C90", # mccabe complexity
|
||||||
|
]
|
||||||
|
|
||||||
|
ignore = [
|
||||||
|
"E501", # line too long (handled by formatter)
|
||||||
|
"S101", # use of assert (needed for tests)
|
||||||
|
"S105", # possible hardcoded password (false positives)
|
||||||
|
"S106", # possible hardcoded password (false positives)
|
||||||
|
"N802", # function name should be lowercase (temporal decorators)
|
||||||
|
"N806", # variable in function should be lowercase
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/**/*.py" = [
|
||||||
|
"S101", # assert allowed in tests
|
||||||
|
"S105", # hardcoded passwords ok in tests
|
||||||
|
"S106", # hardcoded passwords ok in tests
|
||||||
|
"S108", # temp paths are expected in tests
|
||||||
|
]
|
||||||
|
"e2e/**/*.py" = [
|
||||||
|
"S101", # assert allowed in tests
|
||||||
|
"S105", # hardcoded passwords ok in tests
|
||||||
|
"S106", # hardcoded passwords ok in tests
|
||||||
|
"S108", # temp paths are expected in tests
|
||||||
|
"ARG001", # unused function args in fixtures
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.mccabe]
|
||||||
|
max-complexity = 15
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "single"
|
||||||
|
indent-style = "space"
|
||||||
|
line-ending = "auto"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.11"
|
||||||
|
warn_return_any = false
|
||||||
|
warn_unused_configs = true
|
||||||
|
disallow_untyped_defs = false
|
||||||
|
disallow_incomplete_defs = false
|
||||||
|
check_untyped_defs = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_unused_ignores = false
|
||||||
|
warn_no_return = true
|
||||||
|
strict_equality = true
|
||||||
|
ignore_missing_imports = false
|
||||||
|
|
||||||
|
# Ignore missing imports for external packages
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "temporalio.*"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "sientia_do.*"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "mlflow.*"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "prometheus_client.*"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "pandas.*"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "bs4.*"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "evidently.*"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "sklearn.*"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "sientia_model.*"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "yaml"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = [
|
||||||
|
"model_manager.utils.repository.model_repository",
|
||||||
|
"model_manager.utils.repository.data_manager_repository",
|
||||||
|
]
|
||||||
|
ignore_errors = true
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests", "e2e"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_classes = ["Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
addopts = [
|
||||||
|
"-v",
|
||||||
|
"--strict-markers",
|
||||||
|
"--cov=model_manager",
|
||||||
|
"--cov-report=term-missing",
|
||||||
|
"--cov-report=html",
|
||||||
|
"--cov-report=xml",
|
||||||
|
]
|
||||||
|
markers = [
|
||||||
|
"asyncio: marks tests as async",
|
||||||
|
"integration: marks tests as integration tests",
|
||||||
|
"unit: marks tests as unit tests",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pyright]
|
||||||
|
reportMissingTypeStubs = false
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["model_manager"]
|
||||||
|
omit = [
|
||||||
|
"*/tests/*",
|
||||||
|
"*/venv/*",
|
||||||
|
"*/__pycache__/*",
|
||||||
|
"*/site-packages/*",
|
||||||
|
]
|
||||||
|
branch = true
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
precision = 2
|
||||||
|
show_missing = true
|
||||||
|
skip_covered = false
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"def __repr__",
|
||||||
|
"def __str__",
|
||||||
|
"raise AssertionError",
|
||||||
|
"raise NotImplementedError",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
"class .*\\bProtocol\\):",
|
||||||
|
"@(abc\\.)?abstractmethod",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.html]
|
||||||
|
directory = "htmlcov"
|
||||||
|
|
||||||
|
[tool.bandit]
|
||||||
|
exclude_dirs = ["tests", "venv", ".venv"]
|
||||||
|
skips = ["B101", "B601"] # Skip assert and shell injection in controlled environments
|
||||||
|
|
||||||
|
[tool.deptry]
|
||||||
|
known_first_party = [
|
||||||
|
"model_manager"
|
||||||
|
]
|
||||||
|
requirements_files = [
|
||||||
|
"requirements.txt"
|
||||||
|
]
|
||||||
|
requirements_files_dev = [
|
||||||
|
"requirements-dev.txt"
|
||||||
|
]
|
||||||
23
requirements-dev.txt
Normal file
23
requirements-dev.txt
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Development and Testing Dependencies
|
||||||
|
# These packages are only needed for development, testing, and code quality checks
|
||||||
|
# Install with: pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
# Code Quality & Linting
|
||||||
|
ruff>=0.1.0 # Fast Python linter and formatter (replaces flake8, black, isort)
|
||||||
|
mypy>=1.7.0 # Static type checker
|
||||||
|
bandit>=1.7.5 # Security vulnerability scanner
|
||||||
|
pandas-stubs>=2.0.0 # Type stubs for pandas
|
||||||
|
types-psycopg2>=2.9.0 # Type stubs for psycopg2
|
||||||
|
types-requests>=2.31.0 # Type stubs for requests
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest>=7.4.0 # Testing framework
|
||||||
|
pytest-cov>=4.1.0 # Coverage plugin for pytest
|
||||||
|
pytest-asyncio>=0.21.0 # Async test support
|
||||||
|
testcontainers[postgres,minio,mongodb]>=4.0.0 # Real containers for E2E tests
|
||||||
|
requests>=2.31.0 # HTTP client for Gitea REST API seeding (E2E)
|
||||||
|
|
||||||
|
# Development Tools
|
||||||
|
ipython>=8.12.0 # Enhanced Python shell
|
||||||
|
ipdb>=0.13.13 # IPython debugger
|
||||||
|
ipykernel<=6.26.0 # IPython kernel ( other versions cause problems with interactive mode)
|
||||||
10
requirements-local.txt
Normal file
10
requirements-local.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
temporalio==1.23.0
|
||||||
|
psycopg2-binary==2.9.11
|
||||||
|
sqlalchemy==2.0.49
|
||||||
|
boto3==1.42.70
|
||||||
|
botocore==1.42.70
|
||||||
|
git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.12.0
|
||||||
|
git+ssh://git@github.com/Aignosi/sientia-model-library.git@0.8.3
|
||||||
|
prometheus-client==0.23.1
|
||||||
|
beautifulsoup4==4.12.3
|
||||||
|
evidently==0.6.7
|
||||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
temporalio==1.23.0
|
||||||
|
psycopg2-binary==2.9.11
|
||||||
|
sqlalchemy==2.0.50
|
||||||
|
boto3==1.42.70
|
||||||
|
botocore==1.42.70
|
||||||
|
sientia_do>=1.11.0
|
||||||
|
sientia_model>=0.8.1
|
||||||
|
prometheus-client==0.23.1
|
||||||
|
beautifulsoup4==4.12.3
|
||||||
|
evidently==0.6.7
|
||||||
|
jsonschema==4.26.0
|
||||||
15
run_local.sh
Executable file
15
run_local.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Exit on any error
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ -f .env ]; then
|
||||||
|
set -a
|
||||||
|
source <(cat .env | grep -v '^#' | grep -v '^$')
|
||||||
|
set +a
|
||||||
|
echo "Environment variables loaded from .env"
|
||||||
|
else
|
||||||
|
echo "Warning: .env file not found. Continuing without environment variables."
|
||||||
|
fi
|
||||||
|
|
||||||
|
python -m model_manager.worker.worker
|
||||||
48
scripts/inputs/linear_regression.json
Normal file
48
scripts/inputs/linear_regression.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"experiment": {
|
||||||
|
"experiment_run_id": 1001,
|
||||||
|
"experiment_name": "test-experiment-name",
|
||||||
|
"run_name": "test-run-name",
|
||||||
|
"username": "vitor.santos@aignosi.com.br",
|
||||||
|
"status": "ORCHESTRATOR_WAITING_PROC"
|
||||||
|
},
|
||||||
|
"minio": {
|
||||||
|
"mc_alias": "suse",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training-sample-dataset-1001.csv",
|
||||||
|
"local_csv": "input_dataset.csv"
|
||||||
|
},
|
||||||
|
"temporal": {
|
||||||
|
"task_queue": "train_model-basic-queue",
|
||||||
|
"workflow_name": "train_model",
|
||||||
|
"execution_timeout_minutes": 5,
|
||||||
|
"run_timeout_minutes": 5,
|
||||||
|
"task_timeout_minutes": 5
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"experiment_run_id": 1001,
|
||||||
|
"variable_columns": ["Counter", "Rollout"],
|
||||||
|
"target_variable": "Square",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "smoke-linreg",
|
||||||
|
"model_type": "linear_regression",
|
||||||
|
"model_id": 1001,
|
||||||
|
"data_model_kwargs": {},
|
||||||
|
"model_kwargs": {},
|
||||||
|
"opt_params": {},
|
||||||
|
"date_column": "timestamp"
|
||||||
|
},
|
||||||
|
"db_only": {
|
||||||
|
"model_metadata": {
|
||||||
|
"schemas": {
|
||||||
|
"components": {
|
||||||
|
"schemas": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
scripts/inputs/sin-approx.json
Normal file
49
scripts/inputs/sin-approx.json
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"experiment": {
|
||||||
|
"experiment_run_id": 2000,
|
||||||
|
"experiment_name": "experiment-sin-approx",
|
||||||
|
"run_name": "run-sin-approx",
|
||||||
|
"username": "vitor.santos@aignosi.com.br",
|
||||||
|
"status": "ORCHESTRATOR_WAITING_PROC"
|
||||||
|
},
|
||||||
|
"minio": {
|
||||||
|
"mc_alias": "open-suse",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training-sin-approx-dataset-1001.csv",
|
||||||
|
"local_csv": "data-1780946658143-pivot.csv"
|
||||||
|
},
|
||||||
|
"temporal": {
|
||||||
|
"task_queue": "train_model-basic-queue",
|
||||||
|
"workflow_name": "train_model",
|
||||||
|
"execution_timeout_minutes": 5,
|
||||||
|
"run_timeout_minutes": 5,
|
||||||
|
"task_timeout_minutes": 5
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"experiment_run_id": 2000,
|
||||||
|
"variable_columns": ["SourceTri"],
|
||||||
|
"target_variable": "TargetSin",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "sin-approx",
|
||||||
|
"model_type": "linear_regression",
|
||||||
|
"model_id": 1,
|
||||||
|
"data_model_kwargs": {},
|
||||||
|
"model_kwargs": {},
|
||||||
|
"opt_params": {},
|
||||||
|
"date_column": "timestamp"
|
||||||
|
},
|
||||||
|
"db_only": {
|
||||||
|
"model_metadata": {
|
||||||
|
"schemas": {
|
||||||
|
"components": {
|
||||||
|
"schemas": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
63
scripts/inputs/xgboost.json
Normal file
63
scripts/inputs/xgboost.json
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"experiment": {
|
||||||
|
"experiment_run_id": 1002,
|
||||||
|
"experiment_name": "test-experiment-xgboost",
|
||||||
|
"run_name": "test-run-xgboost",
|
||||||
|
"username": "vitor.santos@aignosi.com.br",
|
||||||
|
"status": "ORCHESTRATOR_WAITING_PROC"
|
||||||
|
},
|
||||||
|
"minio": {
|
||||||
|
"mc_alias": "suse",
|
||||||
|
"bucket_name": "model-training",
|
||||||
|
"file_name": "training-sample-dataset-1002.csv",
|
||||||
|
"local_csv": "input_dataset.csv"
|
||||||
|
},
|
||||||
|
"temporal": {
|
||||||
|
"task_queue": "train_model-basic-queue",
|
||||||
|
"workflow_name": "train_model",
|
||||||
|
"execution_timeout_minutes": 5,
|
||||||
|
"run_timeout_minutes": 5,
|
||||||
|
"task_timeout_minutes": 5
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"experiment_run_id": 1002,
|
||||||
|
"variable_columns": ["Counter", "Rollout"],
|
||||||
|
"target_variable": "Square",
|
||||||
|
"line_separator": ",",
|
||||||
|
"decimal_separator": ".",
|
||||||
|
"train_size": 80,
|
||||||
|
"shuffle": true,
|
||||||
|
"random_state": 42,
|
||||||
|
"model_name": "smoke-xgb",
|
||||||
|
"model_type": "xgboost",
|
||||||
|
"model_id": 1002,
|
||||||
|
"data_model_kwargs": {
|
||||||
|
"scaler_method": "MinMax",
|
||||||
|
"window_size": 3,
|
||||||
|
"use_filtering": false,
|
||||||
|
"transform_mode": "all"
|
||||||
|
},
|
||||||
|
"model_kwargs": {},
|
||||||
|
"opt_params": {
|
||||||
|
"tree_method": "hist",
|
||||||
|
"device": "cuda",
|
||||||
|
"learning_rate": 0.3,
|
||||||
|
"n_estimators": 100,
|
||||||
|
"max_depth": 32,
|
||||||
|
"subsample": 0.8,
|
||||||
|
"colsample_bytree": 0.8,
|
||||||
|
"min_child_weight": 5,
|
||||||
|
"random_state": 42
|
||||||
|
},
|
||||||
|
"date_column": "timestamp"
|
||||||
|
},
|
||||||
|
"db_only": {
|
||||||
|
"model_metadata": {
|
||||||
|
"schemas": {
|
||||||
|
"components": {
|
||||||
|
"schemas": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
scripts/run_cleanup_test.py
Normal file
82
scripts/run_cleanup_test.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run cleanup_files workflow once for manual testing.
|
||||||
|
|
||||||
|
This script starts the Temporal workflow `cleanup_files` a single time,
|
||||||
|
using the same Temporal namespace and task queue as the main worker.
|
||||||
|
|
||||||
|
It is intended only for local/manual testing; scheduling (cron) must be
|
||||||
|
configured separately in Temporal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from temporalio.client import Client
|
||||||
|
|
||||||
|
# Ensure project root is on PYTHONPATH when running directly (must run before model_manager import)
|
||||||
|
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if ROOT_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT_DIR)
|
||||||
|
|
||||||
|
from model_manager.workflows.cleanup_files import CleanupFiles # noqa: E402
|
||||||
|
|
||||||
|
# Carrega variáveis de ambiente do arquivo .env na raiz do projeto
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
ENV_PATH = PROJECT_ROOT / '.env'
|
||||||
|
if ENV_PATH.exists():
|
||||||
|
load_dotenv(dotenv_path=ENV_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
async def main(argv: list[str]) -> None:
|
||||||
|
"""Entry point for manual cleanup workflow execution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
argv: Command-line arguments (excluding program name).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Config from environment / defaults
|
||||||
|
temporal_host = os.getenv('TEMPORAL_HOST')
|
||||||
|
temporal_namespace = os.getenv('TEMPORAL_NAMESPACE')
|
||||||
|
task_queue = os.getenv('CLEANUP_TASK_QUEUE')
|
||||||
|
use_tls = os.getenv('TEMPORAL_USE_TLS', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
print(f'Connecting to Temporal at {temporal_host} (namespace={temporal_namespace})...')
|
||||||
|
client = await Client.connect(
|
||||||
|
target_host=temporal_host,
|
||||||
|
namespace=temporal_namespace,
|
||||||
|
tls=use_tls,
|
||||||
|
)
|
||||||
|
|
||||||
|
input_data: dict[str, Any] = {
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow_id = f'cleanup-files-manual-{int(asyncio.get_event_loop().time())}'
|
||||||
|
|
||||||
|
print(
|
||||||
|
f'Starting cleanup_files workflow once...\n'
|
||||||
|
f' workflow_id = {workflow_id}\n'
|
||||||
|
f' task_queue = {task_queue}'
|
||||||
|
)
|
||||||
|
|
||||||
|
handle = await client.start_workflow(
|
||||||
|
CleanupFiles.run,
|
||||||
|
input_data,
|
||||||
|
id=workflow_id,
|
||||||
|
task_queue=task_queue,
|
||||||
|
run_timeout=timedelta(minutes=10),
|
||||||
|
)
|
||||||
|
|
||||||
|
print('Workflow started, waiting for completion...')
|
||||||
|
await handle.result()
|
||||||
|
print('cleanup_files workflow completed successfully.')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__': # pragma: no cover - manual utility script
|
||||||
|
asyncio.run(main(sys.argv[1:]))
|
||||||
310
scripts/run_training_test.py
Normal file
310
scripts/run_training_test.py
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
# ---
|
||||||
|
# jupyter:
|
||||||
|
# jupytext:
|
||||||
|
# formats: py:percent
|
||||||
|
# text_representation:
|
||||||
|
# extension: .py
|
||||||
|
# format_name: percent
|
||||||
|
# kernelspec:
|
||||||
|
# display_name: Python 3
|
||||||
|
# language: python
|
||||||
|
# name: python3
|
||||||
|
# ---
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# # Training smoke test (`input-sample.md`)
|
||||||
|
#
|
||||||
|
# Run cells top to bottom in VS Code / Cursor (**Run Cell** on each `# %%` block).
|
||||||
|
#
|
||||||
|
# Steps mirror `input-sample.md`: optional DB delete + insert, `mc cp` to MinIO, Temporal `train_model`.
|
||||||
|
# Set `POSTGRES_*`, `TEMPORAL_*`, and configure the `mc` alias. Add/remove entries in
|
||||||
|
# `TRAINING_TEST_INPUTS` below to choose which experiments run.
|
||||||
|
|
||||||
|
# %%
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from psycopg2.extras import Json
|
||||||
|
from temporalio import client
|
||||||
|
|
||||||
|
TRAINING_TEST_INPUTS: list[str] = [
|
||||||
|
'scripts/inputs/linear_regression.json',
|
||||||
|
'scripts/inputs/xgboost.json',
|
||||||
|
]
|
||||||
|
|
||||||
|
TRAINING_TEST_INPUTS: list[str] = [
|
||||||
|
'scripts/inputs/sin-approx.json',
|
||||||
|
]
|
||||||
|
|
||||||
|
_REQUIRED_INPUT_KEYS = ('experiment', 'minio', 'temporal', 'payload', 'db_only')
|
||||||
|
|
||||||
|
|
||||||
|
def _load_input(path: Path) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Load a training smoke-test input JSON and validate required top-level keys.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- path: Path to the input JSON file
|
||||||
|
|
||||||
|
Return:
|
||||||
|
Parsed input dict with experiment, minio, temporal, payload, and db_only sections
|
||||||
|
"""
|
||||||
|
data: dict[str, Any] = json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
missing = [key for key in _REQUIRED_INPUT_KEYS if key not in data]
|
||||||
|
if missing:
|
||||||
|
raise KeyError(f'Input JSON missing required top-level keys: {", ".join(missing)}')
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _postgres_connect_kwargs() -> dict[str, str | int]:
|
||||||
|
"""
|
||||||
|
Build psycopg2.connect keyword arguments from POSTGRES_* environment variables.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
host, port, user, password, and dbname suitable for psycopg2.connect
|
||||||
|
"""
|
||||||
|
host = os.getenv('POSTGRES_HOST')
|
||||||
|
user = os.getenv('POSTGRES_USER')
|
||||||
|
password = os.getenv('POSTGRES_PASSWORD')
|
||||||
|
dbname = os.getenv('POSTGRES_DBNAME')
|
||||||
|
if not host or not user or not password or not dbname:
|
||||||
|
raise RuntimeError(
|
||||||
|
'Set POSTGRES_HOST, POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DBNAME'
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'host': host,
|
||||||
|
'port': int(os.getenv('POSTGRES_PORT', '5432')),
|
||||||
|
'user': user,
|
||||||
|
'password': password,
|
||||||
|
'dbname': dbname,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_input_paths(project_root: Path, entries: list[str]) -> list[Path]:
|
||||||
|
"""
|
||||||
|
Resolve and validate the hardcoded TRAINING_TEST_INPUTS list against the filesystem.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- project_root: Repository root used to resolve relative entries
|
||||||
|
- entries: Input file paths (absolute or relative to project_root)
|
||||||
|
|
||||||
|
Return:
|
||||||
|
Absolute paths to the input JSON files, in declaration order
|
||||||
|
"""
|
||||||
|
if not entries:
|
||||||
|
raise RuntimeError('TRAINING_TEST_INPUTS is empty; add at least one input JSON path')
|
||||||
|
resolved: list[Path] = []
|
||||||
|
for entry in entries:
|
||||||
|
candidate = Path(entry)
|
||||||
|
if not candidate.is_absolute():
|
||||||
|
candidate = project_root / candidate
|
||||||
|
if not candidate.is_file():
|
||||||
|
raise FileNotFoundError(f'Training test input file not found: {candidate}')
|
||||||
|
resolved.append(candidate.resolve())
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_csv_columns(
|
||||||
|
local_csv: Path,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
input_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Fail fast when the local CSV header does not match payload column names.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- local_csv: Resolved path to the CSV on disk
|
||||||
|
- payload: Training payload with variable_columns, target_variable, and separators
|
||||||
|
- input_path: Input JSON path (for error messages)
|
||||||
|
|
||||||
|
Return:
|
||||||
|
None; raises ValueError when required columns are missing from the CSV header
|
||||||
|
"""
|
||||||
|
line_separator = str(payload.get('line_separator', ','))
|
||||||
|
with local_csv.open(newline='', encoding='utf-8') as handle:
|
||||||
|
header = next(csv.reader(handle, delimiter=line_separator))
|
||||||
|
required = list(payload['variable_columns']) + [str(payload['target_variable'])]
|
||||||
|
date_column = payload.get('date_column')
|
||||||
|
if date_column:
|
||||||
|
required.append(str(date_column))
|
||||||
|
missing = [column for column in required if column not in header]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f'{input_path}: CSV {local_csv} header {header!r} is missing columns '
|
||||||
|
f'{missing!r} (check payload variable_columns, target_variable, date_column)'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_local_csv(project_root: Path, local_csv: str) -> Path:
|
||||||
|
"""
|
||||||
|
Resolve a minio.local_csv entry against the project root when relative.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- project_root: Repository root
|
||||||
|
- local_csv: Path declared in the input JSON (absolute or relative)
|
||||||
|
|
||||||
|
Return:
|
||||||
|
Absolute path to the local CSV file
|
||||||
|
"""
|
||||||
|
candidate = Path(local_csv)
|
||||||
|
if not candidate.is_absolute():
|
||||||
|
candidate = project_root / candidate
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
load_dotenv(PROJECT_ROOT / '.env')
|
||||||
|
|
||||||
|
experiments: list[dict[str, Any]] = []
|
||||||
|
for _input_path in _resolve_input_paths(PROJECT_ROOT, TRAINING_TEST_INPUTS):
|
||||||
|
_input = _load_input(_input_path)
|
||||||
|
_local_csv = _resolve_local_csv(PROJECT_ROOT, _input['minio']['local_csv'])
|
||||||
|
_validate_csv_columns(_local_csv, _input['payload'], _input_path)
|
||||||
|
experiments.append({'path': _input_path, **_input})
|
||||||
|
_payload = _input['payload']
|
||||||
|
_experiment = _input['experiment']
|
||||||
|
print(
|
||||||
|
f'input={_input_path} '
|
||||||
|
f'model_type={_payload["model_type"]} '
|
||||||
|
f'model_name={_payload["model_name"]} '
|
||||||
|
f'experiment_run_id={_experiment["experiment_run_id"]}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# %%
|
||||||
|
# --- configuration (`.env` at repo root; per-run values from input JSON) ---
|
||||||
|
|
||||||
|
PG = _postgres_connect_kwargs()
|
||||||
|
|
||||||
|
TEMPORAL_HOST = os.getenv('TEMPORAL_HOST')
|
||||||
|
TEMPORAL_NAMESPACE = os.getenv('TEMPORAL_NAMESPACE')
|
||||||
|
TEMPORAL_TLS = os.getenv('TEMPORAL_USE_TLS', 'false').lower() in ('1', 'true', 'yes')
|
||||||
|
|
||||||
|
print(PG)
|
||||||
|
print(TEMPORAL_HOST, TEMPORAL_NAMESPACE, TEMPORAL_TLS)
|
||||||
|
for exp in experiments:
|
||||||
|
exp_minio = exp['minio']
|
||||||
|
exp_temporal = exp['temporal']
|
||||||
|
print(
|
||||||
|
exp_minio['mc_alias'],
|
||||||
|
exp_minio['bucket_name'],
|
||||||
|
exp_minio['file_name'],
|
||||||
|
_resolve_local_csv(PROJECT_ROOT, exp_minio['local_csv']),
|
||||||
|
exp_temporal['task_queue'],
|
||||||
|
)
|
||||||
|
|
||||||
|
# %%
|
||||||
|
# --- 1) database: delete previous row (same id), then insert `experiment_run` for each experiment ---
|
||||||
|
# Primary key column is `id` (see `experiment_tracking` updates).
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
with psycopg2.connect(
|
||||||
|
host=PG['host'],
|
||||||
|
port=PG['port'],
|
||||||
|
user=PG['user'],
|
||||||
|
password=PG['password'],
|
||||||
|
dbname=PG['dbname'],
|
||||||
|
) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
for exp in experiments:
|
||||||
|
exp_experiment = exp['experiment']
|
||||||
|
exp_minio = exp['minio']
|
||||||
|
exp_payload = exp['payload']
|
||||||
|
exp_db_only = exp['db_only']
|
||||||
|
request_data = {
|
||||||
|
**exp_payload,
|
||||||
|
'bucket_name': exp_minio['bucket_name'],
|
||||||
|
'file_name': exp_minio['file_name'],
|
||||||
|
'model_metadata': exp_db_only['model_metadata'],
|
||||||
|
}
|
||||||
|
cur.execute(
|
||||||
|
'DELETE FROM public.experiment_run WHERE id = %s',
|
||||||
|
(exp_experiment['experiment_run_id'],),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO public.experiment_run (
|
||||||
|
id, experiment_name, run_name, username, status, error_message,
|
||||||
|
created_at, updated_at, bucket_name, file_name, request_data, orchestrator_response_data
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
exp_experiment['experiment_run_id'],
|
||||||
|
exp_experiment['experiment_name'],
|
||||||
|
exp_experiment['run_name'],
|
||||||
|
exp_experiment['username'],
|
||||||
|
exp_experiment['status'],
|
||||||
|
None,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
exp_minio['bucket_name'],
|
||||||
|
exp_minio['file_name'],
|
||||||
|
Json(request_data),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# %%
|
||||||
|
# --- 2) MinIO: upload local CSV for each experiment (requires `mc` CLI and alias configured) ---
|
||||||
|
for exp in experiments:
|
||||||
|
exp_minio = exp['minio']
|
||||||
|
local_csv = _resolve_local_csv(PROJECT_ROOT, exp_minio['local_csv'])
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
'mc',
|
||||||
|
'cp',
|
||||||
|
'--insecure',
|
||||||
|
str(local_csv),
|
||||||
|
f'{exp_minio["mc_alias"]}/{exp_minio["bucket_name"]}/{exp_minio["file_name"]}',
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# %%
|
||||||
|
# --- 3) Temporal: connect once, then start `train_model` per experiment ---
|
||||||
|
|
||||||
|
if not TEMPORAL_HOST or not TEMPORAL_NAMESPACE:
|
||||||
|
raise RuntimeError('Set TEMPORAL_HOST and TEMPORAL_NAMESPACE in the environment')
|
||||||
|
|
||||||
|
c = await client.Client.connect( # type: ignore[top-level-await]
|
||||||
|
target_host=TEMPORAL_HOST,
|
||||||
|
namespace=TEMPORAL_NAMESPACE,
|
||||||
|
tls=TEMPORAL_TLS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# %%
|
||||||
|
|
||||||
|
for exp in experiments:
|
||||||
|
exp_minio = exp['minio']
|
||||||
|
exp_temporal = exp['temporal']
|
||||||
|
exp_payload = exp['payload']
|
||||||
|
workflow_input = {
|
||||||
|
**exp_payload,
|
||||||
|
'bucket_name': exp_minio['bucket_name'],
|
||||||
|
'file_name': exp_minio['file_name'],
|
||||||
|
}
|
||||||
|
wid = f'train-model-test-{uuid.uuid4()}'
|
||||||
|
result = await c.execute_workflow( # type: ignore[top-level-await, call-overload]
|
||||||
|
exp_temporal['workflow_name'],
|
||||||
|
workflow_input,
|
||||||
|
id=wid,
|
||||||
|
task_queue=exp_temporal['task_queue'],
|
||||||
|
execution_timeout=timedelta(minutes=exp_temporal['execution_timeout_minutes']),
|
||||||
|
run_timeout=timedelta(minutes=exp_temporal['run_timeout_minutes']),
|
||||||
|
task_timeout=timedelta(minutes=exp_temporal['task_timeout_minutes']),
|
||||||
|
)
|
||||||
|
print(wid)
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
# %%
|
||||||
9
sonar-project.properties
Normal file
9
sonar-project.properties
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
sonar.projectKey=Aignosi_sientia-dataops-model-manager_dc6e051c-995a-4b57-9cde-f511018184c7
|
||||||
|
sonar.projectName=sientia-dataops-model-manager
|
||||||
|
sonar.sources=model_manager
|
||||||
|
sonar.tests=tests
|
||||||
|
sonar.qualitygate.wait=true
|
||||||
|
sonar.qualitygate.timeout=300
|
||||||
|
sonar.python.coverage.reportPaths=coverage.xml
|
||||||
|
sonar.python.xunit.reportPath=pytest.xml
|
||||||
|
sonar.python.version=3.11
|
||||||
24
t.py
Normal file
24
t.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# %%
|
||||||
|
from pandas import read_csv, to_datetime
|
||||||
|
|
||||||
|
df = read_csv('data-1780946658143.csv')
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df.head()
|
||||||
|
|
||||||
|
# %%
|
||||||
|
# normalize timestamp to naive "yyyy-MM-dd HH:mm:ss" (drop the "+00" UTC offset)
|
||||||
|
df['timestamp'] = to_datetime(df['timestamp'], utc=True).dt.tz_localize(None)
|
||||||
|
df['timestamp'] = df['timestamp'].dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
# %%
|
||||||
|
# pivot the dataframe
|
||||||
|
df = df.pivot(index='timestamp', columns='variable', values='value')
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df.head()
|
||||||
|
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df.to_csv('data-1780946658143-pivot.csv')
|
||||||
|
# %%
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/activities/__init__.py
Normal file
0
tests/activities/__init__.py
Normal file
144
tests/activities/test_activities.py
Normal file
144
tests/activities/test_activities.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""Unit tests for Activities orchestrator (constructor, shutdown, destructor)."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||||
|
|
||||||
|
from model_manager.activities.activities import Activities
|
||||||
|
|
||||||
|
|
||||||
|
def _postgres():
|
||||||
|
return {
|
||||||
|
'host': 'h',
|
||||||
|
'port': 5432,
|
||||||
|
'user': 'u',
|
||||||
|
'password': 'p',
|
||||||
|
'dbname': 'db',
|
||||||
|
'min_connections': 1,
|
||||||
|
'max_connections': 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mlflow():
|
||||||
|
return {'url': 'http://mlflow:5000', 'username': 'u', 'password': 'p'}
|
||||||
|
|
||||||
|
|
||||||
|
def _minio(endpoint_url: str):
|
||||||
|
return {
|
||||||
|
'endpoint_url': endpoint_url,
|
||||||
|
'access_key': 'a',
|
||||||
|
'secret_key': 's',
|
||||||
|
'region': 'r',
|
||||||
|
'use_ssl': True,
|
||||||
|
'default_bucket': 'test-bucket',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'endpoint,expected_endpoint',
|
||||||
|
[
|
||||||
|
('http://minio:9000', 'minio:9000'),
|
||||||
|
('https://minio:9000', 'minio:9000'),
|
||||||
|
('minio:9000', 'minio:9000'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_activities_strips_minio_endpoint_scheme(endpoint, expected_endpoint):
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
'model_manager.activities.activities.ExperimentTracking.__init__',
|
||||||
|
Mock(return_value=None),
|
||||||
|
),
|
||||||
|
patch('model_manager.activities.activities.Training.__init__', Mock(return_value=None)),
|
||||||
|
patch('model_manager.activities.activities.Cleanup.__init__', Mock(return_value=None)),
|
||||||
|
patch('model_manager.activities.activities.SientiaMLflowRepository') as m_mlflow,
|
||||||
|
patch('model_manager.activities.activities.MinioRepository') as m_minio,
|
||||||
|
):
|
||||||
|
Activities(
|
||||||
|
postgres_config=_postgres(),
|
||||||
|
mlflow_config=_mlflow(),
|
||||||
|
minio_config=_minio(endpoint),
|
||||||
|
plugin_store=MagicMock(),
|
||||||
|
logger=MagicMock(),
|
||||||
|
notification_handler=MagicMock(),
|
||||||
|
metrics_controller=MagicMock(),
|
||||||
|
)
|
||||||
|
m_minio.assert_called_once()
|
||||||
|
assert m_minio.call_args.kwargs['endpoint'] == expected_endpoint
|
||||||
|
assert m_minio.call_args.kwargs['bucket'] == 'test-bucket'
|
||||||
|
m_mlflow.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_activities_shutdown_calls_parents():
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
'model_manager.activities.activities.ExperimentTracking.__init__',
|
||||||
|
Mock(return_value=None),
|
||||||
|
),
|
||||||
|
patch('model_manager.activities.activities.Training.__init__', Mock(return_value=None)),
|
||||||
|
patch('model_manager.activities.activities.Cleanup.__init__', Mock(return_value=None)),
|
||||||
|
patch('model_manager.activities.activities.SientiaMLflowRepository'),
|
||||||
|
patch('model_manager.activities.activities.MinioRepository'),
|
||||||
|
patch('model_manager.activities.activities.ExperimentTracking.close') as m_close,
|
||||||
|
patch('model_manager.activities.activities.SientiaMonitoring.shutdown') as m_mon,
|
||||||
|
):
|
||||||
|
a = Activities(
|
||||||
|
postgres_config=_postgres(),
|
||||||
|
mlflow_config=_mlflow(),
|
||||||
|
minio_config=_minio('http://x:9000'),
|
||||||
|
plugin_store=MagicMock(),
|
||||||
|
logger=MagicMock(),
|
||||||
|
notification_handler=MagicMock(),
|
||||||
|
metrics_controller=MagicMock(),
|
||||||
|
)
|
||||||
|
with patch.object(SientiaMonitoring, 'info', Mock()):
|
||||||
|
a.shutdown()
|
||||||
|
m_close.assert_called_once()
|
||||||
|
m_mon.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_activities_del_with_engine_runs_without_error():
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
'model_manager.activities.activities.ExperimentTracking.__init__',
|
||||||
|
Mock(return_value=None),
|
||||||
|
),
|
||||||
|
patch('model_manager.activities.activities.Training.__init__', Mock(return_value=None)),
|
||||||
|
patch('model_manager.activities.activities.Cleanup.__init__', Mock(return_value=None)),
|
||||||
|
patch('model_manager.activities.activities.SientiaMLflowRepository'),
|
||||||
|
patch('model_manager.activities.activities.MinioRepository'),
|
||||||
|
):
|
||||||
|
a = Activities(
|
||||||
|
postgres_config=_postgres(),
|
||||||
|
mlflow_config=_mlflow(),
|
||||||
|
minio_config=_minio('http://x:9000'),
|
||||||
|
plugin_store=MagicMock(),
|
||||||
|
logger=MagicMock(),
|
||||||
|
notification_handler=MagicMock(),
|
||||||
|
metrics_controller=MagicMock(),
|
||||||
|
)
|
||||||
|
a.engine = MagicMock()
|
||||||
|
Activities.__del__(a)
|
||||||
|
|
||||||
|
|
||||||
|
def test_activities_del_without_engine_runs_without_error():
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
'model_manager.activities.activities.ExperimentTracking.__init__',
|
||||||
|
Mock(return_value=None),
|
||||||
|
),
|
||||||
|
patch('model_manager.activities.activities.Training.__init__', Mock(return_value=None)),
|
||||||
|
patch('model_manager.activities.activities.Cleanup.__init__', Mock(return_value=None)),
|
||||||
|
patch('model_manager.activities.activities.SientiaMLflowRepository'),
|
||||||
|
patch('model_manager.activities.activities.MinioRepository'),
|
||||||
|
):
|
||||||
|
a = Activities(
|
||||||
|
postgres_config=_postgres(),
|
||||||
|
mlflow_config=_mlflow(),
|
||||||
|
minio_config=_minio('http://x:9000'),
|
||||||
|
plugin_store=MagicMock(),
|
||||||
|
logger=MagicMock(),
|
||||||
|
notification_handler=MagicMock(),
|
||||||
|
metrics_controller=MagicMock(),
|
||||||
|
)
|
||||||
|
Activities.__del__(a)
|
||||||
310
tests/activities/test_cleanup.py
Normal file
310
tests/activities/test_cleanup.py
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
"""Unit tests for the Cleanup activity, ensuring 100% code coverage."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from importlib import reload
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Define mocks at the top level to be accessible by all tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_logger():
|
||||||
|
"""Fixture for a mock logger."""
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_notification_handler():
|
||||||
|
"""Fixture for a mock notification handler."""
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_metrics_controller():
|
||||||
|
"""Fixture for a mock metrics controller with async methods."""
|
||||||
|
controller = MagicMock()
|
||||||
|
controller.shutdown = AsyncMock()
|
||||||
|
controller.emit = AsyncMock()
|
||||||
|
return controller
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_dir():
|
||||||
|
"""Fixture to create and clean up a temporary directory."""
|
||||||
|
path = tempfile.mkdtemp()
|
||||||
|
yield path
|
||||||
|
shutil.rmtree(path)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Initialization Tests ---
|
||||||
|
|
||||||
|
|
||||||
|
@patch.dict(
|
||||||
|
'model_manager.activities.cleanup.os.environ',
|
||||||
|
{
|
||||||
|
'CLEANUP_RETENTION_HOURS': '24',
|
||||||
|
'CLEANUP_DRY_RUN': 'false',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def test_cleanup_init_default_values(
|
||||||
|
mock_logger,
|
||||||
|
mock_notification_handler,
|
||||||
|
mock_metrics_controller,
|
||||||
|
):
|
||||||
|
"""Test Cleanup initialization uses default environment values."""
|
||||||
|
import model_manager.activities.cleanup
|
||||||
|
|
||||||
|
reload(model_manager.activities.cleanup)
|
||||||
|
from model_manager.activities.cleanup import Cleanup
|
||||||
|
|
||||||
|
cleanup = Cleanup(
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert cleanup.retention_hours == 24
|
||||||
|
assert cleanup.dry_run is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_init_custom_env_values(
|
||||||
|
mock_logger,
|
||||||
|
mock_notification_handler,
|
||||||
|
mock_metrics_controller,
|
||||||
|
):
|
||||||
|
"""Test Cleanup initialization with custom environment values."""
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
'CLEANUP_RETENTION_HOURS': '48',
|
||||||
|
'CLEANUP_DRY_RUN': 'true',
|
||||||
|
},
|
||||||
|
):
|
||||||
|
import model_manager.activities.cleanup
|
||||||
|
|
||||||
|
reload(model_manager.activities.cleanup)
|
||||||
|
from model_manager.activities.cleanup import Cleanup
|
||||||
|
|
||||||
|
cleanup = Cleanup(
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert cleanup.retention_hours == 48
|
||||||
|
assert cleanup.dry_run is True
|
||||||
|
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {'CLEANUP_RETENTION_HOURS': 'invalid'})
|
||||||
|
def test_cleanup_init_invalid_env_value_raises_error():
|
||||||
|
"""Test Cleanup module raises ValueError for invalid environment variables on import."""
|
||||||
|
import model_manager.activities.cleanup
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
reload(model_manager.activities.cleanup)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Temp Directory Cleanup Tests ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_temp_directories_nonexistent_path(
|
||||||
|
mock_logger,
|
||||||
|
mock_notification_handler,
|
||||||
|
mock_metrics_controller,
|
||||||
|
):
|
||||||
|
"""Test temp directory cleanup with a non-existent path."""
|
||||||
|
from model_manager.activities.cleanup import Cleanup
|
||||||
|
|
||||||
|
cleanup = Cleanup(
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
cleanup.warning = MagicMock()
|
||||||
|
|
||||||
|
cleanup.cleanup_temp_directories({'temp_path': '/nonexistent/path', 'metadata': {}})
|
||||||
|
|
||||||
|
cleanup.warning.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@patch.dict('model_manager.activities.cleanup.os.environ', {'CLEANUP_DRY_RUN': 'false'})
|
||||||
|
def test_cleanup_temp_directories_success_with_deletions(
|
||||||
|
temp_dir,
|
||||||
|
mock_logger,
|
||||||
|
mock_notification_handler,
|
||||||
|
mock_metrics_controller,
|
||||||
|
):
|
||||||
|
"""Test successful deletion of old temporary directories."""
|
||||||
|
import model_manager.activities.cleanup
|
||||||
|
|
||||||
|
reload(model_manager.activities.cleanup)
|
||||||
|
from model_manager.activities.cleanup import Cleanup
|
||||||
|
|
||||||
|
cleanup = Cleanup(
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
old_time = (datetime.now() - timedelta(hours=48)).strftime('%Y%m%d_%H%M%S_000000')
|
||||||
|
old_dir = os.path.join(temp_dir, f'old_dir_{old_time}')
|
||||||
|
os.makedirs(old_dir)
|
||||||
|
|
||||||
|
recent_time = (datetime.now() - timedelta(hours=1)).strftime('%Y%m%d_%H%M%S_000000')
|
||||||
|
recent_dir = os.path.join(temp_dir, f'recent_dir_{recent_time}')
|
||||||
|
os.makedirs(recent_dir)
|
||||||
|
|
||||||
|
cleanup.cleanup_temp_directories({'temp_path': temp_dir, 'metadata': {}})
|
||||||
|
|
||||||
|
assert not os.path.exists(old_dir)
|
||||||
|
assert os.path.exists(recent_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {'CLEANUP_DRY_RUN': 'true'})
|
||||||
|
def test_cleanup_temp_directories_dry_run(
|
||||||
|
temp_dir,
|
||||||
|
mock_logger,
|
||||||
|
mock_notification_handler,
|
||||||
|
mock_metrics_controller,
|
||||||
|
):
|
||||||
|
"""Test temp directory cleanup in dry_run mode does not delete."""
|
||||||
|
import model_manager.activities.cleanup
|
||||||
|
|
||||||
|
reload(model_manager.activities.cleanup)
|
||||||
|
from model_manager.activities.cleanup import Cleanup
|
||||||
|
|
||||||
|
cleanup = Cleanup(
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
old_time = (datetime.now() - timedelta(hours=48)).strftime('%Y%m%d_%H%M%S_000000')
|
||||||
|
old_dir = os.path.join(temp_dir, f'old_dir_{old_time}')
|
||||||
|
os.makedirs(old_dir)
|
||||||
|
|
||||||
|
cleanup.cleanup_temp_directories({'temp_path': temp_dir, 'metadata': {}})
|
||||||
|
|
||||||
|
assert os.path.exists(old_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@patch.dict('model_manager.activities.cleanup.os.environ', {'CLEANUP_DRY_RUN': 'false'})
|
||||||
|
def test_cleanup_temp_directories_delete_error(
|
||||||
|
temp_dir,
|
||||||
|
mock_logger,
|
||||||
|
mock_notification_handler,
|
||||||
|
mock_metrics_controller,
|
||||||
|
):
|
||||||
|
"""Test graceful handling of errors during directory deletion."""
|
||||||
|
import model_manager.activities.cleanup
|
||||||
|
|
||||||
|
reload(model_manager.activities.cleanup)
|
||||||
|
from model_manager.activities.cleanup import Cleanup
|
||||||
|
|
||||||
|
cleanup = Cleanup(
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
cleanup.error = MagicMock()
|
||||||
|
|
||||||
|
old_time = (datetime.now() - timedelta(hours=48)).strftime('%Y%m%d_%H%M%S_000000')
|
||||||
|
old_dir = os.path.join(temp_dir, f'old_dir_{old_time}')
|
||||||
|
os.makedirs(old_dir)
|
||||||
|
|
||||||
|
with patch('shutil.rmtree', side_effect=OSError('Permission Denied')):
|
||||||
|
cleanup.cleanup_temp_directories({'temp_path': temp_dir, 'metadata': {}})
|
||||||
|
|
||||||
|
cleanup.error.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Utility Tests ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_temp_directories_with_files_and_unmatched_dirs(
|
||||||
|
temp_dir,
|
||||||
|
mock_logger,
|
||||||
|
mock_notification_handler,
|
||||||
|
mock_metrics_controller,
|
||||||
|
):
|
||||||
|
"""Test that files and directories with non-matching names are skipped."""
|
||||||
|
import model_manager.activities.cleanup
|
||||||
|
|
||||||
|
reload(model_manager.activities.cleanup)
|
||||||
|
from model_manager.activities.cleanup import Cleanup
|
||||||
|
|
||||||
|
cleanup = Cleanup(
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
cleanup.debug = MagicMock()
|
||||||
|
|
||||||
|
# Create a file and a directory with a non-matching name
|
||||||
|
with open(os.path.join(temp_dir, 'a_file.txt'), 'w') as f:
|
||||||
|
f.write('hello')
|
||||||
|
os.makedirs(os.path.join(temp_dir, 'a_directory_with_no_timestamp'))
|
||||||
|
|
||||||
|
cleanup.cleanup_temp_directories({'temp_path': temp_dir, 'metadata': {}})
|
||||||
|
|
||||||
|
# Ensure the debug message for skipping was called for the unmatched directory
|
||||||
|
cleanup.debug.assert_called_with(
|
||||||
|
'Skipping directory without timestamp pattern: a_directory_with_no_timestamp', {}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_temp_directories_invalid_timestamp_format(
|
||||||
|
temp_dir,
|
||||||
|
mock_logger,
|
||||||
|
mock_notification_handler,
|
||||||
|
mock_metrics_controller,
|
||||||
|
):
|
||||||
|
"""Test that a directory with an invalid timestamp format is handled correctly."""
|
||||||
|
import model_manager.activities.cleanup
|
||||||
|
|
||||||
|
reload(model_manager.activities.cleanup)
|
||||||
|
from model_manager.activities.cleanup import Cleanup
|
||||||
|
|
||||||
|
cleanup = Cleanup(
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
cleanup.error = MagicMock()
|
||||||
|
|
||||||
|
# Create a directory with a malformed timestamp that matches the regex but fails parsing
|
||||||
|
malformed_dir_name = 'dir_20239999_999999_999999'
|
||||||
|
os.makedirs(os.path.join(temp_dir, malformed_dir_name))
|
||||||
|
|
||||||
|
cleanup.cleanup_temp_directories({'temp_path': temp_dir, 'metadata': {}})
|
||||||
|
|
||||||
|
cleanup.error.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_temp_directories_generic_exception(
|
||||||
|
temp_dir,
|
||||||
|
mock_logger,
|
||||||
|
mock_notification_handler,
|
||||||
|
mock_metrics_controller,
|
||||||
|
):
|
||||||
|
"""Test that a generic exception during directory cleanup is handled."""
|
||||||
|
import model_manager.activities.cleanup
|
||||||
|
|
||||||
|
reload(model_manager.activities.cleanup)
|
||||||
|
from model_manager.activities.cleanup import Cleanup
|
||||||
|
|
||||||
|
cleanup = Cleanup(
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
cleanup.send_notification = MagicMock()
|
||||||
|
|
||||||
|
with patch('os.listdir', side_effect=Exception('Unexpected OS Error')):
|
||||||
|
with pytest.raises(Exception, match='Unexpected OS Error'):
|
||||||
|
cleanup.cleanup_temp_directories({'temp_path': temp_dir, 'metadata': {}})
|
||||||
|
|
||||||
|
cleanup.send_notification.assert_called_once()
|
||||||
643
tests/activities/test_experiment_tracking.py
Normal file
643
tests/activities/test_experiment_tracking.py
Normal file
@@ -0,0 +1,643 @@
|
|||||||
|
"""Unit tests for ExperimentTracking class with 100% coverage."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_logger():
|
||||||
|
"""Create a mock logger."""
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_notification_handler():
|
||||||
|
"""Create a mock notification handler."""
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_metrics_controller():
|
||||||
|
"""Create a mock metrics controller."""
|
||||||
|
controller = MagicMock()
|
||||||
|
controller.shutdown = AsyncMock()
|
||||||
|
controller.emit = AsyncMock()
|
||||||
|
return controller
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_config():
|
||||||
|
"""Create a valid database configuration."""
|
||||||
|
return {
|
||||||
|
'host': 'localhost',
|
||||||
|
'port': 5432,
|
||||||
|
'user': 'testuser',
|
||||||
|
'password': 'testpass',
|
||||||
|
'dbname': 'testdb',
|
||||||
|
'min_connections': 1,
|
||||||
|
'max_connections': 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_experiment_tracking_init(
|
||||||
|
db_config, mock_logger, mock_notification_handler, mock_metrics_controller
|
||||||
|
):
|
||||||
|
"""Test ExperimentTracking initialization."""
|
||||||
|
from model_manager.activities.experiment_tracking import ExperimentTracking
|
||||||
|
|
||||||
|
ExperimentTracking(
|
||||||
|
host=db_config['host'],
|
||||||
|
port=db_config['port'],
|
||||||
|
user=db_config['user'],
|
||||||
|
password=db_config['password'],
|
||||||
|
dbname=db_config['dbname'],
|
||||||
|
min_connections=db_config['min_connections'],
|
||||||
|
max_connections=db_config['max_connections'],
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_experiment_tracking_del_without_engine(
|
||||||
|
db_config, mock_logger, mock_notification_handler, mock_metrics_controller
|
||||||
|
):
|
||||||
|
"""Test __del__ when engine attribute does not exist."""
|
||||||
|
from model_manager.activities.experiment_tracking import ExperimentTracking
|
||||||
|
|
||||||
|
et = ExperimentTracking(
|
||||||
|
host=db_config['host'],
|
||||||
|
port=db_config['port'],
|
||||||
|
user=db_config['user'],
|
||||||
|
password=db_config['password'],
|
||||||
|
dbname=db_config['dbname'],
|
||||||
|
min_connections=db_config['min_connections'],
|
||||||
|
max_connections=db_config['max_connections'],
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasattr(et, 'engine'):
|
||||||
|
delattr(et, 'engine')
|
||||||
|
|
||||||
|
et.__del__()
|
||||||
|
|
||||||
|
|
||||||
|
def test_experiment_tracking_del_with_engine(
|
||||||
|
db_config, mock_logger, mock_notification_handler, mock_metrics_controller
|
||||||
|
):
|
||||||
|
"""Test __del__ when engine exists."""
|
||||||
|
from model_manager.activities.experiment_tracking import ExperimentTracking
|
||||||
|
|
||||||
|
et = ExperimentTracking(
|
||||||
|
host=db_config['host'],
|
||||||
|
port=db_config['port'],
|
||||||
|
user=db_config['user'],
|
||||||
|
password=db_config['password'],
|
||||||
|
dbname=db_config['dbname'],
|
||||||
|
min_connections=db_config['min_connections'],
|
||||||
|
max_connections=db_config['max_connections'],
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
et.engine = MagicMock()
|
||||||
|
|
||||||
|
class MockSuper:
|
||||||
|
def __del__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch('builtins.super', return_value=MockSuper()):
|
||||||
|
et.__del__()
|
||||||
|
|
||||||
|
|
||||||
|
def test_experiment_tracking_del_with_engine_exception(
|
||||||
|
db_config, mock_logger, mock_notification_handler, mock_metrics_controller
|
||||||
|
):
|
||||||
|
"""Test __del__ catches exceptions."""
|
||||||
|
from model_manager.activities.experiment_tracking import ExperimentTracking
|
||||||
|
|
||||||
|
et = ExperimentTracking(
|
||||||
|
host=db_config['host'],
|
||||||
|
port=db_config['port'],
|
||||||
|
user=db_config['user'],
|
||||||
|
password=db_config['password'],
|
||||||
|
dbname=db_config['dbname'],
|
||||||
|
min_connections=db_config['min_connections'],
|
||||||
|
max_connections=db_config['max_connections'],
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
et.engine = MagicMock()
|
||||||
|
|
||||||
|
class MockSuperWithError:
|
||||||
|
_should_raise: bool
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._should_raise = False
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
# Only raise error if not being cleaned up by garbage collector
|
||||||
|
# This prevents the PytestUnraisableExceptionWarning
|
||||||
|
if hasattr(self, '_should_raise') and self._should_raise:
|
||||||
|
raise RuntimeError('Test error')
|
||||||
|
|
||||||
|
# Suppress the PytestUnraisableExceptionWarning for this specific test
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
warnings.filterwarnings('ignore', category=pytest.PytestUnraisableExceptionWarning)
|
||||||
|
|
||||||
|
mock_super = MockSuperWithError()
|
||||||
|
mock_super._should_raise = True
|
||||||
|
try:
|
||||||
|
with patch('builtins.super', return_value=mock_super):
|
||||||
|
et.__del__()
|
||||||
|
finally:
|
||||||
|
# Prevent the exception from being raised during garbage collection
|
||||||
|
mock_super._should_raise = False
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_update_success(
|
||||||
|
db_config, mock_logger, mock_notification_handler, mock_metrics_controller
|
||||||
|
):
|
||||||
|
"""Test _execute_update executes query successfully."""
|
||||||
|
from model_manager.activities.experiment_tracking import ExperimentTracking
|
||||||
|
|
||||||
|
et = ExperimentTracking(
|
||||||
|
host=db_config['host'],
|
||||||
|
port=db_config['port'],
|
||||||
|
user=db_config['user'],
|
||||||
|
password=db_config['password'],
|
||||||
|
dbname=db_config['dbname'],
|
||||||
|
min_connections=db_config['min_connections'],
|
||||||
|
max_connections=db_config['max_connections'],
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_connection = MagicMock()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.rowcount = 1
|
||||||
|
mock_connection.execute.return_value = mock_result
|
||||||
|
mock_engine = MagicMock()
|
||||||
|
mock_engine.begin.return_value.__enter__.return_value = mock_connection
|
||||||
|
et.engine = mock_engine
|
||||||
|
|
||||||
|
result = et._execute_update('UPDATE test SET x = :x', {'x': 1})
|
||||||
|
|
||||||
|
assert result == {'rowcount': 1}
|
||||||
|
mock_connection.execute.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_experiment_run_status_success(
|
||||||
|
db_config, mock_logger, mock_notification_handler, mock_metrics_controller
|
||||||
|
):
|
||||||
|
"""Test update_experiment_run with STATUS update type."""
|
||||||
|
from model_manager.activities.experiment_tracking import ExperimentTracking, UpdateType
|
||||||
|
|
||||||
|
et = ExperimentTracking(
|
||||||
|
host=db_config['host'],
|
||||||
|
port=db_config['port'],
|
||||||
|
user=db_config['user'],
|
||||||
|
password=db_config['password'],
|
||||||
|
dbname=db_config['dbname'],
|
||||||
|
min_connections=db_config['min_connections'],
|
||||||
|
max_connections=db_config['max_connections'],
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_execute = MagicMock()
|
||||||
|
|
||||||
|
def mock_execute_update(*args, **kwargs):
|
||||||
|
mock_execute(*args, **kwargs)
|
||||||
|
return {'rowcount': 1}
|
||||||
|
|
||||||
|
et._execute_update = mock_execute_update # type: ignore[method-assign]
|
||||||
|
et.info = MagicMock()
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
'metadata': {'workflow_id': 'test-123'},
|
||||||
|
'experiment_run_id': 1,
|
||||||
|
'update_type': UpdateType.STATUS,
|
||||||
|
'status': 'running',
|
||||||
|
}
|
||||||
|
|
||||||
|
et.update_experiment_run(input_data)
|
||||||
|
|
||||||
|
mock_execute.assert_called_once()
|
||||||
|
call_args = mock_execute.call_args
|
||||||
|
assert 'status' in call_args[0][1]
|
||||||
|
assert call_args[0][1]['status'] == 'running'
|
||||||
|
assert call_args[0][1]['experiment_run_id'] == 1
|
||||||
|
et.info.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_experiment_run_status_missing_status(
|
||||||
|
db_config, mock_logger, mock_notification_handler, mock_metrics_controller
|
||||||
|
):
|
||||||
|
"""Test update_experiment_run with STATUS but missing status parameter."""
|
||||||
|
from model_manager.activities.experiment_tracking import ExperimentTracking, UpdateType
|
||||||
|
|
||||||
|
et = ExperimentTracking(
|
||||||
|
host=db_config['host'],
|
||||||
|
port=db_config['port'],
|
||||||
|
user=db_config['user'],
|
||||||
|
password=db_config['password'],
|
||||||
|
dbname=db_config['dbname'],
|
||||||
|
min_connections=db_config['min_connections'],
|
||||||
|
max_connections=db_config['max_connections'],
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
et.send_notification = MagicMock()
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
'metadata': {'workflow_id': 'test-123'},
|
||||||
|
'experiment_run_id': 1,
|
||||||
|
'update_type': UpdateType.STATUS,
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
et.update_experiment_run(input_data)
|
||||||
|
|
||||||
|
et.send_notification.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_experiment_run_status_with_error_success(
|
||||||
|
db_config, mock_logger, mock_notification_handler, mock_metrics_controller
|
||||||
|
):
|
||||||
|
"""Test update_experiment_run with STATUS_WITH_ERROR update type."""
|
||||||
|
from model_manager.activities.experiment_tracking import ExperimentTracking, UpdateType
|
||||||
|
|
||||||
|
et = ExperimentTracking(
|
||||||
|
host=db_config['host'],
|
||||||
|
port=db_config['port'],
|
||||||
|
user=db_config['user'],
|
||||||
|
password=db_config['password'],
|
||||||
|
dbname=db_config['dbname'],
|
||||||
|
min_connections=db_config['min_connections'],
|
||||||
|
max_connections=db_config['max_connections'],
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_execute = MagicMock()
|
||||||
|
|
||||||
|
def mock_execute_update(*args, **kwargs):
|
||||||
|
mock_execute(*args, **kwargs)
|
||||||
|
return {'rowcount': 1}
|
||||||
|
|
||||||
|
et._execute_update = mock_execute_update # type: ignore[method-assign]
|
||||||
|
et.info = MagicMock()
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
'metadata': {'workflow_id': 'test-123'},
|
||||||
|
'experiment_run_id': 1,
|
||||||
|
'update_type': UpdateType.STATUS_WITH_ERROR,
|
||||||
|
'status': 'failed',
|
||||||
|
'error_message': 'Test error',
|
||||||
|
}
|
||||||
|
|
||||||
|
et.update_experiment_run(input_data)
|
||||||
|
|
||||||
|
mock_execute.assert_called_once()
|
||||||
|
call_args = mock_execute.call_args
|
||||||
|
assert 'status' in call_args[0][1]
|
||||||
|
assert call_args[0][1]['status'] == 'failed'
|
||||||
|
assert call_args[0][1]['error_message'] == 'Test error'
|
||||||
|
et.info.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_experiment_run_status_with_error_truncate_message(
|
||||||
|
db_config, mock_logger, mock_notification_handler, mock_metrics_controller
|
||||||
|
):
|
||||||
|
"""Test update_experiment_run truncates error message if too long."""
|
||||||
|
from model_manager.activities.experiment_tracking import ExperimentTracking, UpdateType
|
||||||
|
|
||||||
|
et = ExperimentTracking(
|
||||||
|
host=db_config['host'],
|
||||||
|
port=db_config['port'],
|
||||||
|
user=db_config['user'],
|
||||||
|
password=db_config['password'],
|
||||||
|
dbname=db_config['dbname'],
|
||||||
|
min_connections=db_config['min_connections'],
|
||||||
|
max_connections=db_config['max_connections'],
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_execute = MagicMock()
|
||||||
|
|
||||||
|
def mock_execute_update(*args, **kwargs):
|
||||||
|
mock_execute(*args, **kwargs)
|
||||||
|
return {'rowcount': 1}
|
||||||
|
|
||||||
|
et._execute_update = mock_execute_update # type: ignore[method-assign]
|
||||||
|
et.info = MagicMock()
|
||||||
|
|
||||||
|
long_error = 'x' * 2000
|
||||||
|
input_data = {
|
||||||
|
'metadata': {'workflow_id': 'test-123'},
|
||||||
|
'experiment_run_id': 1,
|
||||||
|
'update_type': UpdateType.STATUS_WITH_ERROR,
|
||||||
|
'status': 'failed',
|
||||||
|
'error_message': long_error,
|
||||||
|
}
|
||||||
|
|
||||||
|
et.update_experiment_run(input_data)
|
||||||
|
|
||||||
|
call_args = mock_execute.call_args
|
||||||
|
assert len(call_args[0][1]['error_message']) == 1024
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_experiment_run_status_with_error_missing_error_message(
|
||||||
|
db_config, mock_logger, mock_notification_handler, mock_metrics_controller
|
||||||
|
):
|
||||||
|
"""Test update_experiment_run with STATUS_WITH_ERROR but missing error_message."""
|
||||||
|
from model_manager.activities.experiment_tracking import ExperimentTracking, UpdateType
|
||||||
|
|
||||||
|
et = ExperimentTracking(
|
||||||
|
host=db_config['host'],
|
||||||
|
port=db_config['port'],
|
||||||
|
user=db_config['user'],
|
||||||
|
password=db_config['password'],
|
||||||
|
dbname=db_config['dbname'],
|
||||||
|
min_connections=db_config['min_connections'],
|
||||||
|
max_connections=db_config['max_connections'],
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
et.send_notification = MagicMock()
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
'metadata': {'workflow_id': 'test-123'},
|
||||||
|
'experiment_run_id': 1,
|
||||||
|
'update_type': UpdateType.STATUS_WITH_ERROR,
|
||||||
|
'status': 'failed',
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
et.update_experiment_run(input_data)
|
||||||
|
|
||||||
|
et.send_notification.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_experiment_run_model_saved_success(
|
||||||
|
db_config, mock_logger, mock_notification_handler, mock_metrics_controller
|
||||||
|
):
|
||||||
|
"""Test update_experiment_run with MODEL_SAVED update type."""
|
||||||
|
from model_manager.activities.experiment_tracking import ExperimentTracking, UpdateType
|
||||||
|
|
||||||
|
et = ExperimentTracking(
|
||||||
|
host=db_config['host'],
|
||||||
|
port=db_config['port'],
|
||||||
|
user=db_config['user'],
|
||||||
|
password=db_config['password'],
|
||||||
|
dbname=db_config['dbname'],
|
||||||
|
min_connections=db_config['min_connections'],
|
||||||
|
max_connections=db_config['max_connections'],
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_execute = MagicMock()
|
||||||
|
|
||||||
|
def mock_execute_update(*args, **kwargs):
|
||||||
|
mock_execute(*args, **kwargs)
|
||||||
|
return {'rowcount': 1}
|
||||||
|
|
||||||
|
et._execute_update = mock_execute_update # type: ignore[method-assign]
|
||||||
|
et.info = MagicMock()
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
'metadata': {'workflow_id': 'test-123'},
|
||||||
|
'experiment_run_id': 1,
|
||||||
|
'update_type': UpdateType.MODEL_SAVED,
|
||||||
|
'status': 'completed',
|
||||||
|
'run_name': 'run_001',
|
||||||
|
}
|
||||||
|
|
||||||
|
et.update_experiment_run(input_data)
|
||||||
|
|
||||||
|
mock_execute.assert_called_once()
|
||||||
|
call_args = mock_execute.call_args
|
||||||
|
assert 'run_name' in call_args[0][1]
|
||||||
|
assert call_args[0][1]['run_name'] == 'run_001'
|
||||||
|
assert call_args[0][1]['status'] == 'completed'
|
||||||
|
et.info.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_experiment_run_model_saved_missing_run_name(
|
||||||
|
db_config, mock_logger, mock_notification_handler, mock_metrics_controller
|
||||||
|
):
|
||||||
|
"""Test update_experiment_run with MODEL_SAVED but missing run_name."""
|
||||||
|
from model_manager.activities.experiment_tracking import ExperimentTracking, UpdateType
|
||||||
|
|
||||||
|
et = ExperimentTracking(
|
||||||
|
host=db_config['host'],
|
||||||
|
port=db_config['port'],
|
||||||
|
user=db_config['user'],
|
||||||
|
password=db_config['password'],
|
||||||
|
dbname=db_config['dbname'],
|
||||||
|
min_connections=db_config['min_connections'],
|
||||||
|
max_connections=db_config['max_connections'],
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
et.send_notification = MagicMock()
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
'metadata': {'workflow_id': 'test-123'},
|
||||||
|
'experiment_run_id': 1,
|
||||||
|
'update_type': UpdateType.MODEL_SAVED,
|
||||||
|
'status': 'completed',
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
et.update_experiment_run(input_data)
|
||||||
|
|
||||||
|
et.send_notification.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_experiment_run_invalid_update_type(
|
||||||
|
db_config, mock_logger, mock_notification_handler, mock_metrics_controller
|
||||||
|
):
|
||||||
|
"""Test update_experiment_run with invalid update_type."""
|
||||||
|
from model_manager.activities.experiment_tracking import ExperimentTracking
|
||||||
|
|
||||||
|
et = ExperimentTracking(
|
||||||
|
host=db_config['host'],
|
||||||
|
port=db_config['port'],
|
||||||
|
user=db_config['user'],
|
||||||
|
password=db_config['password'],
|
||||||
|
dbname=db_config['dbname'],
|
||||||
|
min_connections=db_config['min_connections'],
|
||||||
|
max_connections=db_config['max_connections'],
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
et.send_notification = MagicMock()
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
'metadata': {'workflow_id': 'test-123'},
|
||||||
|
'experiment_run_id': 1,
|
||||||
|
'update_type': 'invalid_type',
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
et.update_experiment_run(input_data)
|
||||||
|
|
||||||
|
et.send_notification.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_experiment_run_no_rows_updated(
|
||||||
|
db_config, mock_logger, mock_notification_handler, mock_metrics_controller
|
||||||
|
):
|
||||||
|
"""Test update_experiment_run raises error when no rows are updated."""
|
||||||
|
from model_manager.activities.experiment_tracking import ExperimentTracking, UpdateType
|
||||||
|
|
||||||
|
et = ExperimentTracking(
|
||||||
|
host=db_config['host'],
|
||||||
|
port=db_config['port'],
|
||||||
|
user=db_config['user'],
|
||||||
|
password=db_config['password'],
|
||||||
|
dbname=db_config['dbname'],
|
||||||
|
min_connections=db_config['min_connections'],
|
||||||
|
max_connections=db_config['max_connections'],
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
def mock_execute_update(*args, **kwargs):
|
||||||
|
return {'rowcount': 0}
|
||||||
|
|
||||||
|
et._execute_update = mock_execute_update # type: ignore[method-assign]
|
||||||
|
et.send_notification = MagicMock()
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
'metadata': {'workflow_id': 'test-123'},
|
||||||
|
'experiment_run_id': 999,
|
||||||
|
'update_type': UpdateType.STATUS,
|
||||||
|
'status': 'running',
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
et.update_experiment_run(input_data)
|
||||||
|
|
||||||
|
et.send_notification.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_experiment_run_status_with_error_missing_status(
|
||||||
|
db_config, mock_logger, mock_notification_handler, mock_metrics_controller
|
||||||
|
):
|
||||||
|
"""Test update_experiment_run with STATUS_WITH_ERROR but missing status - covers line 179."""
|
||||||
|
from model_manager.activities.experiment_tracking import ExperimentTracking, UpdateType
|
||||||
|
|
||||||
|
et = ExperimentTracking(
|
||||||
|
host=db_config['host'],
|
||||||
|
port=db_config['port'],
|
||||||
|
user=db_config['user'],
|
||||||
|
password=db_config['password'],
|
||||||
|
dbname=db_config['dbname'],
|
||||||
|
min_connections=db_config['min_connections'],
|
||||||
|
max_connections=db_config['max_connections'],
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
et.send_notification = MagicMock()
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
'metadata': {'workflow_id': 'test-123'},
|
||||||
|
'experiment_run_id': 1,
|
||||||
|
'update_type': UpdateType.STATUS_WITH_ERROR,
|
||||||
|
'error_message': 'Some error',
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
et.update_experiment_run(input_data)
|
||||||
|
|
||||||
|
et.send_notification.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_experiment_run_model_saved_missing_status(
|
||||||
|
db_config, mock_logger, mock_notification_handler, mock_metrics_controller
|
||||||
|
):
|
||||||
|
"""Test update_experiment_run with MODEL_SAVED but missing status - covers line 204."""
|
||||||
|
from model_manager.activities.experiment_tracking import ExperimentTracking, UpdateType
|
||||||
|
|
||||||
|
et = ExperimentTracking(
|
||||||
|
host=db_config['host'],
|
||||||
|
port=db_config['port'],
|
||||||
|
user=db_config['user'],
|
||||||
|
password=db_config['password'],
|
||||||
|
dbname=db_config['dbname'],
|
||||||
|
min_connections=db_config['min_connections'],
|
||||||
|
max_connections=db_config['max_connections'],
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
et.send_notification = MagicMock()
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
'metadata': {'workflow_id': 'test-123'},
|
||||||
|
'experiment_run_id': 1,
|
||||||
|
'update_type': UpdateType.MODEL_SAVED,
|
||||||
|
'run_name': 'run_001',
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
et.update_experiment_run(input_data)
|
||||||
|
|
||||||
|
et.send_notification.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_experiment_tracking_del_with_engine_no_super_del(
|
||||||
|
db_config, mock_logger, mock_notification_handler, mock_metrics_controller
|
||||||
|
):
|
||||||
|
"""Test __del__ when engine exists but super has no __del__ - covers line 103."""
|
||||||
|
from model_manager.activities.experiment_tracking import ExperimentTracking
|
||||||
|
|
||||||
|
et = ExperimentTracking(
|
||||||
|
host=db_config['host'],
|
||||||
|
port=db_config['port'],
|
||||||
|
user=db_config['user'],
|
||||||
|
password=db_config['password'],
|
||||||
|
dbname=db_config['dbname'],
|
||||||
|
min_connections=db_config['min_connections'],
|
||||||
|
max_connections=db_config['max_connections'],
|
||||||
|
logger=mock_logger,
|
||||||
|
notification_handler=mock_notification_handler,
|
||||||
|
metrics_controller=mock_metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
et.engine = MagicMock()
|
||||||
|
|
||||||
|
class MockSuperNoDel:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch('builtins.super', return_value=MockSuperNoDel()):
|
||||||
|
et.__del__()
|
||||||
630
tests/activities/test_training.py
Normal file
630
tests/activities/test_training.py
Normal file
@@ -0,0 +1,630 @@
|
|||||||
|
"""Unit tests for Training activities."""
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from model_manager.utils.models.train_model_params import TrainModelParams
|
||||||
|
from model_manager.utils.models.train_model_result import TrainModelResult
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_params_dict():
|
||||||
|
return {
|
||||||
|
'variable_columns': ['a'],
|
||||||
|
'target_variable': 't',
|
||||||
|
'bucket_name': 'b',
|
||||||
|
'file_name': 'f.csv',
|
||||||
|
'line_separator': '\n',
|
||||||
|
'decimal_separator': '.',
|
||||||
|
'date_column': 'timestamp',
|
||||||
|
'date_format': 'yyyy-MM-dd HH:mm:ss',
|
||||||
|
'train_size': 80,
|
||||||
|
'shuffle': True,
|
||||||
|
'random_state': 42,
|
||||||
|
'experiment_run_id': 1,
|
||||||
|
'model_name': 'Linear Regression',
|
||||||
|
'val_file_name': None,
|
||||||
|
'data_model_kwargs': {},
|
||||||
|
'model_kwargs': {},
|
||||||
|
'opt_params': {},
|
||||||
|
'model_type': 'linear_regression',
|
||||||
|
'model_id': None,
|
||||||
|
'model_metadata': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def training():
|
||||||
|
from model_manager.activities.training import Training
|
||||||
|
|
||||||
|
return Training(
|
||||||
|
mlflow_repository=MagicMock(),
|
||||||
|
plugin_store=MagicMock(),
|
||||||
|
minio_repository=MagicMock(),
|
||||||
|
logger=MagicMock(),
|
||||||
|
notification_handler=MagicMock(),
|
||||||
|
metrics_controller=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_model_metadata_success(training):
|
||||||
|
training.plugin_store.get_model_index = MagicMock(
|
||||||
|
return_value={'schemas': {'components': {'schemas': {}}}}
|
||||||
|
)
|
||||||
|
inp = {**_minimal_params_dict(), 'metadata': {'w': '1'}}
|
||||||
|
out = training.load_model_metadata(inp)
|
||||||
|
assert 'model_metadata' in out
|
||||||
|
assert out['model_metadata']['schemas']
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_model_metadata_notifies_on_error(training):
|
||||||
|
training.plugin_store.get_model_index = MagicMock(side_effect=RuntimeError('idx'))
|
||||||
|
training.send_notification = MagicMock()
|
||||||
|
inp = {**_minimal_params_dict(), 'metadata': {}}
|
||||||
|
with pytest.raises(RuntimeError, match='idx'):
|
||||||
|
training.load_model_metadata(inp)
|
||||||
|
training.send_notification.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_train_params_success(training):
|
||||||
|
pdict = _minimal_params_dict()
|
||||||
|
pdict['model_metadata'] = {'schemas': {'components': {'schemas': {}}}}
|
||||||
|
inp = {**pdict, 'metadata': {}}
|
||||||
|
out = training.validate_train_params(inp)
|
||||||
|
assert isinstance(out, dict)
|
||||||
|
assert out['target_variable'] == 't'
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_train_params_notifies(training):
|
||||||
|
training.send_notification = MagicMock()
|
||||||
|
inp = {'metadata': {}, 'experiment_run_id': 1}
|
||||||
|
with pytest.raises((KeyError, ValueError, TypeError)):
|
||||||
|
training.validate_train_params(inp)
|
||||||
|
training.send_notification.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_train_model_download_fails_notifies(training):
|
||||||
|
"""train_model notifies and re-raises when MinIO download fails."""
|
||||||
|
tp = TrainModelParams.from_dict(
|
||||||
|
{
|
||||||
|
**_minimal_params_dict(),
|
||||||
|
'model_metadata': {'schemas': {'components': {'schemas': {}}}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
training.minio_repository.download_file = MagicMock(side_effect=OSError('minio'))
|
||||||
|
training.send_notification = MagicMock()
|
||||||
|
with pytest.raises(OSError, match='minio'):
|
||||||
|
training.train_model({'metadata': {'pod': 'x'}, 'train_params': tp.to_dict()})
|
||||||
|
training.send_notification.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_resources(training):
|
||||||
|
training.data_manager_repository.cleanup_run_directory = MagicMock()
|
||||||
|
training.cleanup_resources({'metadata': {}, 'run_dir': '/tmp/x'})
|
||||||
|
training.data_manager_repository.cleanup_run_directory.assert_called_once_with('/tmp/x', {})
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_resources_notifies_on_error(training):
|
||||||
|
training.data_manager_repository.cleanup_run_directory = MagicMock(
|
||||||
|
side_effect=RuntimeError('rm')
|
||||||
|
)
|
||||||
|
training.send_notification = MagicMock()
|
||||||
|
with pytest.raises(RuntimeError, match='rm'):
|
||||||
|
training.cleanup_resources({'metadata': {'pod': 'p'}, 'run_dir': '/tmp/x'})
|
||||||
|
training.send_notification.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@patch('model_manager.activities.training.mlflow')
|
||||||
|
def test_train_model_success_serializes_result(mock_mlflow, training):
|
||||||
|
"""Exercise train_model happy path with mocks (MinIO, plugin wrapper, MLflow)."""
|
||||||
|
tp = TrainModelParams.from_dict(
|
||||||
|
{
|
||||||
|
**_minimal_params_dict(),
|
||||||
|
'model_metadata': {'schemas': {'components': {'schemas': {}}}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
train_df = pd.DataFrame({'a': [1.0, 2.0], 't': [1.0, 2.0]})
|
||||||
|
val_df = pd.DataFrame({'a': [1.0], 't': [1.0]})
|
||||||
|
tmr = TrainModelResult(params=tp, train_data=train_df, val_data=val_df)
|
||||||
|
|
||||||
|
training.minio_repository.download_file = MagicMock(return_value=b'csv')
|
||||||
|
training.data_manager_repository.prepare_training_data = MagicMock(return_value=tmr)
|
||||||
|
|
||||||
|
def _set_metrics(x, _w, **_kw):
|
||||||
|
x.mse_val = 0.1
|
||||||
|
x.mae_val = 0.2
|
||||||
|
x.r2_val = 0.9
|
||||||
|
return x
|
||||||
|
|
||||||
|
training.data_manager_repository.compute_regression_metrics = MagicMock(
|
||||||
|
side_effect=_set_metrics
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fill_report(x, **_kw):
|
||||||
|
x.report_path = '/tmp/report.html'
|
||||||
|
x.train_data_path = '/tmp/train.csv'
|
||||||
|
x.test_data_path = '/tmp/test.csv'
|
||||||
|
x.run_dir = '/tmp/run'
|
||||||
|
return x
|
||||||
|
|
||||||
|
training.data_manager_repository.generate_report = MagicMock(side_effect=_fill_report)
|
||||||
|
|
||||||
|
wrapper = MagicMock()
|
||||||
|
wrapper.transform = MagicMock(side_effect=[(train_df, None), (val_df, None)])
|
||||||
|
pred_train = pd.DataFrame({'p': [1.0, 2.0]})
|
||||||
|
pred_val = pd.DataFrame({'p': [1.0]})
|
||||||
|
wrapper.predict = MagicMock(side_effect=[(pred_train, None), (pred_val, None)])
|
||||||
|
wrapper.store_model = MagicMock()
|
||||||
|
training.plugin_store.get_model = MagicMock(return_value=wrapper)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _run_ctx(*_a, **_k):
|
||||||
|
info = MagicMock()
|
||||||
|
info.run_name = 'run-n'
|
||||||
|
info.run_id = 'run-i'
|
||||||
|
yield info
|
||||||
|
|
||||||
|
training.mlflow_repository.start_run = _run_ctx
|
||||||
|
|
||||||
|
out = training.train_model({'metadata': {'pod': 'p'}, 'train_params': tp.to_dict()})
|
||||||
|
assert out['run_name'] is None
|
||||||
|
assert out['run_id'] == 'run-i'
|
||||||
|
assert out['run_dir'] == '/tmp/run'
|
||||||
|
mock_mlflow.log_param.assert_any_call('mse_val', 0.1)
|
||||||
|
mock_mlflow.log_param.assert_any_call('mae_val', 0.2)
|
||||||
|
mock_mlflow.log_param.assert_any_call('r2_val', 0.9)
|
||||||
|
mock_mlflow.log_artifact.assert_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch('model_manager.activities.training.mlflow')
|
||||||
|
def test_train_model_without_logger_does_not_set_wrapper_logger(_mock_mlflow, training):
|
||||||
|
"""Covers branch where activity logger is None."""
|
||||||
|
training.logger = None
|
||||||
|
tp = TrainModelParams.from_dict(
|
||||||
|
{
|
||||||
|
**_minimal_params_dict(),
|
||||||
|
'model_metadata': {'schemas': {'components': {'schemas': {}}}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
train_df = pd.DataFrame({'a': [1.0, 2.0], 't': [1.0, 2.0]})
|
||||||
|
val_df = pd.DataFrame({'a': [1.0], 't': [1.0]})
|
||||||
|
tmr = TrainModelResult(params=tp, train_data=train_df, val_data=val_df)
|
||||||
|
|
||||||
|
training.minio_repository.download_file = MagicMock(return_value=b'csv')
|
||||||
|
training.data_manager_repository.prepare_training_data = MagicMock(return_value=tmr)
|
||||||
|
training.data_manager_repository.compute_regression_metrics = MagicMock(
|
||||||
|
side_effect=lambda x, _w, **_kw: x
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fill_report(x, **_kw):
|
||||||
|
x.report_path = '/tmp/report.html'
|
||||||
|
x.train_data_path = '/tmp/train.csv'
|
||||||
|
x.test_data_path = '/tmp/test.csv'
|
||||||
|
x.equation_path = '/tmp/eq.json'
|
||||||
|
x.run_dir = '/tmp/run'
|
||||||
|
return x
|
||||||
|
|
||||||
|
training.data_manager_repository.generate_report = MagicMock(side_effect=_fill_report)
|
||||||
|
wrapper = MagicMock()
|
||||||
|
wrapper.transform = MagicMock(side_effect=[(train_df, None), (val_df, None)])
|
||||||
|
wrapper.predict = MagicMock(
|
||||||
|
side_effect=[(pd.DataFrame({'p': [1.0, 2.0]}), None), (pd.DataFrame({'p': [1.0]}), None)]
|
||||||
|
)
|
||||||
|
wrapper.store_model = MagicMock()
|
||||||
|
training.plugin_store.get_model = MagicMock(return_value=wrapper)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _run_ctx(*_a, **_k):
|
||||||
|
info = MagicMock()
|
||||||
|
info.run_name = 'n'
|
||||||
|
info.run_id = 'i'
|
||||||
|
yield info
|
||||||
|
|
||||||
|
training.mlflow_repository.start_run = _run_ctx
|
||||||
|
|
||||||
|
training.train_model({'metadata': {}, 'train_params': tp.to_dict()})
|
||||||
|
|
||||||
|
|
||||||
|
@patch('model_manager.activities.training.mlflow')
|
||||||
|
def test_train_model_train_params_as_dict(mock_mlflow, training):
|
||||||
|
"""train_params may arrive as dict and is coerced via TrainModelParams.from_dict."""
|
||||||
|
d = {
|
||||||
|
**_minimal_params_dict(),
|
||||||
|
'model_metadata': {'schemas': {'components': {'schemas': {}}}},
|
||||||
|
}
|
||||||
|
train_df = pd.DataFrame({'a': [1.0, 2.0], 't': [1.0, 2.0]})
|
||||||
|
val_df = pd.DataFrame({'a': [1.0], 't': [1.0]})
|
||||||
|
tp = TrainModelParams.from_dict(d)
|
||||||
|
tmr = TrainModelResult(params=tp, train_data=train_df, val_data=val_df)
|
||||||
|
|
||||||
|
training.minio_repository.download_file = MagicMock(return_value=b'csv')
|
||||||
|
training.data_manager_repository.prepare_training_data = MagicMock(return_value=tmr)
|
||||||
|
training.data_manager_repository.compute_regression_metrics = MagicMock(
|
||||||
|
side_effect=lambda x, _w, **_kw: x
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fill_report2(x, **_kw):
|
||||||
|
x.report_path = '/tmp/report.html'
|
||||||
|
x.train_data_path = '/tmp/train.csv'
|
||||||
|
x.test_data_path = '/tmp/test.csv'
|
||||||
|
x.run_dir = '/tmp/run'
|
||||||
|
return x
|
||||||
|
|
||||||
|
training.data_manager_repository.generate_report = MagicMock(side_effect=_fill_report2)
|
||||||
|
wrapper = MagicMock()
|
||||||
|
wrapper.transform = MagicMock(side_effect=[(train_df, None), (val_df, None)])
|
||||||
|
wrapper.predict = MagicMock(
|
||||||
|
side_effect=[(pd.DataFrame({'p': [1.0, 2.0]}), None), (pd.DataFrame({'p': [1.0]}), None)]
|
||||||
|
)
|
||||||
|
wrapper.store_model = MagicMock()
|
||||||
|
training.plugin_store.get_model = MagicMock(return_value=wrapper)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _run_ctx(*_a, **_k):
|
||||||
|
info = MagicMock()
|
||||||
|
info.run_name = 'n'
|
||||||
|
info.run_id = 'i'
|
||||||
|
yield info
|
||||||
|
|
||||||
|
training.mlflow_repository.start_run = _run_ctx
|
||||||
|
|
||||||
|
training.train_model({'metadata': {}, 'train_params': d})
|
||||||
|
mock_mlflow.log_artifact.assert_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch('model_manager.activities.training.mlflow')
|
||||||
|
def test_train_model_downloads_validation_file_when_set(mock_mlflow, training):
|
||||||
|
"""Second MinIO download when val_file_name is set (covers val_bytes branch)."""
|
||||||
|
d = {
|
||||||
|
**_minimal_params_dict(),
|
||||||
|
'val_file_name': 'val.csv',
|
||||||
|
'model_metadata': {'schemas': {'components': {'schemas': {}}}},
|
||||||
|
}
|
||||||
|
tp = TrainModelParams.from_dict(d)
|
||||||
|
train_df = pd.DataFrame({'a': [1.0, 2.0], 't': [1.0, 2.0]})
|
||||||
|
val_df = pd.DataFrame({'a': [1.0], 't': [1.0]})
|
||||||
|
tmr = TrainModelResult(params=tp, train_data=train_df, val_data=val_df)
|
||||||
|
|
||||||
|
def _dl(object_name, **_kwargs):
|
||||||
|
if object_name == tp.file_name:
|
||||||
|
return b'train'
|
||||||
|
if object_name == 'val.csv':
|
||||||
|
return b'val'
|
||||||
|
raise AssertionError(object_name)
|
||||||
|
|
||||||
|
training.minio_repository.download_file = MagicMock(side_effect=_dl)
|
||||||
|
training.data_manager_repository.prepare_training_data = MagicMock(return_value=tmr)
|
||||||
|
training.data_manager_repository.compute_regression_metrics = MagicMock(
|
||||||
|
side_effect=lambda x, _w, **_kw: x
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fill(x, **_kw):
|
||||||
|
x.report_path = '/tmp/report.html'
|
||||||
|
x.train_data_path = '/tmp/train.csv'
|
||||||
|
x.test_data_path = '/tmp/test.csv'
|
||||||
|
x.run_dir = '/tmp/run'
|
||||||
|
return x
|
||||||
|
|
||||||
|
training.data_manager_repository.generate_report = MagicMock(side_effect=_fill)
|
||||||
|
wrapper = MagicMock()
|
||||||
|
wrapper.transform = MagicMock(side_effect=[(train_df, None), (val_df, None)])
|
||||||
|
wrapper.predict = MagicMock(
|
||||||
|
side_effect=[(pd.DataFrame({'p': [1.0, 2.0]}), None), (pd.DataFrame({'p': [1.0]}), None)]
|
||||||
|
)
|
||||||
|
wrapper.store_model = MagicMock()
|
||||||
|
training.plugin_store.get_model = MagicMock(return_value=wrapper)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _run_ctx(*_a, **_k):
|
||||||
|
info = MagicMock()
|
||||||
|
info.run_name = 'n'
|
||||||
|
info.run_id = 'i'
|
||||||
|
yield info
|
||||||
|
|
||||||
|
training.mlflow_repository.start_run = _run_ctx
|
||||||
|
|
||||||
|
training.train_model({'metadata': {}, 'train_params': tp.to_dict()})
|
||||||
|
assert training.minio_repository.download_file.call_count == 2
|
||||||
|
mock_mlflow.log_artifact.assert_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_data_observes_lag_on_success(training):
|
||||||
|
tp = TrainModelParams.from_dict(
|
||||||
|
{**_minimal_params_dict(), 'model_metadata': {'schemas': {'components': {'schemas': {}}}}}
|
||||||
|
)
|
||||||
|
train_df = pd.DataFrame({'a': [1.0], 't': [1.0]})
|
||||||
|
val_df = pd.DataFrame({'a': [1.0], 't': [1.0]})
|
||||||
|
tmr = TrainModelResult(params=tp, train_data=train_df, val_data=val_df)
|
||||||
|
training.data_manager_repository.prepare_training_data = MagicMock(return_value=tmr)
|
||||||
|
training.observe_lag_sync = MagicMock()
|
||||||
|
training.emit_metric_sync = MagicMock()
|
||||||
|
|
||||||
|
from model_manager import metrics as mm_metrics
|
||||||
|
|
||||||
|
training._prepare_data(b'csv', None, tp, {})
|
||||||
|
|
||||||
|
training.observe_lag_sync.assert_called_once()
|
||||||
|
call_args = training.observe_lag_sync.call_args
|
||||||
|
assert call_args.args[1] is mm_metrics.SIENTIA_TRAINING_DATA_PREPARATION_LAG
|
||||||
|
training.emit_metric_sync.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_data_increments_error_counter_and_still_observes_lag_on_failure(training):
|
||||||
|
tp = TrainModelParams.from_dict(
|
||||||
|
{**_minimal_params_dict(), 'model_metadata': {'schemas': {'components': {'schemas': {}}}}}
|
||||||
|
)
|
||||||
|
training.data_manager_repository.prepare_training_data = MagicMock(
|
||||||
|
side_effect=RuntimeError('prep-fail')
|
||||||
|
)
|
||||||
|
training.observe_lag_sync = MagicMock()
|
||||||
|
training.emit_metric_sync = MagicMock()
|
||||||
|
|
||||||
|
from model_manager import metrics as mm_metrics
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match='prep-fail'):
|
||||||
|
training._prepare_data(b'csv', None, tp, {})
|
||||||
|
|
||||||
|
training.observe_lag_sync.assert_called_once()
|
||||||
|
training.emit_metric_sync.assert_called_once()
|
||||||
|
call_args = training.emit_metric_sync.call_args
|
||||||
|
assert call_args.kwargs['metric_object'] is mm_metrics.SIENTIA_TRAINING_DATA_PREPARATION_ERROR_COUNT_TOTAL
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_model_observes_lag_on_success(training):
|
||||||
|
tp = TrainModelParams.from_dict(
|
||||||
|
{**_minimal_params_dict(), 'model_metadata': {'schemas': {'components': {'schemas': {}}}}}
|
||||||
|
)
|
||||||
|
train_df = pd.DataFrame({'a': [1.0, 2.0], 't': [1.0, 2.0]})
|
||||||
|
val_df = pd.DataFrame({'a': [1.0], 't': [1.0]})
|
||||||
|
tmr = TrainModelResult(params=tp, train_data=train_df, val_data=val_df)
|
||||||
|
|
||||||
|
wrapper = MagicMock()
|
||||||
|
wrapper.transform = MagicMock(side_effect=[(train_df, None), (val_df, None)])
|
||||||
|
wrapper.predict = MagicMock(
|
||||||
|
side_effect=[(pd.DataFrame({'p': [1.0, 2.0]}), None), (pd.DataFrame({'p': [1.0]}), None)]
|
||||||
|
)
|
||||||
|
training.observe_lag_sync = MagicMock()
|
||||||
|
training.emit_metric_sync = MagicMock()
|
||||||
|
|
||||||
|
from model_manager import metrics as mm_metrics
|
||||||
|
|
||||||
|
training._fit_model(wrapper, tmr, tp, {})
|
||||||
|
|
||||||
|
training.observe_lag_sync.assert_called_once()
|
||||||
|
call_args = training.observe_lag_sync.call_args
|
||||||
|
assert call_args.args[1] is mm_metrics.SIENTIA_TRAINING_MODEL_FIT_LAG
|
||||||
|
training.emit_metric_sync.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_model_increments_error_counter_on_failure(training):
|
||||||
|
tp = TrainModelParams.from_dict(
|
||||||
|
{**_minimal_params_dict(), 'model_metadata': {'schemas': {'components': {'schemas': {}}}}}
|
||||||
|
)
|
||||||
|
train_df = pd.DataFrame({'a': [1.0], 't': [1.0]})
|
||||||
|
val_df = pd.DataFrame({'a': [1.0], 't': [1.0]})
|
||||||
|
tmr = TrainModelResult(params=tp, train_data=train_df, val_data=val_df)
|
||||||
|
|
||||||
|
wrapper = MagicMock()
|
||||||
|
wrapper.train = MagicMock(side_effect=RuntimeError('fit-fail'))
|
||||||
|
training.observe_lag_sync = MagicMock()
|
||||||
|
training.emit_metric_sync = MagicMock()
|
||||||
|
|
||||||
|
from model_manager import metrics as mm_metrics
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match='fit-fail'):
|
||||||
|
training._fit_model(wrapper, tmr, tp, {})
|
||||||
|
|
||||||
|
training.observe_lag_sync.assert_called_once()
|
||||||
|
training.emit_metric_sync.assert_called_once()
|
||||||
|
call_args = training.emit_metric_sync.call_args
|
||||||
|
assert call_args.kwargs['metric_object'] is mm_metrics.SIENTIA_TRAINING_MODEL_FIT_ERROR_COUNT_TOTAL
|
||||||
|
|
||||||
|
|
||||||
|
@patch('model_manager.activities.training.mm_metrics')
|
||||||
|
@patch('model_manager.activities.training.mlflow')
|
||||||
|
def test_train_model_sets_quality_gauges_after_compute_metrics(mock_mlflow, mock_mm_metrics, training):
|
||||||
|
tp = TrainModelParams.from_dict(
|
||||||
|
{**_minimal_params_dict(), 'model_metadata': {'schemas': {'components': {'schemas': {}}}}}
|
||||||
|
)
|
||||||
|
train_df = pd.DataFrame({'a': [1.0, 2.0], 't': [1.0, 2.0]})
|
||||||
|
val_df = pd.DataFrame({'a': [1.0], 't': [1.0]})
|
||||||
|
tmr = TrainModelResult(params=tp, train_data=train_df, val_data=val_df)
|
||||||
|
|
||||||
|
training.minio_repository.download_file = MagicMock(return_value=b'csv')
|
||||||
|
training.data_manager_repository.prepare_training_data = MagicMock(return_value=tmr)
|
||||||
|
|
||||||
|
def _set_metrics(x, _w, **_kw):
|
||||||
|
x.mse_val = 0.5
|
||||||
|
x.mae_val = 0.3
|
||||||
|
x.r2_val = -0.1
|
||||||
|
return x
|
||||||
|
|
||||||
|
training.data_manager_repository.compute_regression_metrics = MagicMock(
|
||||||
|
side_effect=_set_metrics
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fill_report(x, **_kw):
|
||||||
|
x.report_path = '/tmp/r.html'
|
||||||
|
x.train_data_path = '/tmp/tr.csv'
|
||||||
|
x.test_data_path = '/tmp/te.csv'
|
||||||
|
x.run_dir = '/tmp/run'
|
||||||
|
return x
|
||||||
|
|
||||||
|
training.data_manager_repository.generate_report = MagicMock(side_effect=_fill_report)
|
||||||
|
wrapper = MagicMock()
|
||||||
|
wrapper.transform = MagicMock(side_effect=[(train_df, None), (val_df, None)])
|
||||||
|
wrapper.predict = MagicMock(
|
||||||
|
side_effect=[(pd.DataFrame({'p': [1.0, 2.0]}), None), (pd.DataFrame({'p': [1.0]}), None)]
|
||||||
|
)
|
||||||
|
wrapper.store_model = MagicMock()
|
||||||
|
training.plugin_store.get_model = MagicMock(return_value=wrapper)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _run_ctx(*_a, **_k):
|
||||||
|
info = MagicMock()
|
||||||
|
info.run_id = 'rid'
|
||||||
|
yield info
|
||||||
|
|
||||||
|
training.mlflow_repository.start_run = _run_ctx
|
||||||
|
training.observe_lag_sync = MagicMock()
|
||||||
|
training.emit_metric_sync = MagicMock()
|
||||||
|
|
||||||
|
training.train_model({'metadata': {}, 'train_params': tp.to_dict()})
|
||||||
|
|
||||||
|
mock_mm_metrics.SIENTIA_TRAINING_MODEL_QUALITY_MSE.labels.return_value.set.assert_called_once_with(0.5)
|
||||||
|
mock_mm_metrics.SIENTIA_TRAINING_MODEL_QUALITY_MAE.labels.return_value.set.assert_called_once_with(0.3)
|
||||||
|
mock_mm_metrics.SIENTIA_TRAINING_MODEL_QUALITY_R2.labels.return_value.set.assert_called_once_with(-0.1)
|
||||||
|
|
||||||
|
|
||||||
|
@patch('model_manager.activities.training.mm_metrics')
|
||||||
|
@patch('model_manager.activities.training.mlflow')
|
||||||
|
def test_train_model_skips_quality_gauges_when_none(_mock_mlflow, mock_mm_metrics, training):
|
||||||
|
tp = TrainModelParams.from_dict(
|
||||||
|
{**_minimal_params_dict(), 'model_metadata': {'schemas': {'components': {'schemas': {}}}}}
|
||||||
|
)
|
||||||
|
train_df = pd.DataFrame({'a': [1.0, 2.0], 't': [1.0, 2.0]})
|
||||||
|
val_df = pd.DataFrame({'a': [1.0], 't': [1.0]})
|
||||||
|
tmr = TrainModelResult(params=tp, train_data=train_df, val_data=val_df)
|
||||||
|
|
||||||
|
training.minio_repository.download_file = MagicMock(return_value=b'csv')
|
||||||
|
training.data_manager_repository.prepare_training_data = MagicMock(return_value=tmr)
|
||||||
|
training.data_manager_repository.compute_regression_metrics = MagicMock(
|
||||||
|
side_effect=lambda x, _w, **_kw: x
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fill_report(x, **_kw):
|
||||||
|
x.report_path = '/tmp/r.html'
|
||||||
|
x.train_data_path = '/tmp/tr.csv'
|
||||||
|
x.test_data_path = '/tmp/te.csv'
|
||||||
|
x.run_dir = '/tmp/run'
|
||||||
|
return x
|
||||||
|
|
||||||
|
training.data_manager_repository.generate_report = MagicMock(side_effect=_fill_report)
|
||||||
|
wrapper = MagicMock()
|
||||||
|
wrapper.transform = MagicMock(side_effect=[(train_df, None), (val_df, None)])
|
||||||
|
wrapper.predict = MagicMock(
|
||||||
|
side_effect=[(pd.DataFrame({'p': [1.0, 2.0]}), None), (pd.DataFrame({'p': [1.0]}), None)]
|
||||||
|
)
|
||||||
|
wrapper.store_model = MagicMock()
|
||||||
|
training.plugin_store.get_model = MagicMock(return_value=wrapper)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _run_ctx(*_a, **_k):
|
||||||
|
info = MagicMock()
|
||||||
|
info.run_id = 'rid'
|
||||||
|
yield info
|
||||||
|
|
||||||
|
training.mlflow_repository.start_run = _run_ctx
|
||||||
|
training.observe_lag_sync = MagicMock()
|
||||||
|
training.emit_metric_sync = MagicMock()
|
||||||
|
|
||||||
|
training.train_model({'metadata': {}, 'train_params': tp.to_dict()})
|
||||||
|
|
||||||
|
mock_mm_metrics.SIENTIA_TRAINING_MODEL_QUALITY_MSE.labels.return_value.set.assert_not_called()
|
||||||
|
mock_mm_metrics.SIENTIA_TRAINING_MODEL_QUALITY_MAE.labels.return_value.set.assert_not_called()
|
||||||
|
mock_mm_metrics.SIENTIA_TRAINING_MODEL_QUALITY_R2.labels.return_value.set.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch('model_manager.activities.training.mm_metrics')
|
||||||
|
@patch('model_manager.activities.training.mlflow')
|
||||||
|
def test_train_model_increments_trained_total_on_success(_mock_mlflow, mock_mm_metrics, training):
|
||||||
|
tp = TrainModelParams.from_dict(
|
||||||
|
{**_minimal_params_dict(), 'model_metadata': {'schemas': {'components': {'schemas': {}}}}}
|
||||||
|
)
|
||||||
|
train_df = pd.DataFrame({'a': [1.0, 2.0], 't': [1.0, 2.0]})
|
||||||
|
val_df = pd.DataFrame({'a': [1.0], 't': [1.0]})
|
||||||
|
tmr = TrainModelResult(params=tp, train_data=train_df, val_data=val_df)
|
||||||
|
|
||||||
|
training.minio_repository.download_file = MagicMock(return_value=b'csv')
|
||||||
|
training.data_manager_repository.prepare_training_data = MagicMock(return_value=tmr)
|
||||||
|
training.data_manager_repository.compute_regression_metrics = MagicMock(
|
||||||
|
side_effect=lambda x, _w, **_kw: x
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fill_report(x, **_kw):
|
||||||
|
x.report_path = '/tmp/r.html'
|
||||||
|
x.train_data_path = '/tmp/tr.csv'
|
||||||
|
x.test_data_path = '/tmp/te.csv'
|
||||||
|
x.run_dir = '/tmp/run'
|
||||||
|
return x
|
||||||
|
|
||||||
|
training.data_manager_repository.generate_report = MagicMock(side_effect=_fill_report)
|
||||||
|
wrapper = MagicMock()
|
||||||
|
wrapper.transform = MagicMock(side_effect=[(train_df, None), (val_df, None)])
|
||||||
|
wrapper.predict = MagicMock(
|
||||||
|
side_effect=[(pd.DataFrame({'p': [1.0, 2.0]}), None), (pd.DataFrame({'p': [1.0]}), None)]
|
||||||
|
)
|
||||||
|
wrapper.store_model = MagicMock()
|
||||||
|
training.plugin_store.get_model = MagicMock(return_value=wrapper)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _run_ctx(*_a, **_k):
|
||||||
|
info = MagicMock()
|
||||||
|
info.run_id = 'rid'
|
||||||
|
yield info
|
||||||
|
|
||||||
|
training.mlflow_repository.start_run = _run_ctx
|
||||||
|
training.observe_lag_sync = MagicMock()
|
||||||
|
training.emit_metric_sync = MagicMock()
|
||||||
|
|
||||||
|
training.train_model({'metadata': {}, 'train_params': tp.to_dict()})
|
||||||
|
|
||||||
|
training.emit_metric_sync.assert_called_once_with(
|
||||||
|
metric_object=mock_mm_metrics.SIENTIA_TRAINING_MODEL_TRAINED_TOTAL,
|
||||||
|
tags=training._get_training_labels(tp),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_train_model_does_not_increment_trained_total_on_failure(training):
|
||||||
|
tp = TrainModelParams.from_dict(
|
||||||
|
{**_minimal_params_dict(), 'model_metadata': {'schemas': {'components': {'schemas': {}}}}}
|
||||||
|
)
|
||||||
|
training.minio_repository.download_file = MagicMock(side_effect=RuntimeError('dl-fail'))
|
||||||
|
training.send_notification = MagicMock()
|
||||||
|
training.emit_metric_sync = MagicMock()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
training.train_model({'metadata': {}, 'train_params': tp.to_dict()})
|
||||||
|
|
||||||
|
training.emit_metric_sync.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_train_model_value_error_when_paths_missing_after_report(training):
|
||||||
|
"""Raises ValueError when report paths are not populated after generate_report."""
|
||||||
|
tp = TrainModelParams.from_dict(
|
||||||
|
{
|
||||||
|
**_minimal_params_dict(),
|
||||||
|
'model_metadata': {'schemas': {'components': {'schemas': {}}}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
train_df = pd.DataFrame({'a': [1.0, 2.0], 't': [1.0, 2.0]})
|
||||||
|
val_df = pd.DataFrame({'a': [1.0], 't': [1.0]})
|
||||||
|
tmr = TrainModelResult(params=tp, train_data=train_df, val_data=val_df)
|
||||||
|
|
||||||
|
training.minio_repository.download_file = MagicMock(return_value=b'x')
|
||||||
|
training.data_manager_repository.prepare_training_data = MagicMock(return_value=tmr)
|
||||||
|
training.data_manager_repository.compute_regression_metrics = MagicMock(
|
||||||
|
side_effect=lambda x, _w, **_kw: x
|
||||||
|
)
|
||||||
|
training.data_manager_repository.generate_report = MagicMock(return_value=tmr)
|
||||||
|
wrapper = MagicMock()
|
||||||
|
wrapper.transform = MagicMock(side_effect=[(train_df, None), (val_df, None)])
|
||||||
|
wrapper.predict = MagicMock(
|
||||||
|
side_effect=[(pd.DataFrame({'p': [1.0, 2.0]}), None), (pd.DataFrame({'p': [1.0]}), None)]
|
||||||
|
)
|
||||||
|
training.plugin_store.get_model = MagicMock(return_value=wrapper)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _run_ctx(*_a, **_k):
|
||||||
|
info = MagicMock()
|
||||||
|
info.run_name = 'n'
|
||||||
|
info.run_id = 'i'
|
||||||
|
yield info
|
||||||
|
|
||||||
|
training.mlflow_repository.start_run = _run_ctx
|
||||||
|
training.send_notification = MagicMock()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='Report path'):
|
||||||
|
training.train_model({'metadata': {}, 'train_params': tp.to_dict()})
|
||||||
103
tests/conftest.py
Normal file
103
tests/conftest.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""
|
||||||
|
Test bootstrap: stub optional `sientia_do` submodules not shipped in minimal installs.
|
||||||
|
|
||||||
|
Must run before importing `model_manager.sientia.models` (pulled in via TrainModelParams).
|
||||||
|
Stubs Evidently submodules so `model_manager.sientia.reports` imports (via DataManagerRepository).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
|
|
||||||
|
def _make_dummy(name: str) -> type:
|
||||||
|
return type(name, (), {})
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_evidently() -> None:
|
||||||
|
"""Minimal Evidently API surface required to import `model_manager.sientia.reports`."""
|
||||||
|
ev = ModuleType('evidently')
|
||||||
|
sys.modules['evidently'] = ev
|
||||||
|
|
||||||
|
mp = ModuleType('evidently.metric_preset')
|
||||||
|
mp.DataDriftPreset = _make_dummy('DataDriftPreset') # type: ignore[attr-defined]
|
||||||
|
sys.modules['evidently.metric_preset'] = mp
|
||||||
|
|
||||||
|
metrics = ModuleType('evidently.metrics')
|
||||||
|
_metric_names = (
|
||||||
|
'ColumnSummaryMetric',
|
||||||
|
'ConflictTargetMetric',
|
||||||
|
'DatasetCorrelationsMetric',
|
||||||
|
'DatasetSummaryMetric',
|
||||||
|
'RegressionAbsPercentageErrorPlot',
|
||||||
|
'RegressionDummyMetric',
|
||||||
|
'RegressionErrorDistribution',
|
||||||
|
'RegressionErrorPlot',
|
||||||
|
'RegressionPerformanceMetrics',
|
||||||
|
'RegressionPredictedVsActualPlot',
|
||||||
|
'RegressionPredictedVsActualScatter',
|
||||||
|
)
|
||||||
|
for n in _metric_names:
|
||||||
|
setattr(metrics, n, _make_dummy(n))
|
||||||
|
sys.modules['evidently.metrics'] = metrics
|
||||||
|
|
||||||
|
base = ModuleType('evidently.metrics.base_metric')
|
||||||
|
|
||||||
|
def generate_column_metrics(*_a, **_k):
|
||||||
|
return []
|
||||||
|
|
||||||
|
base.generate_column_metrics = generate_column_metrics # type: ignore[attr-defined]
|
||||||
|
sys.modules['evidently.metrics.base_metric'] = base
|
||||||
|
|
||||||
|
opt = ModuleType('evidently.options')
|
||||||
|
opt.ColorOptions = _make_dummy('ColorOptions') # type: ignore[attr-defined]
|
||||||
|
sys.modules['evidently.options'] = opt
|
||||||
|
|
||||||
|
pipeline = ModuleType('evidently.pipeline')
|
||||||
|
sys.modules['evidently.pipeline'] = pipeline
|
||||||
|
|
||||||
|
colmap = ModuleType('evidently.pipeline.column_mapping')
|
||||||
|
colmap.ColumnMapping = _make_dummy('ColumnMapping') # type: ignore[attr-defined]
|
||||||
|
sys.modules['evidently.pipeline.column_mapping'] = colmap
|
||||||
|
|
||||||
|
rep = ModuleType('evidently.report')
|
||||||
|
rep.Report = _make_dummy('Report') # type: ignore[attr-defined]
|
||||||
|
sys.modules['evidently.report'] = rep
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config) -> None: # noqa: ARG001
|
||||||
|
"""Register stub modules so imports used by production code resolve in CI/dev venvs."""
|
||||||
|
_stub_evidently()
|
||||||
|
|
||||||
|
if 'sientia_do.operations.df_preprocessor' not in sys.modules:
|
||||||
|
df_pre = ModuleType('sientia_do.operations.df_preprocessor')
|
||||||
|
|
||||||
|
def create_features(input_data, *_a, **_k):
|
||||||
|
return input_data
|
||||||
|
|
||||||
|
def limit_dataset(input_data, low_lim, upp_lim, *_a, **_k):
|
||||||
|
return input_data, low_lim, upp_lim
|
||||||
|
|
||||||
|
def treat_nan(input_data, *_a, **_k):
|
||||||
|
return input_data
|
||||||
|
|
||||||
|
df_pre.create_features = create_features # type: ignore[attr-defined]
|
||||||
|
df_pre.limit_dataset = limit_dataset # type: ignore[attr-defined]
|
||||||
|
df_pre.treat_nan = treat_nan # type: ignore[attr-defined]
|
||||||
|
sys.modules['sientia_do.operations.df_preprocessor'] = df_pre
|
||||||
|
|
||||||
|
sys.modules.setdefault('sientia_do.operations', ModuleType('sientia_do.operations'))
|
||||||
|
|
||||||
|
if 'sientia_do.timeseries.analyzer' not in sys.modules:
|
||||||
|
ts_an = ModuleType('sientia_do.timeseries.analyzer')
|
||||||
|
|
||||||
|
class TimeSeriesDiscontinuityAnalyzer: # noqa: D401
|
||||||
|
"""Stub for tests."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
ts_an.TimeSeriesDiscontinuityAnalyzer = TimeSeriesDiscontinuityAnalyzer # type: ignore[attr-defined]
|
||||||
|
sys.modules['sientia_do.timeseries.analyzer'] = ts_an
|
||||||
|
|
||||||
|
sys.modules.setdefault('sientia_do.timeseries', ModuleType('sientia_do.timeseries'))
|
||||||
0
tests/schedules/__init__.py
Normal file
0
tests/schedules/__init__.py
Normal file
469
tests/schedules/test_cleanup_schedule.py
Normal file
469
tests/schedules/test_cleanup_schedule.py
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
"""Tests for cleanup schedule management."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
from importlib import reload
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_temporal_client():
|
||||||
|
"""Fixture for a mock Temporal client."""
|
||||||
|
client = AsyncMock()
|
||||||
|
client.list_schedules = AsyncMock()
|
||||||
|
client.create_schedule = AsyncMock()
|
||||||
|
handle = AsyncMock()
|
||||||
|
handle.delete = AsyncMock()
|
||||||
|
schedule = MagicMock()
|
||||||
|
schedule.action.task_queue = 'cleanup_files-model-manager-worker-queue'
|
||||||
|
schedule.action.execution_timeout = timedelta(hours=1)
|
||||||
|
schedule.spec.cron_expressions = ['0 0 * * *']
|
||||||
|
schedule.spec.time_zone_name = 'UTC'
|
||||||
|
handle.describe = AsyncMock(return_value=MagicMock(schedule=schedule))
|
||||||
|
client.get_schedule_handle = MagicMock(return_value=handle)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_logger():
|
||||||
|
"""Fixture for a mock Sientia logger."""
|
||||||
|
logger = MagicMock()
|
||||||
|
logger.custom_info = MagicMock()
|
||||||
|
logger.custom_error = MagicMock()
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def metadata():
|
||||||
|
"""Fixture for metadata dict."""
|
||||||
|
return {'pod_id': 'test-pod', 'project_name': 'test-project'}
|
||||||
|
|
||||||
|
|
||||||
|
# --- schedule_exists Tests ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_schedule_exists_returns_true_when_schedule_found(
|
||||||
|
mock_temporal_client, mock_logger, metadata
|
||||||
|
):
|
||||||
|
"""Test that schedule_exists returns True when schedule is found."""
|
||||||
|
from model_manager.schedules.cleanup_schedule import schedule_exists
|
||||||
|
|
||||||
|
# Mock schedule list with matching schedule
|
||||||
|
mock_schedule = MagicMock()
|
||||||
|
mock_schedule.id = 'test-schedule-id'
|
||||||
|
|
||||||
|
async def mock_list_schedules():
|
||||||
|
yield mock_schedule
|
||||||
|
|
||||||
|
mock_temporal_client.list_schedules.return_value = mock_list_schedules()
|
||||||
|
|
||||||
|
result = await schedule_exists(mock_temporal_client, 'test-schedule-id', mock_logger, metadata)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_temporal_client.list_schedules.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_schedule_exists_returns_false_when_schedule_not_found(
|
||||||
|
mock_temporal_client, mock_logger, metadata
|
||||||
|
):
|
||||||
|
"""Test that schedule_exists returns False when schedule is not found."""
|
||||||
|
from model_manager.schedules.cleanup_schedule import schedule_exists
|
||||||
|
|
||||||
|
# Mock empty schedule list
|
||||||
|
async def mock_list_schedules():
|
||||||
|
return
|
||||||
|
yield # Make it an async generator
|
||||||
|
|
||||||
|
mock_temporal_client.list_schedules.return_value = mock_list_schedules()
|
||||||
|
|
||||||
|
result = await schedule_exists(
|
||||||
|
mock_temporal_client, 'nonexistent-schedule', mock_logger, metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
mock_temporal_client.list_schedules.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_schedule_exists_returns_false_when_different_schedule_found(
|
||||||
|
mock_temporal_client, mock_logger, metadata
|
||||||
|
):
|
||||||
|
"""Test that schedule_exists returns False when only different schedules exist."""
|
||||||
|
from model_manager.schedules.cleanup_schedule import schedule_exists
|
||||||
|
|
||||||
|
# Mock schedule list with non-matching schedule
|
||||||
|
mock_schedule = MagicMock()
|
||||||
|
mock_schedule.id = 'different-schedule-id'
|
||||||
|
|
||||||
|
async def mock_list_schedules():
|
||||||
|
yield mock_schedule
|
||||||
|
|
||||||
|
mock_temporal_client.list_schedules.return_value = mock_list_schedules()
|
||||||
|
|
||||||
|
result = await schedule_exists(mock_temporal_client, 'test-schedule-id', mock_logger, metadata)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
mock_temporal_client.list_schedules.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_schedule_exists_handles_exception(mock_temporal_client, mock_logger, metadata):
|
||||||
|
"""Test that schedule_exists handles exceptions gracefully."""
|
||||||
|
from model_manager.schedules.cleanup_schedule import schedule_exists
|
||||||
|
|
||||||
|
# Mock list_schedules to raise an exception
|
||||||
|
mock_temporal_client.list_schedules.side_effect = Exception('Connection error')
|
||||||
|
|
||||||
|
result = await schedule_exists(mock_temporal_client, 'test-schedule-id', mock_logger, metadata)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
mock_logger.custom_error.assert_called_once()
|
||||||
|
assert 'Error checking if schedule exists' in mock_logger.custom_error.call_args[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_needs_schedule_reconcile_handles_describe_exception(mock_logger, metadata):
|
||||||
|
"""Test _needs_schedule_reconcile returns True and logs when describe fails."""
|
||||||
|
from model_manager.schedules.cleanup_schedule import _needs_schedule_reconcile
|
||||||
|
|
||||||
|
handle = AsyncMock()
|
||||||
|
handle.describe = AsyncMock(side_effect=RuntimeError('describe failed'))
|
||||||
|
|
||||||
|
needs_reconcile = await _needs_schedule_reconcile(
|
||||||
|
schedule_handle=handle,
|
||||||
|
cleanup_task_queue='cleanup_files-model-manager-worker-queue',
|
||||||
|
logger=mock_logger,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert needs_reconcile is True
|
||||||
|
mock_logger.custom_error.assert_called_once()
|
||||||
|
assert (
|
||||||
|
'Error describing cleanup schedule for reconcile'
|
||||||
|
in mock_logger.custom_error.call_args[0][0]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- create_cleanup_schedule Tests ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch.dict(
|
||||||
|
'model_manager.schedules.cleanup_schedule.os.environ',
|
||||||
|
{
|
||||||
|
'RUNTIME': 'model-manager-worker',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def test_create_cleanup_schedule_reconciles_when_exists(
|
||||||
|
mock_temporal_client, mock_logger, metadata
|
||||||
|
):
|
||||||
|
"""Test that create_cleanup_schedule recreates schedule when it already exists."""
|
||||||
|
import model_manager.schedules.cleanup_schedule
|
||||||
|
|
||||||
|
reload(model_manager.schedules.cleanup_schedule)
|
||||||
|
from model_manager.schedules.cleanup_schedule import create_cleanup_schedule
|
||||||
|
|
||||||
|
# Mock schedule already exists
|
||||||
|
mock_schedule = MagicMock()
|
||||||
|
mock_schedule.id = 'cleanup-files-model-manager-worker-daily'
|
||||||
|
|
||||||
|
async def mock_list_schedules():
|
||||||
|
yield mock_schedule
|
||||||
|
|
||||||
|
mock_temporal_client.list_schedules.return_value = mock_list_schedules()
|
||||||
|
|
||||||
|
# Force reconcile by diverging task queue
|
||||||
|
mock_temporal_client.get_schedule_handle.return_value.describe.return_value.schedule.action.task_queue = 'different-queue'
|
||||||
|
|
||||||
|
await create_cleanup_schedule(mock_temporal_client, mock_logger, metadata)
|
||||||
|
|
||||||
|
# Verify schedule was reconciled via delete + create
|
||||||
|
mock_temporal_client.get_schedule_handle.assert_called_once_with(
|
||||||
|
'cleanup-files-model-manager-worker-daily'
|
||||||
|
)
|
||||||
|
mock_temporal_client.get_schedule_handle.return_value.delete.assert_called_once()
|
||||||
|
mock_temporal_client.create_schedule.assert_called_once()
|
||||||
|
|
||||||
|
mock_logger.custom_info.assert_called_once()
|
||||||
|
assert 'reconciled successfully' in mock_logger.custom_info.call_args[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch.dict(
|
||||||
|
'model_manager.schedules.cleanup_schedule.os.environ',
|
||||||
|
{
|
||||||
|
'RUNTIME': 'model-manager-worker',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def test_create_cleanup_schedule_noop_when_schedule_is_up_to_date(
|
||||||
|
mock_temporal_client, mock_logger, metadata
|
||||||
|
):
|
||||||
|
"""Test no-op reconcile when existing schedule already matches current config."""
|
||||||
|
import model_manager.schedules.cleanup_schedule
|
||||||
|
|
||||||
|
reload(model_manager.schedules.cleanup_schedule)
|
||||||
|
from model_manager.schedules.cleanup_schedule import create_cleanup_schedule
|
||||||
|
|
||||||
|
mock_schedule = MagicMock()
|
||||||
|
mock_schedule.id = 'cleanup-files-model-manager-worker-daily'
|
||||||
|
|
||||||
|
async def mock_list_schedules():
|
||||||
|
yield mock_schedule
|
||||||
|
|
||||||
|
mock_temporal_client.list_schedules.return_value = mock_list_schedules()
|
||||||
|
|
||||||
|
await create_cleanup_schedule(mock_temporal_client, mock_logger, metadata)
|
||||||
|
|
||||||
|
mock_temporal_client.get_schedule_handle.assert_called_once_with(
|
||||||
|
'cleanup-files-model-manager-worker-daily'
|
||||||
|
)
|
||||||
|
mock_temporal_client.get_schedule_handle.return_value.delete.assert_not_called()
|
||||||
|
mock_temporal_client.create_schedule.assert_not_called()
|
||||||
|
mock_logger.custom_info.assert_called_once()
|
||||||
|
assert 'no-op reconcile' in mock_logger.custom_info.call_args[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch.dict(
|
||||||
|
'model_manager.schedules.cleanup_schedule.os.environ',
|
||||||
|
{
|
||||||
|
'RUNTIME': 'model-manager-worker',
|
||||||
|
'CLEANUP_CRON': '0 2 * * *',
|
||||||
|
'CLEANUP_TIMEZONE': 'America/Sao_Paulo',
|
||||||
|
'CLEANUP_EXECUTION_TIMEOUT_HOURS': '2',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def test_create_cleanup_schedule_creates_with_custom_config(
|
||||||
|
mock_temporal_client, mock_logger, metadata
|
||||||
|
):
|
||||||
|
"""Test that create_cleanup_schedule creates schedule with custom configuration."""
|
||||||
|
import model_manager.schedules.cleanup_schedule
|
||||||
|
|
||||||
|
reload(model_manager.schedules.cleanup_schedule)
|
||||||
|
from model_manager.schedules.cleanup_schedule import create_cleanup_schedule
|
||||||
|
|
||||||
|
# Mock schedule does not exist (empty list)
|
||||||
|
async def mock_list_schedules():
|
||||||
|
return
|
||||||
|
yield # Make it an async generator
|
||||||
|
|
||||||
|
mock_temporal_client.list_schedules.return_value = mock_list_schedules()
|
||||||
|
|
||||||
|
await create_cleanup_schedule(mock_temporal_client, mock_logger, metadata)
|
||||||
|
|
||||||
|
# Verify schedule creation was called
|
||||||
|
mock_temporal_client.create_schedule.assert_called_once()
|
||||||
|
|
||||||
|
# Verify schedule parameters
|
||||||
|
call_args = mock_temporal_client.create_schedule.call_args
|
||||||
|
schedule_id = call_args[0][0]
|
||||||
|
schedule_obj = call_args[0][1]
|
||||||
|
|
||||||
|
assert schedule_id == 'cleanup-files-model-manager-worker-daily'
|
||||||
|
assert schedule_obj.action.workflow == 'cleanup_files'
|
||||||
|
assert schedule_obj.action.task_queue == 'cleanup_files-model-manager-worker-queue'
|
||||||
|
assert schedule_obj.action.execution_timeout == timedelta(hours=2)
|
||||||
|
assert schedule_obj.spec.cron_expressions == ['0 2 * * *']
|
||||||
|
assert schedule_obj.spec.time_zone_name == 'America/Sao_Paulo'
|
||||||
|
|
||||||
|
# Verify success log was called
|
||||||
|
assert mock_logger.custom_info.call_count == 1
|
||||||
|
assert 'created successfully' in mock_logger.custom_info.call_args[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch.dict(
|
||||||
|
'model_manager.schedules.cleanup_schedule.os.environ',
|
||||||
|
{
|
||||||
|
'RUNTIME': 'model-manager-worker',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def test_create_cleanup_schedule_uses_defaults(mock_temporal_client, mock_logger, metadata):
|
||||||
|
"""Test that create_cleanup_schedule uses default values when env vars not set."""
|
||||||
|
import model_manager.schedules.cleanup_schedule
|
||||||
|
|
||||||
|
# Remove optional env vars to test defaults
|
||||||
|
for key in [
|
||||||
|
'CLEANUP_CRON',
|
||||||
|
'CLEANUP_TIMEZONE',
|
||||||
|
'CLEANUP_EXECUTION_TIMEOUT_HOURS',
|
||||||
|
]:
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
|
||||||
|
reload(model_manager.schedules.cleanup_schedule)
|
||||||
|
from model_manager.schedules.cleanup_schedule import create_cleanup_schedule
|
||||||
|
|
||||||
|
# Mock schedule does not exist (empty list)
|
||||||
|
async def mock_list_schedules():
|
||||||
|
return
|
||||||
|
yield # Make it an async generator
|
||||||
|
|
||||||
|
mock_temporal_client.list_schedules.return_value = mock_list_schedules()
|
||||||
|
|
||||||
|
await create_cleanup_schedule(mock_temporal_client, mock_logger, metadata)
|
||||||
|
|
||||||
|
# Verify schedule creation was called
|
||||||
|
mock_temporal_client.create_schedule.assert_called_once()
|
||||||
|
|
||||||
|
# Verify default parameters
|
||||||
|
call_args = mock_temporal_client.create_schedule.call_args
|
||||||
|
schedule_obj = call_args[0][1]
|
||||||
|
|
||||||
|
assert schedule_obj.spec.cron_expressions == ['0 0 * * *'] # Default midnight
|
||||||
|
assert schedule_obj.spec.time_zone_name == 'UTC' # Default UTC
|
||||||
|
assert schedule_obj.action.task_queue == 'cleanup_files-model-manager-worker-queue'
|
||||||
|
assert schedule_obj.action.execution_timeout == timedelta(hours=1) # Default 1 hour
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch.dict(
|
||||||
|
'model_manager.schedules.cleanup_schedule.os.environ',
|
||||||
|
{
|
||||||
|
'RUNTIME': 'model-manager-worker',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def test_create_cleanup_schedule_workflow_id_format(
|
||||||
|
mock_temporal_client, mock_logger, metadata
|
||||||
|
):
|
||||||
|
"""Test that workflow ID is correctly formatted with schedule ID."""
|
||||||
|
import model_manager.schedules.cleanup_schedule
|
||||||
|
|
||||||
|
reload(model_manager.schedules.cleanup_schedule)
|
||||||
|
from model_manager.schedules.cleanup_schedule import create_cleanup_schedule
|
||||||
|
|
||||||
|
# Mock schedule does not exist (empty list)
|
||||||
|
async def mock_list_schedules():
|
||||||
|
return
|
||||||
|
yield # Make it an async generator
|
||||||
|
|
||||||
|
mock_temporal_client.list_schedules.return_value = mock_list_schedules()
|
||||||
|
|
||||||
|
await create_cleanup_schedule(mock_temporal_client, mock_logger, metadata)
|
||||||
|
|
||||||
|
# Verify workflow ID format
|
||||||
|
call_args = mock_temporal_client.create_schedule.call_args
|
||||||
|
schedule_obj = call_args[0][1]
|
||||||
|
|
||||||
|
expected_workflow_id = 'cleanup-files-scheduled-cleanup-files-model-manager-worker-daily'
|
||||||
|
assert schedule_obj.action.id == expected_workflow_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch.dict(
|
||||||
|
'model_manager.schedules.cleanup_schedule.os.environ',
|
||||||
|
{
|
||||||
|
'RUNTIME': 'model-manager-worker',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def test_create_cleanup_schedule_empty_workflow_args(
|
||||||
|
mock_temporal_client, mock_logger, metadata
|
||||||
|
):
|
||||||
|
"""Test that workflow is created with empty args (uses env defaults)."""
|
||||||
|
import model_manager.schedules.cleanup_schedule
|
||||||
|
|
||||||
|
reload(model_manager.schedules.cleanup_schedule)
|
||||||
|
from model_manager.schedules.cleanup_schedule import create_cleanup_schedule
|
||||||
|
|
||||||
|
# Mock schedule does not exist (empty list)
|
||||||
|
async def mock_list_schedules():
|
||||||
|
return
|
||||||
|
yield # Make it an async generator
|
||||||
|
|
||||||
|
mock_temporal_client.list_schedules.return_value = mock_list_schedules()
|
||||||
|
|
||||||
|
await create_cleanup_schedule(mock_temporal_client, mock_logger, metadata)
|
||||||
|
|
||||||
|
# Verify workflow args are empty (it's a list with one empty dict)
|
||||||
|
call_args = mock_temporal_client.create_schedule.call_args
|
||||||
|
schedule_obj = call_args[0][1]
|
||||||
|
|
||||||
|
# The args are passed as positional args, so it's a list with one element
|
||||||
|
assert schedule_obj.action.args == [{}]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Environment Variable Configuration Tests ---
|
||||||
|
|
||||||
|
|
||||||
|
@patch.dict(
|
||||||
|
'model_manager.schedules.cleanup_schedule.os.environ',
|
||||||
|
{
|
||||||
|
'RUNTIME': 'model-manager-worker',
|
||||||
|
'CLEANUP_CRON': '30 3 * * 1',
|
||||||
|
'CLEANUP_TIMEZONE': 'Europe/London',
|
||||||
|
'CLEANUP_EXECUTION_TIMEOUT_HOURS': '3',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def test_environment_variables_loaded_correctly():
|
||||||
|
"""Test that environment variables are loaded correctly."""
|
||||||
|
import model_manager.schedules.cleanup_schedule
|
||||||
|
|
||||||
|
reload(model_manager.schedules.cleanup_schedule)
|
||||||
|
from model_manager.schedules.cleanup_schedule import (
|
||||||
|
CLEANUP_CRON,
|
||||||
|
CLEANUP_EXECUTION_TIMEOUT_HOURS,
|
||||||
|
CLEANUP_TIMEZONE,
|
||||||
|
build_cleanup_schedule_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
build_cleanup_schedule_id('model-manager-worker')
|
||||||
|
== 'cleanup-files-model-manager-worker-daily'
|
||||||
|
)
|
||||||
|
assert CLEANUP_CRON == '30 3 * * 1'
|
||||||
|
assert CLEANUP_TIMEZONE == 'Europe/London'
|
||||||
|
assert CLEANUP_EXECUTION_TIMEOUT_HOURS == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_environment_variables_use_defaults_when_not_set():
|
||||||
|
"""Test that default values are used when environment variables are not set."""
|
||||||
|
import model_manager.schedules.cleanup_schedule
|
||||||
|
|
||||||
|
# Remove all env vars
|
||||||
|
for key in [
|
||||||
|
'RUNTIME',
|
||||||
|
'CLEANUP_CRON',
|
||||||
|
'CLEANUP_TIMEZONE',
|
||||||
|
'CLEANUP_EXECUTION_TIMEOUT_HOURS',
|
||||||
|
]:
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
|
||||||
|
reload(model_manager.schedules.cleanup_schedule)
|
||||||
|
from model_manager.schedules.cleanup_schedule import (
|
||||||
|
CLEANUP_CRON,
|
||||||
|
CLEANUP_EXECUTION_TIMEOUT_HOURS,
|
||||||
|
CLEANUP_TIMEZONE,
|
||||||
|
build_cleanup_schedule_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert build_cleanup_schedule_id(None) == 'cleanup-files-single-daily'
|
||||||
|
assert CLEANUP_CRON == '0 0 * * *'
|
||||||
|
assert CLEANUP_TIMEZONE == 'UTC'
|
||||||
|
assert CLEANUP_EXECUTION_TIMEOUT_HOURS == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_cleanup_schedule_uses_single_runtime_when_runtime_missing(
|
||||||
|
mock_temporal_client, mock_logger, metadata
|
||||||
|
):
|
||||||
|
"""Test create_cleanup_schedule uses single runtime fallback."""
|
||||||
|
import model_manager.schedules.cleanup_schedule
|
||||||
|
|
||||||
|
os.environ.pop('RUNTIME', None)
|
||||||
|
reload(model_manager.schedules.cleanup_schedule)
|
||||||
|
from model_manager.schedules.cleanup_schedule import create_cleanup_schedule
|
||||||
|
|
||||||
|
async def mock_list_schedules():
|
||||||
|
return
|
||||||
|
yield
|
||||||
|
|
||||||
|
mock_temporal_client.list_schedules.return_value = mock_list_schedules()
|
||||||
|
|
||||||
|
await create_cleanup_schedule(mock_temporal_client, mock_logger, metadata)
|
||||||
|
|
||||||
|
call_args = mock_temporal_client.create_schedule.call_args
|
||||||
|
schedule_obj = call_args[0][1]
|
||||||
|
assert schedule_obj.action.task_queue == 'cleanup_files-single-queue'
|
||||||
0
tests/sientia/__init__.py
Normal file
0
tests/sientia/__init__.py
Normal file
9
tests/sientia/test_exceptions.py
Normal file
9
tests/sientia/test_exceptions.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""Unit tests for custom exception aliases."""
|
||||||
|
|
||||||
|
from mlflow.exceptions import MlflowException
|
||||||
|
|
||||||
|
from model_manager.sientia.exceptions import SientiaMlException
|
||||||
|
|
||||||
|
|
||||||
|
def test_sientia_ml_exception_is_mlflow_exception_alias():
|
||||||
|
assert SientiaMlException is MlflowException
|
||||||
481
tests/sientia/test_metrics.py
Normal file
481
tests/sientia/test_metrics.py
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
"""Unit tests for sientia metrics module."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from model_manager.sientia.metrics import (
|
||||||
|
mae,
|
||||||
|
mse,
|
||||||
|
r2,
|
||||||
|
rce_drift,
|
||||||
|
rce_test,
|
||||||
|
rce_train,
|
||||||
|
silverman_radius,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mse_perfect_predictions():
|
||||||
|
"""Test MSE with perfect predictions returns 0.0."""
|
||||||
|
real_data = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||||
|
predictions = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||||
|
|
||||||
|
result = mse(real_data, predictions)
|
||||||
|
|
||||||
|
assert result == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mse_with_errors():
|
||||||
|
"""Test MSE calculation with prediction errors."""
|
||||||
|
real_data = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||||
|
predictions = pd.Series([1.5, 2.5, 3.5, 4.5, 5.5])
|
||||||
|
|
||||||
|
result = mse(real_data, predictions)
|
||||||
|
|
||||||
|
# MSE = mean((0.5^2, 0.5^2, 0.5^2, 0.5^2, 0.5^2)) = 0.25
|
||||||
|
assert result == 0.25
|
||||||
|
|
||||||
|
|
||||||
|
def test_mse_with_integer_input():
|
||||||
|
"""Test MSE handles integer input and converts to float64."""
|
||||||
|
real_data = pd.Series([1, 2, 3, 4, 5])
|
||||||
|
predictions = pd.Series([2, 3, 4, 5, 6])
|
||||||
|
|
||||||
|
result = mse(real_data, predictions)
|
||||||
|
|
||||||
|
# MSE = mean((1^2, 1^2, 1^2, 1^2, 1^2)) = 1.0
|
||||||
|
assert result == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mse_with_large_errors():
|
||||||
|
"""Test MSE with large prediction errors."""
|
||||||
|
real_data = pd.Series([10.0, 20.0, 30.0])
|
||||||
|
predictions = pd.Series([5.0, 15.0, 25.0])
|
||||||
|
|
||||||
|
result = mse(real_data, predictions)
|
||||||
|
|
||||||
|
# MSE = mean((25, 25, 25)) = 25.0
|
||||||
|
assert result == 25.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mse_rounds_to_two_decimals():
|
||||||
|
"""Test MSE rounds result to 2 decimal places."""
|
||||||
|
real_data = pd.Series([1.111, 2.222, 3.333])
|
||||||
|
predictions = pd.Series([1.222, 2.333, 3.444])
|
||||||
|
|
||||||
|
result = mse(real_data, predictions)
|
||||||
|
|
||||||
|
# Result should be rounded to 2 decimals
|
||||||
|
assert isinstance(result, float)
|
||||||
|
assert len(str(result).split('.')[-1]) <= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_mae_perfect_predictions():
|
||||||
|
"""Test MAE with perfect predictions returns 0.0."""
|
||||||
|
real_data = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||||
|
predictions = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||||
|
|
||||||
|
result = mae(real_data, predictions)
|
||||||
|
|
||||||
|
assert result == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mae_with_errors():
|
||||||
|
"""Test MAE calculation with prediction errors."""
|
||||||
|
real_data = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||||
|
predictions = pd.Series([1.5, 2.5, 3.5, 4.5, 5.5])
|
||||||
|
|
||||||
|
result = mae(real_data, predictions)
|
||||||
|
|
||||||
|
# MAE = mean(|0.5|, |0.5|, |0.5|, |0.5|, |0.5|) = 0.5
|
||||||
|
assert result == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_mae_with_integer_input():
|
||||||
|
"""Test MAE handles integer input and converts to float64."""
|
||||||
|
real_data = pd.Series([1, 2, 3, 4, 5])
|
||||||
|
predictions = pd.Series([2, 3, 4, 5, 6])
|
||||||
|
|
||||||
|
result = mae(real_data, predictions)
|
||||||
|
|
||||||
|
# MAE = mean(|1|, |1|, |1|, |1|, |1|) = 1.0
|
||||||
|
assert result == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mae_with_negative_errors():
|
||||||
|
"""Test MAE with negative prediction errors (absolute value)."""
|
||||||
|
real_data = pd.Series([10.0, 20.0, 30.0])
|
||||||
|
predictions = pd.Series([15.0, 25.0, 35.0])
|
||||||
|
|
||||||
|
result = mae(real_data, predictions)
|
||||||
|
|
||||||
|
# MAE = mean(|5|, |5|, |5|) = 5.0
|
||||||
|
assert result == 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mae_rounds_to_two_decimals():
|
||||||
|
"""Test MAE rounds result to 2 decimal places."""
|
||||||
|
real_data = pd.Series([1.111, 2.222, 3.333])
|
||||||
|
predictions = pd.Series([1.222, 2.333, 3.444])
|
||||||
|
|
||||||
|
result = mae(real_data, predictions)
|
||||||
|
|
||||||
|
# Result should be rounded to 2 decimals
|
||||||
|
assert isinstance(result, float)
|
||||||
|
assert len(str(result).split('.')[-1]) <= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_r2_perfect_predictions():
|
||||||
|
"""Test R2 with perfect predictions returns 1.0."""
|
||||||
|
real_data = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||||
|
predictions = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||||
|
|
||||||
|
result = r2(real_data, predictions)
|
||||||
|
|
||||||
|
assert result == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_r2_with_good_predictions():
|
||||||
|
"""Test R2 calculation with good predictions."""
|
||||||
|
real_data = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||||
|
predictions = pd.Series([1.1, 2.1, 2.9, 4.1, 4.9])
|
||||||
|
|
||||||
|
result = r2(real_data, predictions)
|
||||||
|
|
||||||
|
# R2 should be close to 1.0 for good predictions
|
||||||
|
assert result > 0.9
|
||||||
|
assert result <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_r2_with_integer_input():
|
||||||
|
"""Test R2 handles integer input and converts to float64."""
|
||||||
|
real_data = pd.Series([1, 2, 3, 4, 5])
|
||||||
|
predictions = pd.Series([1, 2, 3, 4, 5])
|
||||||
|
|
||||||
|
result = r2(real_data, predictions)
|
||||||
|
|
||||||
|
assert result == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_r2_with_poor_predictions():
|
||||||
|
"""Test R2 with poor predictions returns low score."""
|
||||||
|
real_data = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||||
|
predictions = pd.Series([5.0, 4.0, 3.0, 2.0, 1.0])
|
||||||
|
|
||||||
|
result = r2(real_data, predictions)
|
||||||
|
|
||||||
|
# R2 should be negative for predictions worse than mean
|
||||||
|
assert result < 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_r2_rounds_to_two_decimals():
|
||||||
|
"""Test R2 rounds result to 2 decimal places."""
|
||||||
|
real_data = pd.Series([1.111, 2.222, 3.333, 4.444, 5.555])
|
||||||
|
predictions = pd.Series([1.222, 2.333, 3.444, 4.555, 5.666])
|
||||||
|
|
||||||
|
result = r2(real_data, predictions)
|
||||||
|
|
||||||
|
# Result should be rounded to 2 decimals
|
||||||
|
assert isinstance(result, float)
|
||||||
|
assert len(str(result).split('.')[-1]) <= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_mse_with_mixed_positive_negative():
|
||||||
|
"""Test MSE with mixed positive and negative values."""
|
||||||
|
real_data = pd.Series([-5.0, -2.0, 0.0, 3.0, 7.0])
|
||||||
|
predictions = pd.Series([-4.0, -1.0, 1.0, 4.0, 8.0])
|
||||||
|
|
||||||
|
result = mse(real_data, predictions)
|
||||||
|
|
||||||
|
# MSE = mean((1^2, 1^2, 1^2, 1^2, 1^2)) = 1.0
|
||||||
|
assert result == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mae_with_mixed_positive_negative():
|
||||||
|
"""Test MAE with mixed positive and negative values."""
|
||||||
|
real_data = pd.Series([-5.0, -2.0, 0.0, 3.0, 7.0])
|
||||||
|
predictions = pd.Series([-4.0, -1.0, 1.0, 4.0, 8.0])
|
||||||
|
|
||||||
|
result = mae(real_data, predictions)
|
||||||
|
|
||||||
|
# MAE = mean(|1|, |1|, |1|, |1|, |1|) = 1.0
|
||||||
|
assert result == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_r2_with_mixed_positive_negative():
|
||||||
|
"""Test R2 with mixed positive and negative values."""
|
||||||
|
real_data = pd.Series([-5.0, -2.0, 0.0, 3.0, 7.0])
|
||||||
|
predictions = pd.Series([-5.0, -2.0, 0.0, 3.0, 7.0])
|
||||||
|
|
||||||
|
result = r2(real_data, predictions)
|
||||||
|
|
||||||
|
assert result == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests for silverman_radius
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_silverman_radius_basic():
|
||||||
|
"""Test silverman_radius returns a positive float."""
|
||||||
|
data = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0])
|
||||||
|
|
||||||
|
result = silverman_radius(data)
|
||||||
|
|
||||||
|
assert isinstance(result, float)
|
||||||
|
assert result > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_silverman_radius_uniform_data():
|
||||||
|
"""Test silverman_radius with uniformly distributed data."""
|
||||||
|
data = np.linspace(0, 100, 50)
|
||||||
|
|
||||||
|
result = silverman_radius(data)
|
||||||
|
|
||||||
|
assert result > 0
|
||||||
|
assert np.isfinite(result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_silverman_radius_normal_distribution():
|
||||||
|
"""Test silverman_radius with normally distributed data."""
|
||||||
|
np.random.seed(42)
|
||||||
|
data = np.random.normal(loc=50, scale=10, size=100)
|
||||||
|
|
||||||
|
result = silverman_radius(data)
|
||||||
|
|
||||||
|
assert result > 0
|
||||||
|
assert np.isfinite(result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_silverman_radius_small_dataset():
|
||||||
|
"""Test silverman_radius with small dataset."""
|
||||||
|
data = np.array([1.0, 2.0, 3.0])
|
||||||
|
|
||||||
|
result = silverman_radius(data)
|
||||||
|
|
||||||
|
assert result > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests for rce_train
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_rce_train_returns_dataframe():
|
||||||
|
"""Test rce_train returns a DataFrame."""
|
||||||
|
training_set = pd.DataFrame({'a': [1.0, 2.0, 3.0, 4.0, 5.0], 'b': [2.0, 3.0, 4.0, 5.0, 6.0]})
|
||||||
|
|
||||||
|
result = rce_train(training_set, 0.1)
|
||||||
|
|
||||||
|
assert isinstance(result, pd.DataFrame)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rce_train_includes_first_vector():
|
||||||
|
"""Test rce_train always includes the first vector as a prototype."""
|
||||||
|
training_set = pd.DataFrame({'a': [1.0, 2.0, 3.0], 'b': [1.0, 2.0, 3.0]})
|
||||||
|
|
||||||
|
result = rce_train(training_set, 0.1)
|
||||||
|
|
||||||
|
assert len(result) >= 1
|
||||||
|
assert result.iloc[0].tolist() == [1.0, 1.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rce_train_with_identical_vectors():
|
||||||
|
"""Test rce_train with identical vectors returns single prototype."""
|
||||||
|
training_set = pd.DataFrame({'a': [1.0, 1.0, 1.0], 'b': [2.0, 2.0, 2.0]})
|
||||||
|
|
||||||
|
result = rce_train(training_set, 0.1)
|
||||||
|
|
||||||
|
# All vectors are identical, so only one prototype should be created
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_rce_train_with_distant_vectors():
|
||||||
|
"""Test rce_train with very distant vectors creates multiple prototypes."""
|
||||||
|
training_set = pd.DataFrame({'a': [0.0, 100.0, 200.0], 'b': [0.0, 100.0, 200.0]})
|
||||||
|
|
||||||
|
result = rce_train(training_set, 0.1)
|
||||||
|
|
||||||
|
# Distant vectors should create multiple prototypes
|
||||||
|
assert len(result) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests for rce_test
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_rce_test_returns_series():
|
||||||
|
"""Test rce_test returns a pandas Series."""
|
||||||
|
test_set = pd.DataFrame({'a': [1.5, 2.5], 'b': [1.5, 2.5]})
|
||||||
|
prototypes = pd.DataFrame({'a': [1.0, 3.0], 'b': [1.0, 3.0]})
|
||||||
|
|
||||||
|
result = rce_test(test_set, prototypes)
|
||||||
|
|
||||||
|
assert isinstance(result, pd.Series)
|
||||||
|
assert len(result) == len(test_set)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rce_test_with_exact_match():
|
||||||
|
"""Test rce_test with test vector matching a prototype."""
|
||||||
|
test_set = pd.DataFrame({'a': [1.0], 'b': [2.0]})
|
||||||
|
prototypes = pd.DataFrame({'a': [1.0], 'b': [2.0]})
|
||||||
|
|
||||||
|
result = rce_test(test_set, prototypes)
|
||||||
|
|
||||||
|
# Distance should be 0 for exact match
|
||||||
|
assert result.iloc[0] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_rce_test_multiple_prototypes():
|
||||||
|
"""Test rce_test finds closest prototype."""
|
||||||
|
test_set = pd.DataFrame({'a': [1.1], 'b': [1.1]})
|
||||||
|
prototypes = pd.DataFrame({'a': [1.0, 10.0], 'b': [1.0, 10.0]})
|
||||||
|
|
||||||
|
result = rce_test(test_set, prototypes)
|
||||||
|
|
||||||
|
# Should find the closest prototype (1.0, 1.0)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert np.isfinite(result.iloc[0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_rce_test_signed_distances():
|
||||||
|
"""Test rce_test returns signed distances."""
|
||||||
|
test_set = pd.DataFrame({'a': [0.0, 5.0], 'b': [0.0, 5.0]})
|
||||||
|
prototypes = pd.DataFrame({'a': [2.0], 'b': [2.0]})
|
||||||
|
|
||||||
|
result = rce_test(test_set, prototypes)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
# First test vector (0,0) is less than prototype (2,2) - should be negative
|
||||||
|
# Second test vector (5,5) is greater than prototype (2,2) - should be positive
|
||||||
|
assert result.iloc[0] < 0
|
||||||
|
assert result.iloc[1] > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests for rce_drift
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_rce_drift_returns_series():
|
||||||
|
"""Test rce_drift returns a pandas Series."""
|
||||||
|
reference_data = pd.DataFrame(
|
||||||
|
{
|
||||||
|
'feature1': [1.0, 2.0, 3.0, 4.0, 5.0],
|
||||||
|
'feature2': [2.0, 3.0, 4.0, 5.0, 6.0],
|
||||||
|
'target': [10.0, 20.0, 30.0, 40.0, 50.0],
|
||||||
|
'prediction': [11.0, 21.0, 31.0, 41.0, 51.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
real_data = pd.DataFrame(
|
||||||
|
{
|
||||||
|
'feature1': [1.5, 2.5],
|
||||||
|
'feature2': [2.5, 3.5],
|
||||||
|
'target': [15.0, 25.0],
|
||||||
|
'prediction': [16.0, 26.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = rce_drift(reference_data, real_data, 'target')
|
||||||
|
|
||||||
|
assert isinstance(result, pd.Series)
|
||||||
|
assert len(result) == len(real_data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rce_drift_with_target_column():
|
||||||
|
"""Test rce_drift using target column (drops prediction)."""
|
||||||
|
reference_data = pd.DataFrame(
|
||||||
|
{
|
||||||
|
'feature1': [1.0, 2.0, 3.0],
|
||||||
|
'target': [10.0, 20.0, 30.0],
|
||||||
|
'prediction': [11.0, 21.0, 31.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
real_data = pd.DataFrame(
|
||||||
|
{
|
||||||
|
'feature1': [1.5],
|
||||||
|
'target': [15.0],
|
||||||
|
'prediction': [16.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = rce_drift(reference_data, real_data, 'target')
|
||||||
|
|
||||||
|
assert isinstance(result, pd.Series)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_rce_drift_with_prediction_column():
|
||||||
|
"""Test rce_drift using prediction column (drops target)."""
|
||||||
|
reference_data = pd.DataFrame(
|
||||||
|
{
|
||||||
|
'feature1': [1.0, 2.0, 3.0],
|
||||||
|
'target': [10.0, 20.0, 30.0],
|
||||||
|
'prediction': [11.0, 21.0, 31.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
real_data = pd.DataFrame(
|
||||||
|
{
|
||||||
|
'feature1': [1.5],
|
||||||
|
'target': [15.0],
|
||||||
|
'prediction': [16.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = rce_drift(reference_data, real_data, 'prediction')
|
||||||
|
|
||||||
|
assert isinstance(result, pd.Series)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_rce_drift_normalized_output():
|
||||||
|
"""Test rce_drift returns normalized distances."""
|
||||||
|
reference_data = pd.DataFrame(
|
||||||
|
{
|
||||||
|
'feature1': [1.0, 2.0, 3.0, 4.0, 5.0],
|
||||||
|
'target': [10.0, 20.0, 30.0, 40.0, 50.0],
|
||||||
|
'prediction': [10.0, 20.0, 30.0, 40.0, 50.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
real_data = pd.DataFrame(
|
||||||
|
{
|
||||||
|
'feature1': [2.5, 3.5],
|
||||||
|
'target': [25.0, 35.0],
|
||||||
|
'prediction': [25.0, 35.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = rce_drift(reference_data, real_data, 'target')
|
||||||
|
|
||||||
|
# Result should be a Series with same length as real_data
|
||||||
|
assert isinstance(result, pd.Series)
|
||||||
|
assert len(result) == len(real_data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rce_drift_handles_common_columns():
|
||||||
|
"""Test rce_drift correctly handles common columns between datasets."""
|
||||||
|
reference_data = pd.DataFrame(
|
||||||
|
{
|
||||||
|
'feature1': [1.0, 2.0, 3.0],
|
||||||
|
'feature2': [2.0, 3.0, 4.0],
|
||||||
|
'extra_ref': [100.0, 200.0, 300.0],
|
||||||
|
'target': [10.0, 20.0, 30.0],
|
||||||
|
'prediction': [11.0, 21.0, 31.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
real_data = pd.DataFrame(
|
||||||
|
{
|
||||||
|
'feature1': [1.5],
|
||||||
|
'feature2': [2.5],
|
||||||
|
'extra_real': [150.0],
|
||||||
|
'target': [15.0],
|
||||||
|
'prediction': [16.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = rce_drift(reference_data, real_data, 'target')
|
||||||
|
|
||||||
|
# Should work with only common columns
|
||||||
|
assert isinstance(result, pd.Series)
|
||||||
|
assert len(result) == 1
|
||||||
437
tests/sientia/test_reports.py
Normal file
437
tests/sientia/test_reports.py
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
try:
|
||||||
|
from model_manager.sientia import reports
|
||||||
|
except ImportError as exc:
|
||||||
|
pytest.skip(
|
||||||
|
f'reports requires Evidently API matching production pin: {exc}',
|
||||||
|
allow_module_level=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stub_color_options(monkeypatch):
|
||||||
|
def fake_color_options(**kwargs):
|
||||||
|
return dict(kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(reports, 'ColorOptions', fake_color_options)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_html_from_file_success(tmp_path):
|
||||||
|
sample_file = tmp_path / 'sample.html'
|
||||||
|
sample_file.write_text('<p>Hello</p>', encoding='utf-8')
|
||||||
|
|
||||||
|
content = reports.load_html_from_file(str(sample_file))
|
||||||
|
|
||||||
|
assert content == '<p>Hello</p>'
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_html_from_file_missing_file():
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
reports.load_html_from_file('non-existent.html')
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_html_from_file_os_error(monkeypatch):
|
||||||
|
def fake_open(*_args, **_kwargs):
|
||||||
|
raise OSError('boom')
|
||||||
|
|
||||||
|
monkeypatch.setattr('builtins.open', fake_open)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match='boom'):
|
||||||
|
reports.load_html_from_file('path.html')
|
||||||
|
|
||||||
|
|
||||||
|
def test_inject_content_replaces_section():
|
||||||
|
main_html = "<html><body><div id='target'>old</div></body></html>"
|
||||||
|
content = '<span>new</span>'
|
||||||
|
|
||||||
|
result = reports.inject_content(main_html, 'target', content)
|
||||||
|
|
||||||
|
soup = BeautifulSoup(result, 'html.parser')
|
||||||
|
section = soup.find(id='target')
|
||||||
|
assert section is not None
|
||||||
|
assert section.find('span').text == 'new'
|
||||||
|
|
||||||
|
|
||||||
|
def test_inject_content_missing_section():
|
||||||
|
main_html = "<html><body><div id='other'>keep</div></body></html>"
|
||||||
|
|
||||||
|
result = reports.inject_content(main_html, 'missing', '<p>ignored</p>')
|
||||||
|
|
||||||
|
# Content should be unchanged when section is missing
|
||||||
|
soup = BeautifulSoup(result, 'html.parser')
|
||||||
|
assert soup.find(id='other') is not None
|
||||||
|
assert soup.find(id='other').text == 'keep'
|
||||||
|
|
||||||
|
|
||||||
|
def test_reports_init_sets_defaults(stub_color_options):
|
||||||
|
report = reports.Reports(reference_data='ref', current_data='cur', target_name='target')
|
||||||
|
|
||||||
|
assert report.metrics == []
|
||||||
|
assert isinstance(report.options, list) and len(report.options) == 1
|
||||||
|
assert report.sections == {}
|
||||||
|
assert report.base_path is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_data_quality_section_without_run(monkeypatch, stub_color_options):
|
||||||
|
monkeypatch.setattr(reports, 'DatasetSummaryMetric', lambda: 'summary')
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reports,
|
||||||
|
'generate_column_metrics',
|
||||||
|
lambda *args, **kwargs: ('columns', kwargs),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(reports, 'ConflictTargetMetric', lambda: 'conflict')
|
||||||
|
monkeypatch.setattr(reports, 'DatasetCorrelationsMetric', lambda: 'correlations')
|
||||||
|
|
||||||
|
report = reports.Reports(reference_data='ref', current_data='cur', target_name='target')
|
||||||
|
report.add_data_quality_section(columns=['col'], run=False)
|
||||||
|
|
||||||
|
assert report.metrics[-4:] == [
|
||||||
|
'summary',
|
||||||
|
('columns', {'columns': ['col'], 'skip_id_column': True}),
|
||||||
|
'conflict',
|
||||||
|
'correlations',
|
||||||
|
]
|
||||||
|
assert 'data_quality' not in report.sections
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_data_quality_section_with_run(monkeypatch, tmp_path, stub_color_options):
|
||||||
|
summary = object()
|
||||||
|
column_metrics = object()
|
||||||
|
conflict = object()
|
||||||
|
correlations = object()
|
||||||
|
monkeypatch.setattr(reports, 'DatasetSummaryMetric', lambda: summary)
|
||||||
|
|
||||||
|
def fake_generate_column_metrics(*_args, **kwargs):
|
||||||
|
return column_metrics
|
||||||
|
|
||||||
|
monkeypatch.setattr(reports, 'generate_column_metrics', fake_generate_column_metrics)
|
||||||
|
monkeypatch.setattr(reports, 'ConflictTargetMetric', lambda: conflict)
|
||||||
|
monkeypatch.setattr(reports, 'DatasetCorrelationsMetric', lambda: correlations)
|
||||||
|
|
||||||
|
report_instance = MagicMock()
|
||||||
|
report_instance.as_dict.return_value = {'result': 'data_quality'}
|
||||||
|
ReportMock = MagicMock(return_value=report_instance)
|
||||||
|
monkeypatch.setattr(reports, 'Report', ReportMock)
|
||||||
|
|
||||||
|
report = reports.Reports(
|
||||||
|
reference_data='ref', current_data='cur', target_name='target', base_path=str(tmp_path)
|
||||||
|
)
|
||||||
|
report.add_data_quality_section(columns=['c1'], run=True)
|
||||||
|
|
||||||
|
assert report.metrics[-4:] == [summary, column_metrics, conflict, correlations]
|
||||||
|
assert report.sections['data_quality'] == {'result': 'data_quality'}
|
||||||
|
ReportMock.assert_called_once_with(
|
||||||
|
metrics=[summary, column_metrics, conflict, correlations], options=report.options
|
||||||
|
)
|
||||||
|
run_kwargs = report_instance.run.call_args.kwargs
|
||||||
|
assert run_kwargs['reference_data'] == 'ref'
|
||||||
|
assert run_kwargs['current_data'] == 'cur'
|
||||||
|
assert run_kwargs['column_mapping'].target == 'target'
|
||||||
|
report_instance.save_html.assert_called_once_with(
|
||||||
|
os.path.join(str(tmp_path), 'data_quality.html')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_data_quality_section_run_without_base_path(monkeypatch, stub_color_options):
|
||||||
|
summary = object()
|
||||||
|
column_metrics = object()
|
||||||
|
conflict = object()
|
||||||
|
correlations = object()
|
||||||
|
monkeypatch.setattr(reports, 'DatasetSummaryMetric', lambda: summary)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reports,
|
||||||
|
'generate_column_metrics',
|
||||||
|
lambda *args, **kwargs: column_metrics,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(reports, 'ConflictTargetMetric', lambda: conflict)
|
||||||
|
monkeypatch.setattr(reports, 'DatasetCorrelationsMetric', lambda: correlations)
|
||||||
|
|
||||||
|
report_instance = MagicMock()
|
||||||
|
report_instance.as_dict.return_value = {'result': 'quality'}
|
||||||
|
ReportMock = MagicMock(return_value=report_instance)
|
||||||
|
monkeypatch.setattr(reports, 'Report', ReportMock)
|
||||||
|
|
||||||
|
report = reports.Reports(reference_data='ref', current_data='cur', target_name='target')
|
||||||
|
report.add_data_quality_section(run=True)
|
||||||
|
|
||||||
|
assert report.sections['data_quality'] == {'result': 'quality'}
|
||||||
|
report_instance.save_html.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_data_quality_section_non_default_target_keeps_conflict_metric(
|
||||||
|
monkeypatch, stub_color_options
|
||||||
|
):
|
||||||
|
summary = object()
|
||||||
|
column_metrics = object()
|
||||||
|
conflict = object()
|
||||||
|
correlations = object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(reports, 'DatasetSummaryMetric', lambda: summary)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reports,
|
||||||
|
'generate_column_metrics',
|
||||||
|
lambda *args, **kwargs: column_metrics,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(reports, 'ConflictTargetMetric', lambda: conflict)
|
||||||
|
monkeypatch.setattr(reports, 'DatasetCorrelationsMetric', lambda: correlations)
|
||||||
|
|
||||||
|
report = reports.Reports(reference_data='ref', current_data='cur', target_name='sales')
|
||||||
|
report.add_data_quality_section(columns=['c1'], run=False)
|
||||||
|
|
||||||
|
assert report.metrics[-4:] == [
|
||||||
|
summary,
|
||||||
|
column_metrics,
|
||||||
|
conflict,
|
||||||
|
correlations,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_data_drift_section_paths(monkeypatch, tmp_path, stub_color_options):
|
||||||
|
drift_instances = [object(), object(), object()]
|
||||||
|
DataDriftPresetMock = MagicMock(side_effect=drift_instances)
|
||||||
|
monkeypatch.setattr(reports, 'DataDriftPreset', DataDriftPresetMock)
|
||||||
|
|
||||||
|
report_instance = MagicMock()
|
||||||
|
report_instance.as_dict.return_value = {'result': 'data_drift'}
|
||||||
|
ReportMock = MagicMock(return_value=report_instance)
|
||||||
|
monkeypatch.setattr(reports, 'Report', ReportMock)
|
||||||
|
|
||||||
|
report = reports.Reports(
|
||||||
|
reference_data='ref', current_data='cur', target_name='target', base_path=str(tmp_path)
|
||||||
|
)
|
||||||
|
report.add_data_drift_section(columns=['c1'], run=False)
|
||||||
|
assert report.metrics[-1] == drift_instances[0]
|
||||||
|
assert 'data_drift' not in report.sections
|
||||||
|
|
||||||
|
report.add_data_drift_section(columns=['c1'], run=True)
|
||||||
|
assert report.sections['data_drift'] == {'result': 'data_drift'}
|
||||||
|
ReportMock.assert_called_with(metrics=[drift_instances[2]], options=report.options)
|
||||||
|
run_kwargs = report_instance.run.call_args.kwargs
|
||||||
|
assert run_kwargs['reference_data'] == 'ref'
|
||||||
|
assert run_kwargs['current_data'] == 'cur'
|
||||||
|
assert run_kwargs['column_mapping'].target == 'target'
|
||||||
|
report_instance.save_html.assert_called_with(os.path.join(str(tmp_path), 'data_drift.html'))
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_data_drift_section_run_without_base_path(monkeypatch, stub_color_options):
|
||||||
|
drift_instances = [object(), object(), object()]
|
||||||
|
DataDriftPresetMock = MagicMock(side_effect=drift_instances)
|
||||||
|
monkeypatch.setattr(reports, 'DataDriftPreset', DataDriftPresetMock)
|
||||||
|
|
||||||
|
report_instance = MagicMock()
|
||||||
|
report_instance.as_dict.return_value = {'result': 'drift'}
|
||||||
|
ReportMock = MagicMock(return_value=report_instance)
|
||||||
|
monkeypatch.setattr(reports, 'Report', ReportMock)
|
||||||
|
|
||||||
|
report = reports.Reports(reference_data='ref', current_data='cur', target_name='target')
|
||||||
|
report.add_data_drift_section(run=True)
|
||||||
|
|
||||||
|
assert report.sections['data_drift'] == {'result': 'drift'}
|
||||||
|
report_instance.save_html.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_regression_section(monkeypatch, tmp_path, stub_color_options):
|
||||||
|
regression_metrics = [object() for _ in range(7)]
|
||||||
|
monkeypatch.setattr(reports, 'RegressionPerformanceMetrics', lambda: regression_metrics[0])
|
||||||
|
monkeypatch.setattr(reports, 'RegressionDummyMetric', lambda: regression_metrics[1])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reports, 'RegressionPredictedVsActualScatter', lambda: regression_metrics[2]
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(reports, 'RegressionPredictedVsActualPlot', lambda: regression_metrics[3])
|
||||||
|
monkeypatch.setattr(reports, 'RegressionErrorPlot', lambda: regression_metrics[4])
|
||||||
|
monkeypatch.setattr(reports, 'RegressionAbsPercentageErrorPlot', lambda: regression_metrics[5])
|
||||||
|
monkeypatch.setattr(reports, 'RegressionErrorDistribution', lambda: regression_metrics[6])
|
||||||
|
|
||||||
|
report_instance = MagicMock()
|
||||||
|
report_instance.as_dict.return_value = {'result': 'regression'}
|
||||||
|
ReportMock = MagicMock(return_value=report_instance)
|
||||||
|
monkeypatch.setattr(reports, 'Report', ReportMock)
|
||||||
|
|
||||||
|
report = reports.Reports(
|
||||||
|
reference_data='ref', current_data='cur', target_name='target', base_path=str(tmp_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
report.add_regression_section(run=False)
|
||||||
|
assert report.metrics[-7:] == regression_metrics
|
||||||
|
assert 'regression' not in report.sections
|
||||||
|
|
||||||
|
report.add_regression_section(run=True)
|
||||||
|
assert report.sections['regression'] == {'result': 'regression'}
|
||||||
|
ReportMock.assert_called_with(metrics=regression_metrics, options=report.options)
|
||||||
|
report_instance.run.assert_called_with(
|
||||||
|
reference_data='ref',
|
||||||
|
current_data='cur',
|
||||||
|
column_mapping=report_instance.run.call_args.kwargs['column_mapping'],
|
||||||
|
)
|
||||||
|
report_instance.save_html.assert_called_with(os.path.join(str(tmp_path), 'regression.html'))
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_regression_section_run_without_base_path(monkeypatch, stub_color_options):
|
||||||
|
regression_metrics = [object() for _ in range(7)]
|
||||||
|
monkeypatch.setattr(reports, 'RegressionPerformanceMetrics', lambda: regression_metrics[0])
|
||||||
|
monkeypatch.setattr(reports, 'RegressionDummyMetric', lambda: regression_metrics[1])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reports, 'RegressionPredictedVsActualScatter', lambda: regression_metrics[2]
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(reports, 'RegressionPredictedVsActualPlot', lambda: regression_metrics[3])
|
||||||
|
monkeypatch.setattr(reports, 'RegressionErrorPlot', lambda: regression_metrics[4])
|
||||||
|
monkeypatch.setattr(reports, 'RegressionAbsPercentageErrorPlot', lambda: regression_metrics[5])
|
||||||
|
monkeypatch.setattr(reports, 'RegressionErrorDistribution', lambda: regression_metrics[6])
|
||||||
|
|
||||||
|
report_instance = MagicMock()
|
||||||
|
report_instance.as_dict.return_value = {'result': 'reg'}
|
||||||
|
ReportMock = MagicMock(return_value=report_instance)
|
||||||
|
monkeypatch.setattr(reports, 'Report', ReportMock)
|
||||||
|
|
||||||
|
report = reports.Reports(reference_data='ref', current_data='cur', target_name='target')
|
||||||
|
report.add_regression_section(run=True)
|
||||||
|
|
||||||
|
assert report.sections['regression'] == {'result': 'reg'}
|
||||||
|
report_instance.save_html.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_color_options_appends(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def color_options_mock(**kwargs):
|
||||||
|
calls.append(kwargs)
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
monkeypatch.setattr(reports, 'ColorOptions', color_options_mock)
|
||||||
|
|
||||||
|
report = reports.Reports(reference_data='ref', current_data='cur', target_name='target')
|
||||||
|
report.set_color_options(primary_color='#111', secondary_color='#222')
|
||||||
|
|
||||||
|
options = report.options
|
||||||
|
assert options is not None
|
||||||
|
assert len(options) == 2
|
||||||
|
assert calls[0]['primary_color'] == '#0F4C81'
|
||||||
|
assert calls[1]['primary_color'] == '#111'
|
||||||
|
assert options[1]['secondary_color'] == '#222'
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_all_sections_html_requires_base_path(stub_color_options):
|
||||||
|
report = reports.Reports(reference_data='ref', current_data='cur', target_name='target')
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
report.save_all_sections_html('output/report.html')
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_all_sections_html_requires_template_path(stub_color_options, tmp_path):
|
||||||
|
report = reports.Reports(
|
||||||
|
reference_data='ref',
|
||||||
|
current_data='cur',
|
||||||
|
target_name='target',
|
||||||
|
base_path=str(tmp_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='template_path is required'):
|
||||||
|
report.save_all_sections_html('output/report.html')
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_all_sections_html_writes_output(tmp_path, stub_color_options):
|
||||||
|
base_dir = tmp_path / 'templates'
|
||||||
|
base_dir.mkdir()
|
||||||
|
(base_dir / 'header.html').write_text(
|
||||||
|
"<html><body><div id='data_drift'></div><div id='data_quality'></div><div id='regression'></div></body></html>",
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
(base_dir / 'data_drift.html').write_text('<p>Drift</p>', encoding='utf-8')
|
||||||
|
(base_dir / 'data_quality.html').write_text('<p>Quality</p>', encoding='utf-8')
|
||||||
|
(base_dir / 'regression.html').write_text('<p>Regression</p>', encoding='utf-8')
|
||||||
|
|
||||||
|
report = reports.Reports(
|
||||||
|
reference_data='ref',
|
||||||
|
current_data='cur',
|
||||||
|
target_name='target',
|
||||||
|
base_path=str(base_dir),
|
||||||
|
template_path=str(base_dir),
|
||||||
|
)
|
||||||
|
output_path = tmp_path / 'reports' / 'combined.html'
|
||||||
|
|
||||||
|
report.save_all_sections_html(str(output_path))
|
||||||
|
|
||||||
|
assert output_path.exists()
|
||||||
|
content = output_path.read_text(encoding='utf-8')
|
||||||
|
assert '<p>Drift</p>' in content
|
||||||
|
assert '<p>Quality</p>' in content
|
||||||
|
assert '<p>Regression</p>' in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_all_sections_html_creates_directory(monkeypatch, tmp_path, stub_color_options):
|
||||||
|
base_dir = tmp_path / 'templates'
|
||||||
|
base_dir.mkdir()
|
||||||
|
(base_dir / 'header.html').write_text(
|
||||||
|
"<html><body><div id='data_drift'></div><div id='data_quality'></div><div id='regression'></div></body></html>",
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
(base_dir / 'data_drift.html').write_text('<p>Drift</p>', encoding='utf-8')
|
||||||
|
(base_dir / 'data_quality.html').write_text('<p>Quality</p>', encoding='utf-8')
|
||||||
|
(base_dir / 'regression.html').write_text('<p>Regression</p>', encoding='utf-8')
|
||||||
|
|
||||||
|
make_dirs_called = []
|
||||||
|
report = reports.Reports(
|
||||||
|
reference_data='ref',
|
||||||
|
current_data='cur',
|
||||||
|
target_name='target',
|
||||||
|
base_path=str(base_dir),
|
||||||
|
template_path=str(base_dir),
|
||||||
|
)
|
||||||
|
output_path = tmp_path / 'nested' / 'report.html'
|
||||||
|
output_dir = str(output_path.parent)
|
||||||
|
|
||||||
|
original_exists = os.path.exists
|
||||||
|
original_makedirs = os.makedirs
|
||||||
|
|
||||||
|
def fake_exists(path):
|
||||||
|
if path == output_dir:
|
||||||
|
return False
|
||||||
|
return original_exists(path)
|
||||||
|
|
||||||
|
def fake_makedirs(path, exist_ok=False):
|
||||||
|
make_dirs_called.append((path, exist_ok))
|
||||||
|
return original_makedirs(path, exist_ok=exist_ok)
|
||||||
|
|
||||||
|
monkeypatch.setattr(os.path, 'exists', fake_exists)
|
||||||
|
monkeypatch.setattr(os, 'makedirs', fake_makedirs)
|
||||||
|
|
||||||
|
report.save_all_sections_html(str(output_path))
|
||||||
|
|
||||||
|
assert make_dirs_called == [(str(output_path.parent), True)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_all_sections_html_no_directory_needed(monkeypatch, tmp_path, stub_color_options):
|
||||||
|
base_dir = tmp_path / 'templates'
|
||||||
|
base_dir.mkdir()
|
||||||
|
(base_dir / 'header.html').write_text(
|
||||||
|
"<html><body><div id='data_drift'></div><div id='data_quality'></div><div id='regression'></div></body></html>",
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
(base_dir / 'data_drift.html').write_text('<p>Drift</p>', encoding='utf-8')
|
||||||
|
(base_dir / 'data_quality.html').write_text('<p>Quality</p>', encoding='utf-8')
|
||||||
|
(base_dir / 'regression.html').write_text('<p>Regression</p>', encoding='utf-8')
|
||||||
|
|
||||||
|
mk_calls = []
|
||||||
|
|
||||||
|
def fake_makedirs(path, exist_ok=False):
|
||||||
|
mk_calls.append((path, exist_ok))
|
||||||
|
|
||||||
|
monkeypatch.setattr(os, 'makedirs', fake_makedirs)
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
|
report = reports.Reports(
|
||||||
|
reference_data='ref',
|
||||||
|
current_data='cur',
|
||||||
|
target_name='target',
|
||||||
|
base_path=str(base_dir),
|
||||||
|
template_path=str(base_dir),
|
||||||
|
)
|
||||||
|
report.save_all_sections_html('report.html')
|
||||||
|
|
||||||
|
assert mk_calls == []
|
||||||
|
assert (tmp_path / 'report.html').exists()
|
||||||
379
tests/test_metrics.py
Normal file
379
tests/test_metrics.py
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
"""Unit tests for model_manager.metrics module.
|
||||||
|
|
||||||
|
This module tests the Prometheus metrics configuration used for
|
||||||
|
monitoring and observability in the Sientia DataOps Model Manager.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_up_metric_exists():
|
||||||
|
"""Test that APP_UP metric is properly defined."""
|
||||||
|
from model_manager.metrics import APP_UP
|
||||||
|
|
||||||
|
assert APP_UP is not None
|
||||||
|
assert APP_UP._name == 'app_up'
|
||||||
|
assert (
|
||||||
|
APP_UP._documentation == 'Indicates if the application is running (1) or shutting down (0)'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_up_metric_has_pod_id_label():
|
||||||
|
"""Test that APP_UP metric has pod_id label."""
|
||||||
|
from model_manager.metrics import APP_UP
|
||||||
|
|
||||||
|
assert 'pod_id' in APP_UP._labelnames
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_up_metric_is_gauge():
|
||||||
|
"""Test that APP_UP is a Gauge metric."""
|
||||||
|
from prometheus_client import Gauge
|
||||||
|
|
||||||
|
from model_manager.metrics import APP_UP
|
||||||
|
|
||||||
|
assert isinstance(APP_UP, Gauge)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_up_metric_can_be_set_to_one():
|
||||||
|
"""Test that APP_UP metric can be set to 1 (running)."""
|
||||||
|
from model_manager.metrics import APP_UP
|
||||||
|
|
||||||
|
# Set metric to 1 for a specific pod
|
||||||
|
APP_UP.labels(pod_id='test-pod-1').set(1)
|
||||||
|
|
||||||
|
# Verify the metric value
|
||||||
|
metric_value = APP_UP.labels(pod_id='test-pod-1')._value._value
|
||||||
|
assert metric_value == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_up_metric_can_be_set_to_zero():
|
||||||
|
"""Test that APP_UP metric can be set to 0 (shutting down)."""
|
||||||
|
from model_manager.metrics import APP_UP
|
||||||
|
|
||||||
|
# Set metric to 0 for a specific pod
|
||||||
|
APP_UP.labels(pod_id='test-pod-2').set(0)
|
||||||
|
|
||||||
|
# Verify the metric value
|
||||||
|
metric_value = APP_UP.labels(pod_id='test-pod-2')._value._value
|
||||||
|
assert metric_value == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_up_metric_multiple_pods():
|
||||||
|
"""Test that APP_UP metric can track multiple pods independently."""
|
||||||
|
from model_manager.metrics import APP_UP
|
||||||
|
|
||||||
|
# Set different values for different pods
|
||||||
|
APP_UP.labels(pod_id='pod-1').set(1)
|
||||||
|
APP_UP.labels(pod_id='pod-2').set(0)
|
||||||
|
APP_UP.labels(pod_id='pod-3').set(1)
|
||||||
|
|
||||||
|
# Verify each pod has correct value
|
||||||
|
assert APP_UP.labels(pod_id='pod-1')._value._value == 1
|
||||||
|
assert APP_UP.labels(pod_id='pod-2')._value._value == 0
|
||||||
|
assert APP_UP.labels(pod_id='pod-3')._value._value == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_up_metric_default_value():
|
||||||
|
"""Test that APP_UP metric starts with no value set."""
|
||||||
|
# Create a new label that hasn't been used yet
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from model_manager.metrics import APP_UP
|
||||||
|
|
||||||
|
unique_pod = f'test-pod-{uuid.uuid4()}'
|
||||||
|
|
||||||
|
# The metric should exist but not have a value until set
|
||||||
|
metric = APP_UP.labels(pod_id=unique_pod)
|
||||||
|
assert metric is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_module_imports():
|
||||||
|
"""Test that metrics module can be imported successfully."""
|
||||||
|
import model_manager.metrics
|
||||||
|
|
||||||
|
assert hasattr(model_manager.metrics, 'APP_UP')
|
||||||
|
assert hasattr(model_manager.metrics, 'Gauge')
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_module_docstring():
|
||||||
|
"""Test that metrics module has proper documentation."""
|
||||||
|
import model_manager.metrics
|
||||||
|
|
||||||
|
assert model_manager.metrics.__doc__ is not None
|
||||||
|
assert 'Prometheus' in model_manager.metrics.__doc__
|
||||||
|
assert 'metrics' in model_manager.metrics.__doc__
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_up_metric_can_increment():
|
||||||
|
"""Test that APP_UP metric value can be incremented."""
|
||||||
|
from model_manager.metrics import APP_UP
|
||||||
|
|
||||||
|
pod_id = 'test-pod-increment'
|
||||||
|
APP_UP.labels(pod_id=pod_id).set(0)
|
||||||
|
|
||||||
|
# Increment the metric
|
||||||
|
APP_UP.labels(pod_id=pod_id).inc()
|
||||||
|
|
||||||
|
metric_value = APP_UP.labels(pod_id=pod_id)._value._value
|
||||||
|
assert metric_value == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_up_metric_can_decrement():
|
||||||
|
"""Test that APP_UP metric value can be decremented."""
|
||||||
|
from model_manager.metrics import APP_UP
|
||||||
|
|
||||||
|
pod_id = 'test-pod-decrement'
|
||||||
|
APP_UP.labels(pod_id=pod_id).set(1)
|
||||||
|
|
||||||
|
# Decrement the metric
|
||||||
|
APP_UP.labels(pod_id=pod_id).dec()
|
||||||
|
|
||||||
|
metric_value = APP_UP.labels(pod_id=pod_id)._value._value
|
||||||
|
assert metric_value == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_up_metric_set_to_timestamp():
|
||||||
|
"""Test that APP_UP metric can be set to current timestamp."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from model_manager.metrics import APP_UP
|
||||||
|
|
||||||
|
pod_id = 'test-pod-timestamp'
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Set to timestamp
|
||||||
|
APP_UP.labels(pod_id=pod_id).set_to_current_time()
|
||||||
|
|
||||||
|
metric_value = APP_UP.labels(pod_id=pod_id)._value._value
|
||||||
|
|
||||||
|
# Should be close to current time
|
||||||
|
assert abs(metric_value - current_time) < 2 # Within 2 seconds
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_up_metric_label_validation():
|
||||||
|
"""Test that APP_UP metric validates label names."""
|
||||||
|
from model_manager.metrics import APP_UP
|
||||||
|
|
||||||
|
# Should work with valid label
|
||||||
|
APP_UP.labels(pod_id='valid-pod-name').set(1)
|
||||||
|
|
||||||
|
# Should work with empty string (though not recommended)
|
||||||
|
APP_UP.labels(pod_id='').set(1)
|
||||||
|
|
||||||
|
# Should work with special characters
|
||||||
|
APP_UP.labels(pod_id='pod-123_test.example').set(1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_exports():
|
||||||
|
"""Test that metrics module exports expected symbols."""
|
||||||
|
import model_manager.metrics as metrics_module
|
||||||
|
|
||||||
|
# Check that module has the expected exports
|
||||||
|
module_contents = dir(metrics_module)
|
||||||
|
|
||||||
|
assert 'APP_UP' in module_contents
|
||||||
|
assert 'Gauge' in module_contents
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_up_metric_thread_safety():
|
||||||
|
"""Test that APP_UP metric is thread-safe."""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from model_manager.metrics import APP_UP
|
||||||
|
|
||||||
|
pod_id = 'test-pod-threading'
|
||||||
|
APP_UP.labels(pod_id=pod_id).set(0)
|
||||||
|
|
||||||
|
def increment_metric():
|
||||||
|
for _ in range(100):
|
||||||
|
APP_UP.labels(pod_id=pod_id).inc()
|
||||||
|
|
||||||
|
# Create multiple threads that increment the metric
|
||||||
|
threads = [threading.Thread(target=increment_metric) for _ in range(5)]
|
||||||
|
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
for thread in threads:
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
# Should have incremented 500 times total
|
||||||
|
metric_value = APP_UP.labels(pod_id=pod_id)._value._value
|
||||||
|
assert metric_value == 500
|
||||||
|
|
||||||
|
|
||||||
|
def test_prometheus_client_gauge_import():
|
||||||
|
"""Test that Gauge is properly imported from prometheus_client."""
|
||||||
|
from prometheus_client import Gauge as PrometheusGauge
|
||||||
|
|
||||||
|
from model_manager.metrics import Gauge
|
||||||
|
|
||||||
|
assert Gauge is PrometheusGauge
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Training metrics — existence, type, and labels
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_TRAINING_LABEL_NAMES = ('pod_id', 'model_name', 'model_type')
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_training_labels(metric):
|
||||||
|
for label in _TRAINING_LABEL_NAMES:
|
||||||
|
assert label in metric._labelnames
|
||||||
|
|
||||||
|
|
||||||
|
def test_sientia_training_data_preparation_lag_is_histogram():
|
||||||
|
from prometheus_client import Histogram
|
||||||
|
|
||||||
|
from model_manager.metrics import SIENTIA_TRAINING_DATA_PREPARATION_LAG
|
||||||
|
|
||||||
|
assert isinstance(SIENTIA_TRAINING_DATA_PREPARATION_LAG, Histogram)
|
||||||
|
assert SIENTIA_TRAINING_DATA_PREPARATION_LAG._name == 'sientia_training_data_preparation_lag'
|
||||||
|
_assert_training_labels(SIENTIA_TRAINING_DATA_PREPARATION_LAG)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sientia_training_data_preparation_error_count_total_is_counter():
|
||||||
|
from prometheus_client import Counter
|
||||||
|
|
||||||
|
from model_manager.metrics import SIENTIA_TRAINING_DATA_PREPARATION_ERROR_COUNT_TOTAL
|
||||||
|
|
||||||
|
assert isinstance(SIENTIA_TRAINING_DATA_PREPARATION_ERROR_COUNT_TOTAL, Counter)
|
||||||
|
assert 'sientia_training_data_preparation_error_count' in SIENTIA_TRAINING_DATA_PREPARATION_ERROR_COUNT_TOTAL._name
|
||||||
|
_assert_training_labels(SIENTIA_TRAINING_DATA_PREPARATION_ERROR_COUNT_TOTAL)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sientia_training_model_fit_lag_is_histogram():
|
||||||
|
from prometheus_client import Histogram
|
||||||
|
|
||||||
|
from model_manager.metrics import SIENTIA_TRAINING_MODEL_FIT_LAG
|
||||||
|
|
||||||
|
assert isinstance(SIENTIA_TRAINING_MODEL_FIT_LAG, Histogram)
|
||||||
|
assert SIENTIA_TRAINING_MODEL_FIT_LAG._name == 'sientia_training_model_fit_lag'
|
||||||
|
_assert_training_labels(SIENTIA_TRAINING_MODEL_FIT_LAG)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sientia_training_model_fit_error_count_total_is_counter():
|
||||||
|
from prometheus_client import Counter
|
||||||
|
|
||||||
|
from model_manager.metrics import SIENTIA_TRAINING_MODEL_FIT_ERROR_COUNT_TOTAL
|
||||||
|
|
||||||
|
assert isinstance(SIENTIA_TRAINING_MODEL_FIT_ERROR_COUNT_TOTAL, Counter)
|
||||||
|
assert 'sientia_training_model_fit_error_count' in SIENTIA_TRAINING_MODEL_FIT_ERROR_COUNT_TOTAL._name
|
||||||
|
_assert_training_labels(SIENTIA_TRAINING_MODEL_FIT_ERROR_COUNT_TOTAL)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sientia_training_model_quality_mse_is_gauge():
|
||||||
|
from prometheus_client import Gauge
|
||||||
|
|
||||||
|
from model_manager.metrics import SIENTIA_TRAINING_MODEL_QUALITY_MSE
|
||||||
|
|
||||||
|
assert isinstance(SIENTIA_TRAINING_MODEL_QUALITY_MSE, Gauge)
|
||||||
|
assert SIENTIA_TRAINING_MODEL_QUALITY_MSE._name == 'sientia_training_model_quality_mse'
|
||||||
|
_assert_training_labels(SIENTIA_TRAINING_MODEL_QUALITY_MSE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sientia_training_model_quality_mae_is_gauge():
|
||||||
|
from prometheus_client import Gauge
|
||||||
|
|
||||||
|
from model_manager.metrics import SIENTIA_TRAINING_MODEL_QUALITY_MAE
|
||||||
|
|
||||||
|
assert isinstance(SIENTIA_TRAINING_MODEL_QUALITY_MAE, Gauge)
|
||||||
|
assert SIENTIA_TRAINING_MODEL_QUALITY_MAE._name == 'sientia_training_model_quality_mae'
|
||||||
|
_assert_training_labels(SIENTIA_TRAINING_MODEL_QUALITY_MAE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sientia_training_model_quality_r2_is_gauge():
|
||||||
|
from prometheus_client import Gauge
|
||||||
|
|
||||||
|
from model_manager.metrics import SIENTIA_TRAINING_MODEL_QUALITY_R2
|
||||||
|
|
||||||
|
assert isinstance(SIENTIA_TRAINING_MODEL_QUALITY_R2, Gauge)
|
||||||
|
assert SIENTIA_TRAINING_MODEL_QUALITY_R2._name == 'sientia_training_model_quality_r2'
|
||||||
|
_assert_training_labels(SIENTIA_TRAINING_MODEL_QUALITY_R2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sientia_training_dataset_train_rows_is_gauge():
|
||||||
|
from prometheus_client import Gauge
|
||||||
|
|
||||||
|
from model_manager.metrics import SIENTIA_TRAINING_DATASET_TRAIN_ROWS
|
||||||
|
|
||||||
|
assert isinstance(SIENTIA_TRAINING_DATASET_TRAIN_ROWS, Gauge)
|
||||||
|
assert SIENTIA_TRAINING_DATASET_TRAIN_ROWS._name == 'sientia_training_dataset_train_rows'
|
||||||
|
_assert_training_labels(SIENTIA_TRAINING_DATASET_TRAIN_ROWS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sientia_training_dataset_val_rows_is_gauge():
|
||||||
|
from prometheus_client import Gauge
|
||||||
|
|
||||||
|
from model_manager.metrics import SIENTIA_TRAINING_DATASET_VAL_ROWS
|
||||||
|
|
||||||
|
assert isinstance(SIENTIA_TRAINING_DATASET_VAL_ROWS, Gauge)
|
||||||
|
assert SIENTIA_TRAINING_DATASET_VAL_ROWS._name == 'sientia_training_dataset_val_rows'
|
||||||
|
_assert_training_labels(SIENTIA_TRAINING_DATASET_VAL_ROWS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sientia_training_model_trained_total_is_counter():
|
||||||
|
from prometheus_client import Counter
|
||||||
|
|
||||||
|
from model_manager.metrics import SIENTIA_TRAINING_MODEL_TRAINED_TOTAL
|
||||||
|
|
||||||
|
assert isinstance(SIENTIA_TRAINING_MODEL_TRAINED_TOTAL, Counter)
|
||||||
|
assert 'sientia_training_model_trained' in SIENTIA_TRAINING_MODEL_TRAINED_TOTAL._name
|
||||||
|
_assert_training_labels(SIENTIA_TRAINING_MODEL_TRAINED_TOTAL)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sientia_training_feature_count_is_gauge():
|
||||||
|
from prometheus_client import Gauge
|
||||||
|
|
||||||
|
from model_manager.metrics import SIENTIA_TRAINING_FEATURE_COUNT
|
||||||
|
|
||||||
|
assert isinstance(SIENTIA_TRAINING_FEATURE_COUNT, Gauge)
|
||||||
|
assert SIENTIA_TRAINING_FEATURE_COUNT._name == 'sientia_training_feature_count'
|
||||||
|
_assert_training_labels(SIENTIA_TRAINING_FEATURE_COUNT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sientia_training_info_is_gauge():
|
||||||
|
from prometheus_client import Gauge
|
||||||
|
|
||||||
|
from model_manager.metrics import SIENTIA_TRAINING_INFO
|
||||||
|
|
||||||
|
assert isinstance(SIENTIA_TRAINING_INFO, Gauge)
|
||||||
|
assert SIENTIA_TRAINING_INFO._name == 'sientia_training_info'
|
||||||
|
|
||||||
|
expected_labels = {
|
||||||
|
'pod_id', 'model_name', 'model_type',
|
||||||
|
'dataset_train_rows', 'dataset_val_rows', 'feature_count',
|
||||||
|
'mse', 'mae', 'r2',
|
||||||
|
}
|
||||||
|
assert expected_labels == set(SIENTIA_TRAINING_INFO._labelnames)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sientia_training_info_set_value():
|
||||||
|
import time
|
||||||
|
|
||||||
|
from model_manager.metrics import SIENTIA_TRAINING_INFO
|
||||||
|
|
||||||
|
ts = time.time() * 1000
|
||||||
|
SIENTIA_TRAINING_INFO.labels(
|
||||||
|
pod_id='test-pod',
|
||||||
|
model_name='my_model',
|
||||||
|
model_type='linear',
|
||||||
|
dataset_train_rows='1000',
|
||||||
|
dataset_val_rows='200',
|
||||||
|
feature_count='5',
|
||||||
|
mse='0.01',
|
||||||
|
mae='0.08',
|
||||||
|
r2='0.95',
|
||||||
|
).set(ts)
|
||||||
|
|
||||||
|
value = SIENTIA_TRAINING_INFO.labels(
|
||||||
|
pod_id='test-pod',
|
||||||
|
model_name='my_model',
|
||||||
|
model_type='linear',
|
||||||
|
dataset_train_rows='1000',
|
||||||
|
dataset_val_rows='200',
|
||||||
|
feature_count='5',
|
||||||
|
mse='0.01',
|
||||||
|
mae='0.08',
|
||||||
|
r2='0.95',
|
||||||
|
)._value._value
|
||||||
|
assert abs(value - ts) < 2000
|
||||||
18
tests/test_runtime_paths.py
Normal file
18
tests/test_runtime_paths.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""Tests for runtime filesystem layout constants."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_runtime_directories_creates_expected_paths():
|
||||||
|
from model_manager.runtime_paths import (
|
||||||
|
LOGS_DIR,
|
||||||
|
REPORTS_ROOT,
|
||||||
|
REPORTS_TEMP_DIR,
|
||||||
|
ensure_runtime_directories,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('model_manager.runtime_paths.makedirs') as makedirs_mock:
|
||||||
|
ensure_runtime_directories()
|
||||||
|
|
||||||
|
created = {call.args[0] for call in makedirs_mock.call_args_list}
|
||||||
|
assert created == {REPORTS_ROOT, REPORTS_TEMP_DIR, LOGS_DIR}
|
||||||
0
tests/utils/__init__.py
Normal file
0
tests/utils/__init__.py
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user