From 9e7c94667eb0a9d122f763ecf574a36a1978d2c2 Mon Sep 17 00:00:00 2001 From: Ishaan Date: Tue, 30 Jun 2026 10:32:12 -0400 Subject: [PATCH 1/8] Remove unused LLM coordinator logic --- .cursor/rules/hpo-coordinator.mdc | 42 - .cursor/rules/hpo-onboarding.mdc | 16 - .cursor/skills/hpo-onboard/SKILL.md | 16 - pathfinder.egg-info/PKG-INFO | 302 ----- pathfinder.egg-info/SOURCES.txt | 45 - pathfinder.egg-info/dependency_links.txt | 1 - pathfinder.egg-info/entry_points.txt | 2 - pathfinder.egg-info/requires.txt | 15 - pathfinder.egg-info/top_level.txt | 4 - src/hpo_coordinator.py | 1305 ---------------------- tests/test_coordinator_packet.py | 98 -- tests/test_lean_roadmap.py | 124 -- 12 files changed, 1970 deletions(-) delete mode 100644 .cursor/rules/hpo-coordinator.mdc delete mode 100644 .cursor/rules/hpo-onboarding.mdc delete mode 100644 .cursor/skills/hpo-onboard/SKILL.md delete mode 100644 pathfinder.egg-info/PKG-INFO delete mode 100644 pathfinder.egg-info/SOURCES.txt delete mode 100644 pathfinder.egg-info/dependency_links.txt delete mode 100644 pathfinder.egg-info/entry_points.txt delete mode 100644 pathfinder.egg-info/requires.txt delete mode 100644 pathfinder.egg-info/top_level.txt delete mode 100644 src/hpo_coordinator.py delete mode 100644 tests/test_coordinator_packet.py delete mode 100644 tests/test_lean_roadmap.py diff --git a/.cursor/rules/hpo-coordinator.mdc b/.cursor/rules/hpo-coordinator.mdc deleted file mode 100644 index 54bfe8a..0000000 --- a/.cursor/rules/hpo-coordinator.mdc +++ /dev/null @@ -1,42 +0,0 @@ ---- -description: HPO coordinator review procedure (episodic, read-heavy, write-once per trial window) -globs: broker.py,hpo_mcp_server.py,hpo_coordinator.py,colab_worker.py,hpo_config.json,active_search_space.json,index.html,.hpo_status.json -alwaysApply: false ---- - -# Pathfinder Coordinator Review - -Optuna (TPE) in `broker.py` is the hot path. The Colab worker must never wait on a language model. You are an **episodic coordinator**: you interpret results, flag bad policy, and apply bounded changes through the `@pathfinder` MCP server. Do not turn the per-trial suggest into an LLM call. - -## Active Divergence Warning Trigger - -If the workspace status file `.hpo_status.json` has `health_tier` set to `"watch"` or `"intervene"`: -1. Immediately notify the user in your response that the Pathfinder study is in a warning state (`Watch` or `Intervene`) and explain the `health_reason` cited in the status file. -2. Proactively offer to execute the **Pathfinder Coordinator Review** (the 7-step procedure below). Do not wait for the user to ask. -3. Recommend using the `/goal` slash command to resolve the stagnation or suggesting specific active bounds changes. - -## When to run a review - -Only on an explicit user request, or when one of these is true. Do **not** auto-run from a hook. - -- The `.hpo_status.json` indicates a warning state (`Watch` or `Intervene`), or -- The dashboard shows "Coordinator review suggested", or -- ~5 completed/pruned trials have elapsed since the last review, or -- The user is about to change the search space or eval protocol, or -- The user starts editing tuning code (the files this rule is scoped to). - -Run a review **at most once per trial window** unless `review_recommended` is set or the user passes `force`. - -## Seven-step procedure - -1. Call `get_study_data(study_name)` to retrieve the compacted review packet (which contains active search space, config, and study telemetry). -2. Read dynamic metric labels from the packet's `project_context`. -3. Rate search health 1–5. Cite trials by number. Prefer fixed-eval (deploy) Dice over train Dice. -4. Perform safety review of VRAM predictions and check coordinator accuracy. -5. Pick exactly one policy action: `no_change`, `update_search_space` (propose), or `enqueue_one_manual_trial`. -6. If proposing active search space changes, call `update_search_space(study_name, space_config, apply=False)`. If enqueuing a manual trial, pass the parameter dictionary as the `manual_trial` argument when calling `submit_agent_review`. -7. Call `submit_agent_review(study_name, summary, health_rating, policy_action, reasons=...)` to persist the audit trail. - -## Multi-IDE safety - -`submit_agent_review` is idempotent per trial window. If Cursor and Antigravity are both open, a duplicate review for the same number of finished trials returns the existing review (`duplicate: true`); do not retry with `force` to override it unless the user asks. Coordinator work is read-heavy and write-once per cycle. diff --git a/.cursor/rules/hpo-onboarding.mdc b/.cursor/rules/hpo-onboarding.mdc deleted file mode 100644 index d15c7ac..0000000 --- a/.cursor/rules/hpo-onboarding.mdc +++ /dev/null @@ -1,16 +0,0 @@ ---- -description: HPO cloner onboarding - scaffold a thin worker against the broker without forking colab_worker.py -globs: templates/**,hpo_client.py,docs/INTEGRATION.md,AGENTS.md -alwaysApply: false ---- - -# Pathfinder Onboarding - -When the user asks to integrate HPO / onboard a training script, or edits these template/client files, follow the onboarding section of [AGENTS.md](../../AGENTS.md). - -- Offer a one-line "Run Pathfinder onboarding?" - do NOT write files until the user confirms. -- Scaffold from `templates/` using `hpo_client.TrialSession` (suggest / report_epoch / complete). Do NOT fork the root `colab_worker.py`. -- Use the `@pathfinder` MCP tools: `validate_manifest`, `init_from_manifest`, `validate_integration`. -- Never block the GPU worker on an LLM; never auto-run the coordinator. - -Scope note: this rule is for onboarding only. Production tuning files (`broker.py`, `hpo_mcp_server.py`, `colab_worker.py`, configs, `index.html`) are governed by `hpo-coordinator.mdc`, not this rule. diff --git a/.cursor/skills/hpo-onboard/SKILL.md b/.cursor/skills/hpo-onboard/SKILL.md deleted file mode 100644 index 3b7626f..0000000 --- a/.cursor/skills/hpo-onboard/SKILL.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -name: hpo-onboard -description: Onboard a training pipeline to Pathfinder. Use when the user wants to integrate HPO, onboard a training pipeline, set up a fresh clone, or wire hyperparameter tuning into their training script. ---- - -# Pathfinder Onboarding - -Follow AGENTS.md onboarding section; use @pathfinder MCP tools; do not modify root colab_worker.py unless user owns bridge-crack project. - -## Steps - -1. Read [AGENTS.md](../../../AGENTS.md) "Onboarding procedure" and follow it. -2. Load the MCP resource `hpo://prompts/grill` for the canonical onboarding checklist. -3. Offer a one-line "Run Pathfinder onboarding?" and do not write files until the user confirms. -4. Scaffold using templates/manifest.template.yaml and worker_minimal.py. Do not write json space config files. Validate and register the study using the MCP tools `validate_manifest` and `init_from_manifest`. -5. Finish by running the MCP tool `validate_integration(study_name)` to verify. diff --git a/pathfinder.egg-info/PKG-INFO b/pathfinder.egg-info/PKG-INFO deleted file mode 100644 index 96f3743..0000000 --- a/pathfinder.egg-info/PKG-INFO +++ /dev/null @@ -1,302 +0,0 @@ -Metadata-Version: 2.4 -Name: pathfinder -Version: 1.0.0 -Summary: Decoupled hyperparameter optimization (HPO) framework with periodically scheduled AI coordinator reviews -Author-email: Ishaan Patel -License: MIT -Classifier: Programming Language :: Python :: 3 -Classifier: License :: OSI Approved :: MIT License -Classifier: Operating System :: OS Independent -Requires-Python: >=3.10 -Description-Content-Type: text/markdown -License-File: LICENSE -Requires-Dist: mcp<2,>=1.1 -Requires-Dist: optuna<5,>=3.6 -Requires-Dist: optuna-dashboard<1,>=0.15 -Requires-Dist: sqlalchemy<3,>=2.0 -Requires-Dist: psycopg2-binary<3,>=2.9 -Requires-Dist: pydantic<3,>=2.0 -Requires-Dist: numpy<3,>=1.20 -Requires-Dist: scikit-learn<2,>=1.0 -Requires-Dist: fastapi<1,>=0.110 -Requires-Dist: uvicorn<1,>=0.23 -Requires-Dist: requests<3,>=2.28 -Requires-Dist: PyYAML<7,>=6.0 -Provides-Extra: dev -Requires-Dist: pytest>=8.0; extra == "dev" -Dynamic: license-file - -# Pathfinder - -[![Python 3.10+](https://img.shields.io/badge/python-3.10+-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/downloads/) -[![FastAPI](https://img.shields.io/badge/FastAPI-009688?style=flat-square&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com/) -[![Optuna](https://img.shields.io/badge/Optuna-Tuning-1E90FF?style=flat-square)](https://optuna.org/) -[![SQLite](https://img.shields.io/badge/SQLite-003B57?style=flat-square&logo=sqlite&logoColor=white)](https://www.sqlite.org/) -[![MCP](https://img.shields.io/badge/MCP-Model_Context_Protocol-orange?style=flat-square)](https://modelcontextprotocol.io/) -[![Build Status](https://github.com/Ishaan1402/pathfinder/actions/workflows/ci.yml/badge.svg)](https://github.com/Ishaan1402/pathfinder/actions) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](LICENSE) - - -A decoupled hyperparameter optimization (HPO) framework that separates the deterministic optimizer from episodic AI reviews. Train workers run autonomously without ever blocking on an LLM. Optimizers run fast. Humans (or AI agents in your IDE) review results periodically and decide when to adjust the search space. - -**Designed for:** ML researchers and engineers tuning deep learning models on their own infrastructure (local GPU, Colab, cloud VMs). Use it as a reference for the bridge-crack U-Net project, or adapt the templates for your own training script. - -## Why Pathfinder? - -**Problem:** Traditional HPO frameworks either require workers to wait for an optimizer, or they add LLM reasoning that introduces latency into every training loop. You end up trading off between speed and intelligence. - -**Solution:** Three independent layers: - -- **Broker (Optuna TPE)**: Fast, deterministic suggestion engine. Never calls an LLM. Workers hit this endpoint and move on. -- **Worker**: Train autonomously. Report metrics incrementally. Handles pruning, OOM, checkpointing. Never waits. -- **Coordinator (You + Optional LLM)**: Run episodic reviews when *you* decide. Inspect trial history, check search health, propose bounds changes. AI agents (Claude, Cursor) can run reviews via MCP tools. - -All state lives in **SQLite**β€”no config files, no in-memory state. This makes it easy to resume reviews, audit decisions, and sync across machines. - -## Quick Start - -### Option 1: Local GPU - -```bash -python3 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt - -# Terminal 1: Start broker -python broker.py --daemon -# Dashboard: http://127.0.0.1:8000 - -# Terminal 2: Run worker -HPO_BROKER_URL=http://localhost:8000 HPO_STUDY_NAME=test_study python simulators/training_worker.py -Option 2: Remote Workers (Colab / Cloud GPU) -``` - -### Option 2: Remote Workers (Colab / Cloud GPU) - -```bash -# Terminal 1: Start broker with tunnel + auth -export HPO_SECRET_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')" - -# ngrok (auto-generates URL) -python broker.py --daemon --tunnel - -# OR Cloudflare (bring your own domain) -python broker.py --daemon --tunnel-provider cloudflare --tunnel-url https://your-domain.com - -# Prints: πŸ”₯ Remote broker URL established: https://... - -# Terminal 2 (on remote): Set environment and run worker -export HPO_BROKER_URL="https://..." -export HPO_SECRET_TOKEN="" -python colab_worker.py -``` - -### 4. (Optional) Use IDE Integration - -Point Claude Code, Cursor, or Antigravity to the MCP server for agent-driven onboarding and reviews. See **IDE Setup** below. - ---- - -## Core Features - -### Deterministic Optimizer (Hot Path) - -- **TPE Sampler**: Probability-based hyperparameter suggestions (beats grid search) -- **ASHA Pruning**: Stop underperforming trials early to save GPU time -- **Multi-Objective Pareto**: Optimize for both Dice score *and* loss simultaneously -- **fANOVA Importances**: Which hyperparams actually matter? (β†’ guides your reviews) -- **No LLM calls**: Workers never block. Suggest latency is <10ms. - -### Episodic Coordinator (You Decide When) - -- Dashboard shows health warnings (nudges to review, never auto-reviews) -- 7-step review procedure: inspect data β†’ rate health β†’ adjust bounds if needed β†’ submit audit trail -- Search space proposals staged for approval before taking effect -- Coordinator accuracy tracked: your reviews' forecasted score gains vs. measured deltas -- Optional LLM integration (Claude, Gemini, OpenAI) for automatic reviews - -### State Machine (SQLite) - -All configuration, trials, reviews, and metadata live in `hpo_studies.db`: - -- Active search space (not on disk) -- Trial results + VRAM telemetry -- Coordinator review history with citations -- Study health tier and dismissal states -- Generated model cards - ---- - -## For Your Own Project - -If you cloned this to tune your model (not the bridge-crack reference): - -1. **Write a manifest** (`train.hpo.yaml`): - ```yaml - study_name: my_study - metrics: - objectives: - - name: loss - direction: minimize - - name: accuracy - direction: maximize - params: - - name: learning_rate - type: float_log - min: 1e-5 - max: 1e-2 - - name: batch_size - type: categorical - options: [4, 8, 16, 32] - worker: - entrypoint: python train.py - ``` -2. **Register the study**: - ```bash - python hpo_cli.py validate train.hpo.yaml - python hpo_cli.py init train.hpo.yaml - ``` -3. **Write a worker** (use `templates/worker_minimal.py` as template): - ```python - from src.hpo_client import TrialSession - - session = TrialSession(broker_url="http://localhost:8000", study_name="my_study") - trial = session.suggest() - - for epoch in range(epochs): - accuracy, loss = train_one_epoch(trial["params"]) - should_prune = session.report_epoch(epoch, score=accuracy, loss=loss) - if should_prune: - break - - session.complete(epoch, score=accuracy, loss=loss, state="COMPLETE") - ``` -4. **Run on your GPU** (set env vars first): - ```bash - export HPO_BROKER_URL=http://localhost:8000 - export HPO_STUDY_NAME=my_study - python train.py - ``` - -Full integration walkthrough: [docs/INTEGRATION.md](docs/INTEGRATION.md) - ---- - -## IDE Setup (Agent-Driven Onboarding & Reviews) - -### Cursor - -1. **Settings β†’ Features β†’ MCP** -2. **+ Add New MCP Server** -3. Name: `pathfinder` - Type: `command` - Command: `source .venv/bin/activate && python3 hpo_mcp_server.py` - -### Claude Code / Antigravity - -Add to your MCP config (`~/.config/claudecode/mcp_config.json` or similar): - -```json -{ - "mcpServers": { - "pathfinder": { - "command": "python3", - "args": ["hpo_mcp_server.py"], - "env": { - "HPO_DATABASE_URL": "sqlite:///./hpo_studies.db" - } - } - } -} -``` - -Then tell any agent: - -- **"integrate HPO"** β†’ agent drafts manifest, validates, registers study -- **"run a coordinator review"** β†’ agent fetches study data, rates health, proposes bounds changes - -See [AGENTS.md](AGENTS.md) for the full procedure. - ---- - -## Architecture - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ You + Optional IDE Agent β”‚ -β”‚ - Manual reviews or @pathfinder tools β”‚ -β”‚ - Dashboard inspection β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - ↕ MCP + HTTP -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Broker (broker.py on localhost:8000) β”‚ -β”‚ - Optuna TPE suggestion engine β”‚ -β”‚ - Trial lifecycle (/api/suggest, β”‚ -β”‚ /api/report_epoch, /api/complete) β”‚ -β”‚ - Dashboard serving β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - ↕ SQLite -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ hpo_studies.db β”‚ -β”‚ - All state (search space, trials, β”‚ -β”‚ reviews, health, config) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - ↕ HTTP -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Training Workers (Any Box) β”‚ -β”‚ - Colab, local GPU, cloud VM β”‚ -β”‚ - 3-call client API β”‚ -β”‚ - Never blocks on optimizer or LLM β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - ---- - -## Common Commands - -```bash -# Start broker + dashboard -python broker.py --daemon - -# Validate & initialize a study from manifest -python hpo_cli.py validate train.hpo.yaml -python hpo_cli.py init train.hpo.yaml - -# Check study health -python hpo_cli.py status - -# Run a manual coordinator review (or prints prompt for copy-paste) -python hpo_cli.py review - -# Export study config back to YAML -python hpo_cli.py manifest my_study - -# Commit pending search space changes -python hpo_cli.py apply - -# Run tests -pytest tests/ -q -``` - ---- - -## Reference: Bridge Crack Segmentation (bridge-crack repo) - -This Pathfinder instance was initially tuned for [crack-seg](https://github.com/Ishaan1402/crack-seg#crack-seg), a **U-Net pixel-level crack detection model** on high-res UAV bridge imagery. See [colab_worker.py](colab_worker.py) for the full reference implementation (dataset download, model setup, training loop). - -**Don't modify `colab_worker.py`** unless you're maintaining the bridge-crack project. Cloners should use `templates/worker_minimal.py` instead. - ---- - -## Docs - -- **[AGENTS.md](AGENTS.md)** β€” Guide for AI agents (Claude, Cursor, Antigravity) -- **[CLAUDE.md](CLAUDE.md)** β€” Development commands and architecture for Claude Code -- **[examples/onboarding/](examples/onboarding/)** β€” Step-by-step walkthrough for a new project -- **[docs/INTEGRATION.md](docs/INTEGRATION.md)** β€” Worker integration contract details - ---- - -## License - -MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/pathfinder.egg-info/SOURCES.txt b/pathfinder.egg-info/SOURCES.txt deleted file mode 100644 index e660f33..0000000 --- a/pathfinder.egg-info/SOURCES.txt +++ /dev/null @@ -1,45 +0,0 @@ -LICENSE -README.md -broker.py -hpo_cli.py -hpo_mcp_server.py -pyproject.toml -pathfinder.egg-info/PKG-INFO -pathfinder.egg-info/SOURCES.txt -pathfinder.egg-info/dependency_links.txt -pathfinder.egg-info/entry_points.txt -pathfinder.egg-info/requires.txt -pathfinder.egg-info/top_level.txt -src/analytics.py -src/db_manager.py -src/hpo_client.py -src/hpo_config.py -src/hpo_coordinator.py -src/hpo_daemon.py -src/leases.py -src/manifest.py -src/metrics.py -src/onboarding.py -src/pruning.py -src/reporting.py -src/schema.py -src/search_space.py -src/settings.py -src/sparklines.py -src/suggest.py -src/routers/__init__.py -src/routers/dashboard.py -src/routers/static.py -src/routers/worker.py -tests/test_concurrency.py -tests/test_coordinator_packet.py -tests/test_health_tier.py -tests/test_http_api.py -tests/test_http_auth.py -tests/test_http_concurrency.py -tests/test_integration.py -tests/test_lean_roadmap.py -tests/test_manifest.py -tests/test_metrics.py -tests/test_pruning.py -tests/test_robustness_features.py \ No newline at end of file diff --git a/pathfinder.egg-info/dependency_links.txt b/pathfinder.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/pathfinder.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/pathfinder.egg-info/entry_points.txt b/pathfinder.egg-info/entry_points.txt deleted file mode 100644 index 13df9cf..0000000 --- a/pathfinder.egg-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[console_scripts] -pathfinder = hpo_cli:main diff --git a/pathfinder.egg-info/requires.txt b/pathfinder.egg-info/requires.txt deleted file mode 100644 index 1e14ac7..0000000 --- a/pathfinder.egg-info/requires.txt +++ /dev/null @@ -1,15 +0,0 @@ -mcp<2,>=1.1 -optuna<5,>=3.6 -optuna-dashboard<1,>=0.15 -sqlalchemy<3,>=2.0 -psycopg2-binary<3,>=2.9 -pydantic<3,>=2.0 -numpy<3,>=1.20 -scikit-learn<2,>=1.0 -fastapi<1,>=0.110 -uvicorn<1,>=0.23 -requests<3,>=2.28 -PyYAML<7,>=6.0 - -[dev] -pytest>=8.0 diff --git a/pathfinder.egg-info/top_level.txt b/pathfinder.egg-info/top_level.txt deleted file mode 100644 index ef471f8..0000000 --- a/pathfinder.egg-info/top_level.txt +++ /dev/null @@ -1,4 +0,0 @@ -broker -hpo_cli -hpo_mcp_server -src diff --git a/src/hpo_coordinator.py b/src/hpo_coordinator.py deleted file mode 100644 index 515c4ef..0000000 --- a/src/hpo_coordinator.py +++ /dev/null @@ -1,1305 +0,0 @@ -"""Shared coordinator logic for Pathfinder. - -This module is the single source of truth for: - - read-only review packets (Pareto, fANOVA, eval insights, recent trials), - - deterministic "review recommended" heuristics (no LLM, no network), - - study review persistence (idempotent across IDE clients). - -It is imported by both broker.py (HTTP runtime) and hpo_mcp_server.py (MCP tools) so -the two surfaces speak the same language without duplicating the suggest path or the -drift-detection logic. -""" -import datetime -import json -import logging -from typing import Any, Dict, List, Optional - -import optuna -from optuna.trial import TrialState - -from .db_manager import get_db_session, DATABASE_URL -from .schema import StudyReview, StudyStatus, TrialResult, SystemConfiguration, CompactedPacket -from .hpo_config import load_hpo_config, normalize_trial_params, param_display_name -from .metrics import get_score, get_loss, get_best_trial, get_best_score, score_objective_index, get_completed_trials, get_eval_attr_names, TERMINAL_STATES - -logger = logging.getLogger(__name__) - -# --- Tunables for drift heuristics (deterministic, no model calls) --- - -MIN_COMPLETED_FOR_FIRST_REVIEW = 5 - -POLICY_ACTIONS = ("no_change", "update_active_search_space", "enqueue_one_manual_trial") - -REVIEW_PROMPT = """You are the Pathfinder coordinator reviewing study '{study_name}'. Do NOT block the training worker. -1. Call get_study_data('{study_name}') to obtain the compacted review packet, which includes active search space constraints, HPO config, Spearman correlations, fANOVA, VRAM/OOM telemetry, boundary hits, past reviews, and prediction accuracy. -2. Analyze the packet data under the following constraints: - - COORDINATOR DECISION MEMORY: Review `past_reviews` (up to the last 3 reviews) to maintain logical consistency. Do NOT blindly reverse previous decisions or flip-flop unless new evidence warrants it. However, do NOT copy previous decisions; critically analyze fresh trials and build on previous hypotheses. - - SPEARMAN CORRELATIONS: Check the `spearman_correlations` confidence tags. Treat correlations with caution if the confidence is "Low" or "Moderate" (due to statistical noise at low sample sizes). - - VRAM & OOM FORECASTS: Examine `bounds_oom_risk` under `vram_telemetry`. Do NOT treat mean predictions as facts. Treat `predicted_mean_vram_gb + margin_gb` (the predicted max VRAM) as the safety boundary relative to `gpu_capacity_gb`. Shrink/cap search bounds if there is high OOM risk. - - ACCURACY SELF-REGULATION: Check `coordinator_accuracy` in the packet. If `insufficient_data` is true (fewer than 3 scored reviews), do not self-regulate yet. If `mean_absolute_error` > 0.05 with n_scored_reviews >= 3, be more conservativeβ€”propose smaller search space shifts. Ignore reviews where `quality_flagged` is true. - - DYNAMIC METRIC LABELS: Refer to scores and losses using the dynamic labels specified in the packet (e.g. '{metric_score_label}' and '{metric_loss_label}'). - - GLOBAL BEST TRIAL: The current global best trial is: {best_trial_info}. -3. Rate search space health 1-5 (preferring fixed-eval score if available). -4. Provide a numeric forecast for estimated score improvement. If you have thin/insufficient data (e.g. fewer than 5 trials completed), use `-1.0` as a sentinel value. -5. Identify the trial number you cite as the best trial so far and provide it as the `cited_best_trial` parameter. -6. Select exactly ONE policy action: no_change, update_active_search_space (via update_search_space), or enqueue_one_manual_trial. -7. If proposing active search space changes, call the tool update_search_space(study_name, space_config, apply=False). If enqueuing a manual trial, pass the parameter dictionary as the `manual_trial` argument when calling submit_agent_review. -8. Call submit_agent_review. Write a 3-5 line summary focusing on specific trial numbers and stats, and including a Git-like diff of bounds changes if updated. -""" - - -def compute_statistical_confidence(n_complete: int) -> str: - """Tiered confidence from completed-trial count (caveat only, never a hard gate).""" - if n_complete < 10: - return "low" - if n_complete < 20: - return "medium" - return "high" - - -def get_best_primary_score(study) -> Optional[float]: - completed = get_completed_trials(study) - if not completed: - return None - score = get_best_score(completed, study) - if score is not None: - return score - if len(study.directions) == 1 and study.best_value is not None: - return float(study.best_value) - return None - - -def validate_review_fields( - estimated_score_improvement: Optional[float], - cited_best_trial: Optional[int], -) -> Dict[str, Any]: - """Required JSON contract for coordinator review submissions.""" - errors: List[str] = [] - if cited_best_trial is None: - errors.append("cited_best_trial is required (int trial number).") - if estimated_score_improvement is None: - errors.append("estimated_score_improvement is required (float).") - else: - try: - float(estimated_score_improvement) - except (TypeError, ValueError): - errors.append("estimated_score_improvement must be a number.") - return {"ok": len(errors) == 0, "errors": errors} - - -def compute_coordinator_accuracy(study_name: str) -> Dict[str, Any]: - """MAE from measured StudyReview outcomes (excludes inconclusive, sentinel, and flagged).""" - with get_db_session() as session: - rows = ( - session.query(StudyReview) - .filter( - StudyReview.study_name == study_name, - StudyReview.outcome_status == "measured", - StudyReview.quality_flagged.is_(False), - StudyReview.estimated_score_improvement.isnot(None), - StudyReview.actual_score_improvement.isnot(None), - ) - .order_by(StudyReview.id.asc()) - .all() - ) - scored = [] - for r in rows: - if r.estimated_score_improvement == -1.0: - continue - scored.append({ - "review_id": r.id, - "estimated_score_improvement": r.estimated_score_improvement, - "actual_score_improvement": r.actual_score_improvement, - "absolute_error": abs(r.estimated_score_improvement - r.actual_score_improvement), - }) - n = len(scored) - result: Dict[str, Any] = { - "n_scored_reviews": n, - "insufficient_data": n < 3, - "mean_absolute_error": None, - "accuracy_rate_05": None, - "recent_predictions": scored[-5:], - } - if n > 0: - errors = [s["absolute_error"] for s in scored] - result["mean_absolute_error"] = sum(errors) / n - result["accuracy_rate_05"] = sum(1 for e in errors if e <= 0.05) / n - return result - - -def mark_review_applied(study_name: str) -> None: - """Record when a coordinator search-space patch was committed.""" - study = optuna.load_study(study_name=study_name, storage=DATABASE_URL) - complete_count = len(get_completed_trials(study)) - now = datetime.datetime.utcnow() - with get_db_session() as session: - review = ( - session.query(StudyReview) - .filter_by(study_name=study_name) - .filter(StudyReview.policy_action != "no_change") - .filter(StudyReview.outcome_status == "pending") - .filter(StudyReview.applied_at_completed_count.is_(None)) - .order_by(StudyReview.created_at.desc(), StudyReview.id.desc()) - .first() - ) - if review: - review.applied_at_completed_count = complete_count - review.applied_at = now - - -def backfill_review_outcomes(study_name: str) -> None: - """Measure coordinator forecast accuracy after post-apply trial windows.""" - study = optuna.load_study(study_name=study_name, storage=DATABASE_URL) - complete_count = len(get_completed_trials(study)) - - with get_db_session() as session: - pending = ( - session.query(StudyReview) - .filter( - StudyReview.study_name == study_name, - StudyReview.outcome_status == "pending", - StudyReview.policy_action != "no_change", - StudyReview.applied_at_completed_count.isnot(None), - ) - .all() - ) - for review in pending: - if review.applied_at is None: - continue - complete_since = complete_count - review.applied_at_completed_count - applied_at = review.applied_at.replace(tzinfo=None) if review.applied_at.tzinfo else review.applied_at - finished_since = [ - t for t in study.trials - if t.state in TERMINAL_STATES - and t.datetime_complete - and ( - t.datetime_complete.replace(tzinfo=None) - if getattr(t.datetime_complete, "tzinfo", None) - else t.datetime_complete - ) >= applied_at - ] - if len(finished_since) >= 15 and complete_since < 3: - review.outcome_status = "inconclusive" - review.outcome_measured_at = datetime.datetime.utcnow() - continue - if complete_since >= 5: - new_best = get_best_primary_score(study) - baseline = review.baseline_best_score - if new_best is not None and baseline is not None: - review.actual_score_improvement = new_best - baseline - review.outcome_status = "measured" - review.outcome_measured_at = datetime.datetime.utcnow() - - -def flag_study_review(review_id: int, flagged: bool = True) -> Dict[str, Any]: - with get_db_session() as session: - review = session.query(StudyReview).filter_by(id=review_id).first() - if not review: - return {"success": False, "error": f"Review id {review_id} not found."} - review.quality_flagged = flagged - session.flush() - return {"success": True, "review": review.to_dict()} - - -def build_review_prompt(study_name: str) -> str: - config = load_hpo_config(study_name) - score_label = config.get("metric_score_label", "Score") - loss_label = config.get("metric_loss_label", "Loss") - - best_trial_info = "None (No trials completed yet)" - stat_confidence = "low" - try: - study = optuna.load_study(study_name=study_name, storage=DATABASE_URL) - completed = get_completed_trials(study) - stat_confidence = compute_statistical_confidence(len(completed)) - if completed: - best_t = get_best_trial(completed, study) or study.best_trial - score_val = get_score(best_t, study) if best_t else 0.0 - best_trial_info = f"Trial #{best_t.number} with {score_label}: {score_val:.4f}" - except Exception as e: - best_trial_info = f"Error reading best trial: {e}" - - prompt = REVIEW_PROMPT.format( - study_name=study_name, - metric_score_label=score_label, - metric_loss_label=loss_label, - best_trial_info=best_trial_info - ) - if stat_confidence == "low": - prompt = ( - "STATISTICAL CONFIDENCE: LOW β€” fewer than 10 completed trials. " - "Treat fANOVA and Spearman signals as noisy. Use estimated_score_improvement=-1.0 " - "when you cannot justify a numeric forecast.\n\n" - ) + prompt - elif stat_confidence == "medium": - prompt = ( - "STATISTICAL CONFIDENCE: MEDIUM β€” 10–19 completed trials. " - "Correlations may stabilize but remain cautious on bound changes.\n\n" - ) + prompt - return prompt - - -def load_active_search_space(study_name: Optional[str] = None) -> Dict[str, Any]: - from .settings import settings - if not study_name: - study_name = settings.study_name - with get_db_session() as session: - row = session.query(SystemConfiguration).filter_by( - study_name=study_name, config_key="active_search_space" - ).first() - if row: - try: - return json.loads(row.config_value) - except Exception as e: - logger.warning(f"Failed to parse active_search_space for {study_name}: {e}") - return {} - - -def get_ranks(v: List[float]) -> List[float]: - n = len(v) - indexed = sorted(enumerate(v), key=lambda x: x[1]) - ranks = [0.0] * n - i = 0 - while i < n: - j = i - while j < n and indexed[j][1] == indexed[i][1]: - j += 1 - avg_rank = sum(range(i + 1, j + 1)) / (j - i) - for k in range(i, j): - ranks[indexed[k][0]] = avg_rank - i = j - return ranks - - -def compute_spearman_rank_correlation(x: List[float], y: List[float]) -> float: - n = len(x) - if n < 3: - return 0.0 - rx = get_ranks(x) - ry = get_ranks(y) - mean_x = sum(rx) / n - mean_y = sum(ry) / n - num = sum((rx[i] - mean_x) * (ry[i] - mean_y) for i in range(n)) - den_x = sum((rx[i] - mean_x) ** 2 for i in range(n)) - den_y = sum((ry[i] - mean_y) ** 2 for i in range(n)) - if den_x == 0 or den_y == 0: - return 0.0 - raw_corr = num / (den_x * den_y) ** 0.5 - return raw_corr - - -def compute_prune_rate_clusters(study, search_space: Dict[str, Any]) -> Dict[str, Any]: - clusters = {} - continuous_params = [] - for p_name, p_info in search_space.items(): - p_type = p_info.get("type", "") - if p_type in ("float", "float_log", "int"): - continuous_params.append(p_name) - - for p_name in continuous_params: - vals = [] - states = [] - for t in study.trials: - if t.state in (TrialState.COMPLETE, TrialState.PRUNED) and p_name in t.params: - val = t.params[p_name] - if val is not None: - vals.append(float(val)) - states.append(t.state) - - if not vals: - continue - - v_min, v_max = min(vals), max(vals) - if v_max <= v_min: - continue - - w = (v_max - v_min) / 3.0 - bins = [ - {"min": v_min, "max": v_min + w, "total": 0, "pruned": 0}, - {"min": v_min + w, "max": v_min + 2*w, "total": 0, "pruned": 0}, - {"min": v_min + 2*w, "max": v_max, "total": 0, "pruned": 0} - ] - - for val, state in zip(vals, states): - if val <= bins[0]["max"]: - bin_idx = 0 - elif val <= bins[1]["max"]: - bin_idx = 1 - else: - bin_idx = 2 - - bins[bin_idx]["total"] += 1 - if state == TrialState.PRUNED: - bins[bin_idx]["pruned"] += 1 - - for b in bins: - b["prune_rate"] = b["pruned"] / b["total"] if b["total"] > 0 else 0.0 - - clusters[p_name] = bins - return clusters - - -def check_boundary_hits(study, pareto_numbers: List[int], search_space: Dict[str, Any]) -> Dict[str, Any]: - hits = {} - pareto_trials = [t for t in study.trials if t.number in pareto_numbers and t.state == TrialState.COMPLETE] - n_pareto = len(pareto_trials) - if n_pareto == 0: - return hits - - for p_name, p_info in search_space.items(): - p_type = p_info.get("type", "") - if p_type not in ("float", "float_log", "int"): - continue - - s_min = p_info.get("min") - s_max = p_info.get("max") - if s_min is None or s_max is None or s_max <= s_min: - continue - - s_min, s_max = float(s_min), float(s_max) - margin = 0.1 * (s_max - s_min) - - near_min_count = 0 - near_max_count = 0 - - for t in pareto_trials: - val = t.params.get(p_name) - if val is not None: - val = float(val) - if val <= s_min + margin: - near_min_count += 1 - if val >= s_max - margin: - near_max_count += 1 - - total_hits = near_min_count + near_max_count - ratio = total_hits / n_pareto - - if ratio > 0.6: - hits[p_name] = { - "near_min_count": near_min_count, - "near_max_count": near_max_count, - "total_pareto": n_pareto, - "hit_ratio": ratio, - "bound_hit": "min" if near_min_count > near_max_count else "max" if near_max_count > near_min_count else "both" - } - return hits - - -def compute_fidelity_durations(study, config: Dict[str, Any]) -> Dict[str, Any]: - ev = config.get("eval_protocol", {}) - train_param = ev.get("train_resolution_param", "resolution") - - groups = {} - for t in study.trials: - if t.state != TrialState.COMPLETE: - continue - val = t.params.get(train_param) - if val is None: - continue - try: - val = int(val) - except (TypeError, ValueError): - continue - - groups.setdefault(val, []).append(t) - - res_stats = {} - for val, trials in groups.items(): - durations = [] - epoch_durations = [] - for t in trials: - if t.datetime_start and t.datetime_complete: - dur = (t.datetime_complete - t.datetime_start).total_seconds() - durations.append(dur) - history = t.user_attrs.get("history", []) - epochs = len(history) if history else t.user_attrs.get("latest_epoch") - if not epochs: - epochs = max([h.get("epoch", 1) for h in history] or [1]) - if epochs > 0: - epoch_durations.append(dur / epochs) - - if durations: - res_stats[val] = { - "avg_total_duration": sum(durations) / len(durations), - "avg_epoch_duration": sum(epoch_durations) / len(epoch_durations) if epoch_durations else None, - "count": len(durations) - } - - if not res_stats: - return {} - - lowest_scale = min(res_stats.keys()) - base_dur = res_stats[lowest_scale]["avg_total_duration"] - - for val, stats in res_stats.items(): - if base_dur > 0: - stats["overhead_ratio"] = stats["avg_total_duration"] / base_dur - else: - stats["overhead_ratio"] = 1.0 - - return { - "fidelity_param": train_param, - "lowest_scale": lowest_scale, - "scales": res_stats - } -def fit_vram_model(trials: List[Any], db_metrics: Dict[int, Any], train_param: str) -> Optional[Dict[str, Any]]: - points = [] - for t in trials: - if t.state != TrialState.COMPLETE: - continue - metric = db_metrics.get(t.number, {}) - oom = metric.get("oom_triggered") or t.user_attrs.get("oom_triggered", False) - if oom: - continue - vram = metric.get("max_vram_gb") or t.user_attrs.get("max_vram_gb") - if vram is None: - continue - bs = t.params.get("batch_size") - res = t.params.get(train_param) - if bs is not None and res is not None: - try: - points.append((float(bs), float(res), float(vram))) - except (ValueError, TypeError): - continue - - # We require at least 6 points to fit the model to prevent overfitting - if len(points) < 6: - return None - - X = [p[0] * (p[1] ** 2) for p in points] - Y = [p[2] for p in points] - - # Verify variance in X - if len(set(X)) < 2: - return None - - n = len(points) - sum_x = sum(X) - sum_y = sum(Y) - sum_xx = sum(x*x for x in X) - sum_xy = sum(X[i]*Y[i] for i in range(n)) - - denom = n * sum_xx - sum_x * sum_x - if abs(denom) < 1e-12: - return None - - slope = (n * sum_xy - sum_x * sum_y) / denom - intercept = (sum_y - slope * sum_x) / n - - # Calculate Residual Standard Error - ssr = sum((Y[i] - (slope * X[i] + intercept)) ** 2 for i in range(n)) - rse = (ssr / (n - 2)) ** 0.5 if n > 2 else 0.0 - - return { - "slope": slope, - "intercept": intercept, - "n_points": n, - "rse": rse - } - - -def compute_vram_telemetry(study, db_metrics: Dict[int, Any], search_space: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]: - ev = config.get("eval_protocol", {}) - train_param = ev.get("train_resolution_param", "resolution") - - trials = list(study.trials) - model = fit_vram_model(trials, db_metrics, train_param) - - gpu_capacity_gb = 0.0 - gpu_models = [] - oom_count = 0 - - for t in trials: - metric = db_metrics.get(t.number, {}) - vram = metric.get("max_vram_gb") or t.user_attrs.get("max_vram_gb") - gpu = metric.get("gpu_model") or t.user_attrs.get("gpu_model") - oom = metric.get("oom_triggered") or t.user_attrs.get("oom_triggered", False) - - if vram: - gpu_capacity_gb = max(gpu_capacity_gb, float(vram)) - if gpu: - gpu_models.append(gpu) - if oom: - oom_count += 1 - - gpu_model = max(set(gpu_models), key=gpu_models.count) if gpu_models else "Unknown" - - oom_risk = None - if model and gpu_capacity_gb > 0: - max_bs = None - bs_info = search_space.get("batch_size", {}) - if bs_info.get("type") == "categorical": - active_bs = bs_info.get("active", []) - if active_bs: - max_bs = max(active_bs) - else: - max_bs = bs_info.get("max") - - max_res = None - res_info = search_space.get(train_param, {}) - if res_info.get("type") == "categorical": - active_res = res_info.get("active", []) - if active_res: - max_res = max(active_res) - else: - max_res = res_info.get("max") - - if max_bs is not None and max_res is not None: - predicted_mean_vram = model["slope"] * (float(max_bs) * (float(max_res) ** 2)) + model["intercept"] - margin = max(1.0, 1.96 * model["rse"]) - predicted_max_vram = predicted_mean_vram + margin - - if predicted_max_vram > 0.9 * gpu_capacity_gb: - oom_risk = { - "max_batch_size": max_bs, - "max_resolution": max_res, - "predicted_mean_vram_gb": predicted_mean_vram, - "margin_gb": margin, - "predicted_max_vram_gb": predicted_max_vram, - "gpu_capacity_gb": gpu_capacity_gb, - "risk_level": "high" if predicted_max_vram > gpu_capacity_gb else "medium" - } - - return { - "gpu_model": gpu_model, - "gpu_capacity_gb": gpu_capacity_gb, - "oom_count": oom_count, - "vram_model": model, - "bounds_oom_risk": oom_risk - } - - -# --- Train-resolution helper (shared with broker pruning/pareto) --- -def trial_train_resolution(trial, train_param: str) -> Optional[int]: - val = trial.params.get(train_param) - if val is None: - return None - try: - return int(val) - except (TypeError, ValueError): - return None - - -def pareto_trial_numbers_deploy_aware(study, hpo_config: Dict[str, Any]) -> List[int]: - """ - Pareto set for dashboard: optional filter excluding low train-res trials that inflate Dice. - Uses fixed-eval Dice when available on completed trials. - """ - ev = hpo_config.get("eval_protocol", {}) - train_param = ev.get("train_resolution_param", "resolution") - low_warn = ev.get("low_train_res_warning") - low_warn = int(low_warn) if low_warn is not None else None - deploy_only = ev.get("pareto_deploy_resolution_only", True) - dice_fixed_key = ev.get("fixed_dice_attr", "dice_eval_fixed") - - points: List[tuple] = [] - for t in study.trials: - if t.state != TrialState.COMPLETE: - continue - loss_val = get_loss(t, study) - score_val = get_score(t, study) - if loss_val is None or score_val is None: - continue - train_res = trial_train_resolution(t, train_param) - if deploy_only and low_warn is not None and train_res is not None and train_res < low_warn: - continue - if ev.get("enabled"): - fd = t.user_attrs.get(dice_fixed_key) - if fd is not None: - score_val = float(fd) - points.append((t.number, float(loss_val), float(score_val))) - - if not points: - try: - return [t.number for t in study.best_trials] - except Exception: - return [] - - pareto: List[int] = [] - for num_i, loss_i, score_i in points: - dominated = False - for num_j, loss_j, score_j in points: - if num_i == num_j: - continue - if loss_j <= loss_i and score_j >= score_i and (loss_j < loss_i or score_j > score_i): - dominated = True - break - if not dominated: - pareto.append(num_i) - return pareto - - -def study_eval_insights(study, config: Dict[str, Any]) -> Dict[str, Any]: - ev = config.get("eval_protocol", {}) - train_param = ev.get("train_resolution_param", "resolution") - fixed_res = ev.get("fixed_resolution") - low_warn = ev.get("low_train_res_warning") - dice_fixed_key, _ = get_eval_attr_names(ev) - - complete = get_completed_trials(study) - by_res: Dict[int, List] = {} - warnings = [] - - for t in complete: - tr = t.params.get(train_param) - if tr is not None: - by_res.setdefault(int(tr), []).append(t) - - res_summary = {} - for res, trials in sorted(by_res.items()): - scores = [get_score(t, study) for t in trials] - scores = [s for s in scores if s is not None] - fixed_scores = [ - t.user_attrs.get(dice_fixed_key) - for t in trials - if t.user_attrs.get(dice_fixed_key) is not None - ] - res_summary[res] = { - "count": len(trials), - "best_dice_train": max(scores) if scores else None, - "best_dice_fixed": max(fixed_scores) if fixed_scores else None, - "best_score_train": max(scores) if scores else None, - "best_score_fixed": max(fixed_scores) if fixed_scores else None, - } - - # Suppress low-fidelity warning if a valid deploy-scale candidate exists (res >= low_warn) - valid_deploy_exists = False - if low_warn is not None: - for t in complete: - tr = t.params.get(train_param) - fd = t.user_attrs.get(dice_fixed_key) - if tr is not None and int(tr) >= int(low_warn) and fd is not None: - valid_deploy_exists = True - break - - if complete and ev.get("enabled") and fixed_res: - best_train = get_best_trial(complete, study) - if best_train is None: - best_train = complete[0] - train_res = best_train.params.get(train_param) - if train_res is not None and low_warn and int(train_res) < int(low_warn) and not valid_deploy_exists: - warnings.append( - { - "code": "low_train_res_pareto", - "trial_number": best_train.number, - "message": ( - f"Pareto-best trial #{best_train.number} trained at scale {train_res}, " - f"below warning threshold {low_warn}. Check {ev.get('dice_fixed_label', 'fixed eval')}." - ), - } - ) - fd = best_train.user_attrs.get(dice_fixed_key) - td = get_score(best_train, study) - if fd is not None and td is not None and (td - fd) > 0.08: - warnings.append( - { - "code": "train_eval_gap", - "trial_number": best_train.number, - "message": ( - f"Trial #{best_train.number}: train score {td:.3f} vs " - f"fixed-eval score {fd:.3f} β€” train scale/resolution may be inflating scores." - ), - } - ) - - best_deploy = None - if ev.get("enabled"): - ranked = [ - t - for t in complete - if t.user_attrs.get(dice_fixed_key) is not None - ] - if ranked: - best_deploy = max(ranked, key=lambda t: t.user_attrs.get(dice_fixed_key)) - - return { - "resolution_summary": res_summary, - "warnings": warnings, - "best_deploy_trial_number": best_deploy.number if best_deploy else None, - "best_deploy_dice_fixed": ( - best_deploy.user_attrs.get(dice_fixed_key) if best_deploy else None - ), - "best_deploy_score_fixed": ( - best_deploy.user_attrs.get(dice_fixed_key) if best_deploy else None - ), - } - - -def count_evaluated_trials(study) -> int: - """Finished trials (COMPLETE / PRUNED / FAIL) β€” the idempotency window for reviews.""" - return sum( - 1 - for t in study.trials - if t.state in TERMINAL_STATES - ) - - -def compute_health_tier(study, study_name: str) -> tuple[str, Optional[str]]: - """Evaluates study health using a tiered severity model (Healthy, Watch, Intervene). - - Returns (health_tier, health_reason) - """ - import math - from optuna.trial import TrialState - - trials = list(study.trials) - finished = sorted([t for t in trials if t.state in TERMINAL_STATES], key=lambda t: t.number) - completed = sorted(get_completed_trials(study), key=lambda t: t.number) - - # πŸ”΄ Intervene Triggers - - # 1. NaN or Inf detected in reported metrics - for t in trials: - if t.values: - for v in t.values: - if v is not None and (math.isnan(v) or math.isinf(v)): - return "intervene", f"NaN or Inf detected in reported metrics for Trial #{t.number}" - - # 2. Same parameter combination caused 2+ OOM failures - try: - with get_db_session() as session: - oom_trials = session.query(TrialResult).filter_by(study_name=study_name, oom_triggered=True).all() - if len(oom_trials) >= 2: - trial_params_map = {t._trial_id: t.params for t in study.trials} - oom_combos = {} - for r in oom_trials: - params = trial_params_map.get(r.trial_id) - if params: - key = tuple(sorted((k, str(v)) for k, v in params.items())) - oom_combos[key] = oom_combos.get(key, 0) + 1 - if oom_combos[key] >= 2: - params_desc = ", ".join(f"{k}={v}" for k, v in key) - return "intervene", f"OOM cluster detected: parameter combination ({params_desc}) failed with OOM 2+ times" - except Exception as e: - print(f"Error checking OOM clusters: {e}") - - # 3. Best score hasn't improved in 2x the study's average improvement interval - if len(completed) >= 5: - improvements = [] - best_so_far = -float("inf") - for i, t in enumerate(completed): - score = get_score(t, study) - if score is None: - continue - if score > best_so_far + 1e-4: - best_so_far = score - improvements.append(i) - - if len(improvements) >= 2: - intervals = [improvements[j] - improvements[j-1] for j in range(1, len(improvements))] - avg_interval = sum(intervals) / len(intervals) - trials_since_last_improvement = len(completed) - 1 - improvements[-1] - threshold = max(4, int(math.ceil(2 * avg_interval))) - if trials_since_last_improvement >= threshold: - return "intervene", f"Score stagnation: no improvement over last {trials_since_last_improvement} completed trials (average improvement interval is {avg_interval:.1f} trials, threshold is {threshold})" - - # 4. Train-eval metric gap exceeds 2Οƒ of the study's historical gap distribution - if len(completed) >= 4: - config = load_hpo_config(study_name) - dice_fixed_key = config.get("eval_protocol", {}).get("fixed_dice_attr", "dice_eval_fixed") - gaps = [] - for t in completed: - fd = t.user_attrs.get(dice_fixed_key) - td = get_score(t, study) - if fd is not None and td is not None: - gaps.append(float(td) - float(fd)) - - if len(gaps) >= 4: - mean_gap = sum(gaps) / len(gaps) - var_gap = sum((g - mean_gap) ** 2 for g in gaps) / len(gaps) - std_gap = var_gap ** 0.5 - latest_gap = gaps[-1] - if std_gap > 0 and latest_gap > mean_gap + 2 * std_gap: - return "intervene", f"Train-eval gap anomaly: latest trial gap ({latest_gap:.4f}) exceeds 2 standard deviations of historical gap distribution (mean={mean_gap:.4f}, std={std_gap:.4f}, threshold={mean_gap + 2*std_gap:.4f})" - - # 🟑 Watch Triggers - - # 1. Prune rate over last 5 trials exceeds 80% (>= 4 out of last 5 finished trials are pruned) - if len(finished) >= 5: - recent_finished = finished[-5:] - pruned_count = sum(1 for t in recent_finished if t.state == TrialState.PRUNED) - if pruned_count >= 4: - return "watch", f"High prune rate: {pruned_count}/5 ({pruned_count*20}%) of recent trials were pruned" - - # 2. Score variance in top quartile drops below epsilon (stagnation) - if len(completed) >= 4: - scores = [get_score(t, study) for t in completed] - scores = [s for s in scores if s is not None] - scores.sort(reverse=True) - top_quartile_count = max(1, len(scores) // 4) - top_scores = scores[:top_quartile_count] - if len(top_scores) >= 2: - mean_top = sum(top_scores) / len(top_scores) - var_top = sum((x - mean_top) ** 2 for x in top_scores) / len(top_scores) - if var_top < 1e-4: - return "watch", f"Score convergence: top quartile score variance ({var_top:.6f}) is below 1e-4" - - # 3. Trial-level warnings - running_trials = [t for t in trials if t.state == TrialState.RUNNING] - latest_completed = completed[-1:] if completed else [] - for t in (running_trials + latest_completed): - t_tier = t.user_attrs.get("health_tier") - t_reason = t.user_attrs.get("health_reason") - if t_tier == "watch" and t_reason: - return "watch", f"Trial #{t.number} warning: {t_reason}" - - return "healthy", "No issues detected. Search space is healthy." - - -def compute_review_heuristics( - study, insights: Dict[str, Any], config: Dict[str, Any], study_name: str -) -> Dict[str, Any]: - """Simplified heuristics adapter using compute_health_tier.""" - health_tier, health_reason = compute_health_tier(study, study_name) - review_recommended = health_tier in ("watch", "intervene") - finished = [t for t in study.trials if t.state in TERMINAL_STATES] - n_eval = len(finished) - latest = get_latest_study_review(study_name) - - # Check if this trial count has been reviewed or dismissed - already_reviewed = latest is not None and latest.get("trials_evaluated") == n_eval - - already_dismissed = False - with get_db_session() as session: - status_row = session.query(StudyStatus).filter_by(study_name=study_name).first() - if status_row and status_row.nudge_dismissed_trials is not None: - if status_row.nudge_dismissed_trials == n_eval: - already_dismissed = True - - if already_reviewed or already_dismissed: - review_recommended = False - - return { - "review_recommended": review_recommended, - "health_tier": health_tier, - "health_reason": health_reason, - "reasons": [{"code": health_tier, "message": health_reason}] if health_reason else [], - "trials_evaluated": n_eval, - "already_reviewed": already_reviewed, - "already_dismissed": already_dismissed, - "last_review_trials_evaluated": latest.get("trials_evaluated") if latest else None, - } - - -# --- Review persistence (idempotent) --- -def get_latest_study_review(study_name: str) -> Optional[Dict[str, Any]]: - with get_db_session() as session: - row = ( - session.query(StudyReview) - .filter_by(study_name=study_name) - .order_by(StudyReview.created_at.desc(), StudyReview.id.desc()) - .first() - ) - return row.to_dict() if row else None - - -def get_recent_study_reviews(study_name: str, limit: int = 10) -> List[Dict[str, Any]]: - with get_db_session() as session: - rows = ( - session.query(StudyReview) - .filter_by(study_name=study_name) - .order_by(StudyReview.created_at.desc(), StudyReview.id.desc()) - .limit(limit) - .all() - ) - return [r.to_dict() for r in rows] - - -def save_study_review( - study_name: str, - summary: str, - *, - health_rating: Optional[int] = None, - policy_action: str = "no_change", - model_version: str = "unspecified", - prompt_strategy: str = "coordinator_review", - reasons: Optional[List[Dict[str, Any]]] = None, - trials_evaluated: int = 0, - estimated_score_improvement: Optional[float] = None, - cited_best_trial: Optional[int] = None, - force: bool = False, -) -> Dict[str, Any]: - """Persist a coordinator review. Idempotent per trial window unless force=True.""" - if policy_action not in POLICY_ACTIONS: - policy_action = "no_change" - if health_rating is not None: - try: - health_rating = max(1, min(5, int(health_rating))) - except (TypeError, ValueError): - health_rating = None - if estimated_score_improvement is not None: - try: - estimated_score_improvement = float(estimated_score_improvement) - except (TypeError, ValueError): - estimated_score_improvement = None - - # Load study first to run validation assertions - study = optuna.load_study(study_name=study_name, storage=DATABASE_URL) - completed_trials = get_completed_trials(study) - completed_count = len(completed_trials) - - if completed_count < MIN_COMPLETED_FOR_FIRST_REVIEW: - estimated_score_improvement = -1.0 - - baseline_best_score = get_best_primary_score(study) - if baseline_best_score is None: - baseline_best_score = -1.0 - - if policy_action == "no_change": - outcome_status = "not_applicable" - else: - outcome_status = "pending" - - # 1. Require at least one evaluated trial (ValueError, not assert, so it survives `python -O`). - if trials_evaluated <= 0: - raise ValueError("No trials have been evaluated yet. Cannot save review.") - - # 2. Verify trials_evaluated matches finished-trial idempotency window (COMPLETE+PRUNED+FAIL) - evaluated_count = count_evaluated_trials(study) - if trials_evaluated != evaluated_count: - raise ValueError( - f"Idempotency key trials_evaluated ({trials_evaluated}) must match " - f"actual count of evaluated trials ({evaluated_count})." - ) - - # 3. Compare cited trial score with actual best trial score - confidence = "high" - if completed_trials: - best_t = get_best_trial(completed_trials, study) or study.best_trial - actual_best_score = get_score(best_t, study) - - if cited_best_trial is not None: - cited_t = None - for t in completed_trials: - if t.number == cited_best_trial: - cited_t = t - break - if cited_t: - cited_score = get_score(cited_t, study) - if actual_best_score is None or cited_score is None: - confidence = "low" - elif abs(actual_best_score - cited_score) > 0.10: - confidence = "low" - else: - # Cited a non-existent completed trial - confidence = "low" - else: - # Did not specify cited best trial - confidence = "low" - - latest = get_latest_study_review(study_name) - if latest and not force and latest.get("trials_evaluated") == trials_evaluated: - return {"success": True, "duplicate": True, "review": latest} - - with get_db_session() as session: - review = StudyReview( - study_name=study_name, - health_rating=health_rating, - summary=summary, - policy_action=policy_action, - model_version=model_version or "unspecified", - prompt_strategy=prompt_strategy or "coordinator_review", - trials_evaluated=trials_evaluated, - estimated_score_improvement=estimated_score_improvement, - cited_best_trial=cited_best_trial, - confidence=confidence, - baseline_best_score=baseline_best_score, - outcome_status=outcome_status, - ) - review.set_reasons(reasons) - session.add(review) - - # Record review window without masking underlying study health - status = session.query(StudyStatus).filter_by(study_name=study_name).first() - if status: - tier, reason = compute_health_tier(study, study_name) - status.health_tier = tier - status.health_reason = reason - - session.flush() - saved = review.to_dict() - return {"success": True, "duplicate": False, "review": saved} - - -# --- fANOVA + packet assembly --- -def get_fanova_importances(study, config: Dict[str, Any]) -> Dict[str, float]: - complete = get_completed_trials(study) - if len(complete) < 2: - return {} - importances: Dict[str, float] = {} - try: - if len(study.directions) > 1: - _si = score_objective_index(study) - if _si is not None: - importances = optuna.importance.get_param_importances( - study, - target=lambda t, idx=_si: t.values[idx] if (t.values and len(t.values) > idx) else None, - evaluator=optuna.importance.FanovaImportanceEvaluator(), - ) - else: - importances = optuna.importance.get_param_importances( - study, evaluator=optuna.importance.FanovaImportanceEvaluator() - ) - except Exception: - return {} - - aliases = config.get("legacy_param_aliases", {}) - display: Dict[str, float] = {} - for param, value in importances.items(): - canonical = aliases.get(param, param) - label = param_display_name(canonical, config) - display[label] = max(display.get(label, 0.0), float(value)) - return display - - -def _recent_trials_summary(study, config: Dict[str, Any], limit: int) -> List[Dict[str, Any]]: - ev = config.get("eval_protocol", {}) - train_param = ev.get("train_resolution_param", "resolution") - dice_fixed_key = ev.get("fixed_dice_attr", "dice_eval_fixed") - bce_fixed_key = ev.get("fixed_bce_attr", "bce_eval_fixed") - - ordered = sorted(study.trials, key=lambda t: t.number, reverse=True)[:limit] - out: List[Dict[str, Any]] = [] - for t in ordered: - dice = get_score(t, study) if t.state == TrialState.COMPLETE else t.user_attrs.get("latest_dice") - bce = get_loss(t, study) if t.state == TrialState.COMPLETE else t.user_attrs.get("latest_bce") - out.append({ - "number": t.number, - "state": t.state.name, - "params": normalize_trial_params(dict(t.params), config), - "train_resolution": trial_train_resolution(t, train_param), - "dice_train": dice, - "bce_train": bce, - "dice_eval_fixed": t.user_attrs.get(dice_fixed_key), - "bce_eval_fixed": t.user_attrs.get(bce_fixed_key), - "latest_epoch": t.user_attrs.get("latest_epoch"), - }) - return out - - -def build_review_packet(study_name: str) -> Dict[str, Any]: - """Assemble the compacted HPO review packet, utilizing a lazy materialization cache layer. - - This is the single source of truth for both the HTTP broker API and the MCP server. - """ - try: - study = optuna.load_study(study_name=study_name, storage=DATABASE_URL) - n_eval = count_evaluated_trials(study) - - # Check compacted packets cache - with get_db_session() as session: - cached = session.query(CompactedPacket).filter_by( - study_name=study_name, trials_evaluated=n_eval - ).first() - if cached: - try: - packet = json.loads(cached.packet_json) - n_complete = len(get_completed_trials(study)) - packet["statistical_confidence"] = compute_statistical_confidence(n_complete) - packet["coordinator_accuracy"] = compute_coordinator_accuracy(study_name) - packet["review_prompt"] = build_review_prompt(study_name) - packet["policy_actions"] = list(POLICY_ACTIONS) - packet["latest_review"] = get_latest_study_review(study_name) - return packet - except Exception as e: - logger.warning(f"Failed to load cached packet for {study_name}: {e}") - - # Otherwise materialize from scratch - with get_db_session() as session: - # 1. Fetch DB metrics - db_metrics = {} - rows = session.query(TrialResult).filter_by(study_name=study_name).all() - for r in rows: - db_metrics[r.trial_id] = r.to_dict() - - # 2. Fetch search space and config - search_space = load_active_search_space(study_name) - config = load_hpo_config(study_name) - - # 3. Fetch project context - project_context = {} - context_row = session.query(SystemConfiguration).filter_by( - study_name=study_name, config_key="project_context" - ).first() - if context_row: - try: - project_context = json.loads(context_row.config_value) - except Exception as e: - logger.warning(f"Failed to parse project_context for {study_name}: {e}") - - # 4. Fetch health tier - health_tier, health_reason = compute_health_tier(study, study_name) - - # 5. Fetch past reviews - past_reviews = [] - rev_rows = ( - session.query(StudyReview) - .filter_by(study_name=study_name) - .order_by(StudyReview.created_at.desc(), StudyReview.id.desc()) - .limit(3) - .all() - ) - for r in rev_rows: - past_reviews.append(r.to_dict()) - - # 6. Accuracy + statistical confidence - n_complete = len(get_completed_trials(study)) - statistical_confidence = compute_statistical_confidence(n_complete) - accuracy_stats = compute_coordinator_accuracy(study_name) - - # 7. Assemble compacted packet using build_compacted_packet - from .analytics import build_compacted_packet - packet = build_compacted_packet( - study_name, - study, - db_metrics, - search_space, - config, - health_tier, - health_reason, - past_reviews, - accuracy_stats, - project_context, - statistical_confidence, - ) - - # Cache it in compacted_packets table - with get_db_session() as session: - session.merge(CompactedPacket( - study_name=study_name, - trials_evaluated=n_eval, - packet_json=json.dumps(packet) - )) - - packet["statistical_confidence"] = statistical_confidence - packet["coordinator_accuracy"] = accuracy_stats - packet["review_prompt"] = build_review_prompt(study_name) - packet["policy_actions"] = list(POLICY_ACTIONS) - packet["latest_review"] = get_latest_study_review(study_name) - return packet - except Exception as e: - import traceback - traceback.print_exc() - return {"success": False, "error": f"Failed to build review packet: {str(e)}"} - - -def _validate_manual_parameters(manual_parameters: Dict[str, Any], study_name: str) -> Dict[str, Any]: - """Validate agent-proposed params against DB-backed search space.""" - from .validators import UNetHyperparameters, LEGACY_UNET_PARAMS - - config = load_hpo_config(study_name) - norm = normalize_trial_params(dict(manual_parameters), config) - - space = load_active_search_space(study_name) - space_keys = {k for k in space.keys() if not k.startswith("_") and isinstance(space.get(k), dict)} - - # Study-specific validator for bridge-crack U-Net params - if space_keys == LEGACY_UNET_PARAMS: - try: - valid = UNetHyperparameters(**norm) - return {"ok": True, "params": valid.model_dump(), "error": None, "warnings": []} - except Exception as exc: - return {"ok": False, "params": {}, "error": str(exc), "warnings": []} - - warnings: List[str] = [] - out: Dict[str, Any] = {} - - for name, spec in space.items(): - if name.startswith("_") or not isinstance(spec, dict): - continue - ptype = spec.get("type", "float") - if name not in norm: - return {"ok": False, "params": {}, "error": f"Missing required parameter '{name}'.", "warnings": warnings} - value = norm[name] - - if ptype in ("float", "float_log", "int"): - try: - num = float(value) - except (TypeError, ValueError): - return {"ok": False, "params": {}, "error": f"Parameter '{name}' must be numeric.", "warnings": warnings} - lo, hi = spec.get("min"), spec.get("max") - if lo is not None and num < float(lo): - return {"ok": False, "params": {}, "error": f"'{name}'={num} below min {lo}.", "warnings": warnings} - if hi is not None and num > float(hi): - return {"ok": False, "params": {}, "error": f"'{name}'={num} above max {hi}.", "warnings": warnings} - out[name] = int(round(num)) if ptype == "int" else num - elif ptype == "categorical": - options = spec.get("options", []) - coerced = value - if value not in options: - for opt in options: - if str(opt) == str(value): - coerced = opt - break - if coerced not in options: - return {"ok": False, "params": {}, "error": f"'{name}'={value} not in options {options}.", "warnings": warnings} - active = spec.get("active", options) - if coerced not in active: - warnings.append(f"'{name}'={coerced} is allowed but not in the active set {active}.") - out[name] = coerced - else: - out[name] = value - - extra = [k for k in norm if k not in space_keys] - if extra: - warnings.append(f"Ignoring parameters not in search space: {extra}.") - - return {"ok": True, "params": out, "error": None, "warnings": warnings} - - -def load_study_cards(study_name: Optional[str] = None) -> List[Dict[str, Any]]: - """Query and return generated study cards, loading their markdown contents from disk if available.""" - from .schema import StudyCard - with get_db_session() as session: - query = session.query(StudyCard) - if study_name: - query = query.filter_by(study_name=study_name) - cards = query.all() - - result = [] - for c in cards: - content = "" - root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) - full_path = os.path.join(root_dir, c.file_path) - if os.path.exists(full_path): - try: - with open(full_path, "r") as f: - content = f.read() - except Exception as e: - logger.warning(f"Failed to read model card {full_path}: {e}") - card_dict = c.to_dict() - card_dict["markdown_content"] = content - result.append(card_dict) - return result - - -_last_hook_trigger: Dict[str, float] = {} - -def write_ide_status_file(study_name: str, health_tier: str, health_reason: str, study) -> None: - """Writes the current health status to .hpo_status.json in the workspace root.""" - import datetime - - trials_evaluated = count_evaluated_trials(study) - - # Form status payload - payload = { - "study_name": study_name, - "health_tier": health_tier.lower(), - "health_reason": health_reason, - "trials_evaluated": trials_evaluated, - "review_recommended": health_tier.lower() in ("watch", "intervene"), - "last_updated": datetime.datetime.utcnow().isoformat() - } - - # Write to .hpo_status.json in workspace root - from pathlib import Path - root_dir = Path(__file__).resolve().parent.parent - status_file_path = root_dir / ".hpo_status.json" - - try: - with open(status_file_path, "w") as f: - json.dump(payload, f, indent=4) - except Exception as e: - print(f"Error writing .hpo_status.json: {e}") - - diff --git a/tests/test_coordinator_packet.py b/tests/test_coordinator_packet.py deleted file mode 100644 index 72af6f1..0000000 --- a/tests/test_coordinator_packet.py +++ /dev/null @@ -1,98 +0,0 @@ -import os -import sys - -# Ensure project root is in sys.path and HPO_DATABASE_URL is set before any src imports -_project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if _project_root not in sys.path: - sys.path.insert(0, _project_root) - -if "HPO_DATABASE_URL" not in os.environ: - import tempfile - _test_db_fd, TEST_DB_PATH = tempfile.mkstemp(suffix=".db", prefix="hpo_test_suite_") - os.close(_test_db_fd) - os.environ["HPO_DATABASE_URL"] = f"sqlite:///{TEST_DB_PATH}" - import atexit - def _cleanup(): - for suffix in ("", "-shm", "-wal"): - try: - os.unlink(TEST_DB_PATH + suffix) - except OSError: - pass - atexit.register(_cleanup) - -import unittest -import optuna -from optuna.trial import TrialState - -from src.db_manager import init_db, get_db_session -from src.schema import TrialResult, StudyReview -from src.hpo_coordinator import build_review_packet, build_review_prompt, save_study_review - - -class TestCoordinatorPacket(unittest.TestCase): - @classmethod - def setUpClass(cls): - init_db() - - def setUp(self): - self.study_name = "test_study_coord_" + self._testMethodName - self.study = optuna.create_study( - study_name=self.study_name, - storage=os.environ["HPO_DATABASE_URL"], - directions=["minimize", "maximize"], - load_if_exists=True - ) - - def test_build_packet_and_prompt(self): - """Review prompt contains best trial info and packet has required keys.""" - self.study.enqueue_trial({"learning_rate": 1e-3, "batch_size": 16}) - t = self.study.ask() - self.study.tell(t.number, [0.2, 0.8]) - - # Test review prompt references the best trial - prompt = build_review_prompt(self.study_name) - self.assertIn("Trial #0", prompt) - self.assertIn("0.8000", prompt) - - # Test build review packet has binned trials and keys - packet = build_review_packet(self.study_name) - self.assertIn("trial_bins", packet) - self.assertIn("spearman_correlations", packet) - - def test_save_study_review_confidence_low(self): - """Citing a non-existent trial results in low confidence.""" - self.study.enqueue_trial({"learning_rate": 1e-3, "batch_size": 16}) - t = self.study.ask() - self.study.tell(t.number, [0.1, 0.9]) - - res = save_study_review( - self.study_name, - "Citing wrong trial number", - health_rating=3, - policy_action="no_change", - trials_evaluated=1, - cited_best_trial=999 # non-existent trial - ) - self.assertTrue(res["success"]) - self.assertEqual(res["review"]["confidence"], "low") - - def test_save_study_review_confidence_high(self): - """Citing the correct best trial results in high confidence.""" - self.study.enqueue_trial({"learning_rate": 1e-3, "batch_size": 16}) - t = self.study.ask() - self.study.tell(t.number, [0.1, 0.9]) - - res = save_study_review( - self.study_name, - "Citing correct trial number", - health_rating=4, - policy_action="no_change", - trials_evaluated=1, - cited_best_trial=0 - ) - self.assertTrue(res["success"]) - self.assertEqual(res["review"]["confidence"], "high") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_lean_roadmap.py b/tests/test_lean_roadmap.py deleted file mode 100644 index 55c748c..0000000 --- a/tests/test_lean_roadmap.py +++ /dev/null @@ -1,124 +0,0 @@ -import os -import sys -import datetime -import unittest - -_project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if _project_root not in sys.path: - sys.path.insert(0, _project_root) - -if "HPO_DATABASE_URL" not in os.environ: - import tempfile - _test_db_fd, TEST_DB_PATH = tempfile.mkstemp(suffix=".db", prefix="hpo_lean_") - os.close(_test_db_fd) - os.environ["HPO_DATABASE_URL"] = f"sqlite:///{TEST_DB_PATH}" - import atexit - - def _cleanup(): - for suffix in ("", "-shm", "-wal"): - try: - os.unlink(TEST_DB_PATH + suffix) - except OSError: - pass - - atexit.register(_cleanup) - -import optuna -from optuna.trial import TrialState - -from src.db_manager import init_db, get_db_session -from src.schema import StudyReview -from src.hpo_coordinator import ( - compute_statistical_confidence, - compute_coordinator_accuracy, - backfill_review_outcomes, - mark_review_applied, - validate_review_fields, - save_study_review, - build_review_packet, -) -from hpo_mcp_server import validate_search_space - - -class TestLeanRoadmap(unittest.TestCase): - @classmethod - def setUpClass(cls): - init_db() - - def setUp(self): - self.study_name = "test_lean_" + self._testMethodName - self.study = optuna.create_study( - study_name=self.study_name, - storage=os.environ["HPO_DATABASE_URL"], - directions=["minimize", "maximize"], - load_if_exists=True, - ) - - def _complete_trial(self, loss: float, score: float): - self.study.enqueue_trial({"learning_rate": 1e-3, "batch_size": 8}) - t = self.study.ask() - self.study.tell(t.number, [loss, score]) - - def test_statistical_confidence_tiers(self): - self.assertEqual(compute_statistical_confidence(5), "low") - self.assertEqual(compute_statistical_confidence(15), "medium") - self.assertEqual(compute_statistical_confidence(25), "high") - packet = build_review_packet(self.study_name) - self.assertIn("statistical_confidence", packet) - - def test_validate_review_fields_contract(self): - bad = validate_review_fields(None, None) - self.assertFalse(bad["ok"]) - good = validate_review_fields(0.03, 0) - self.assertTrue(good["ok"]) - - def test_coordinator_accuracy_insufficient_data(self): - acc = compute_coordinator_accuracy(self.study_name) - self.assertTrue(acc["insufficient_data"]) - self.assertEqual(acc["n_scored_reviews"], 0) - - def test_backfill_measured_outcome(self): - for i in range(6): - self._complete_trial(0.5 - i * 0.01, 0.5 + i * 0.02) - - save_study_review( - self.study_name, - "Narrow LR for gain", - health_rating=4, - policy_action="update_active_search_space", - trials_evaluated=6, - estimated_score_improvement=0.05, - cited_best_trial=5, - force=True, - ) - mark_review_applied(self.study_name) - - for i in range(5): - self._complete_trial(0.3, 0.7 + i * 0.01) - - backfill_review_outcomes(self.study_name) - with get_db_session() as session: - review = ( - session.query(StudyReview) - .filter_by(study_name=self.study_name) - .order_by(StudyReview.id.desc()) - .first() - ) - self.assertEqual(review.outcome_status, "measured") - self.assertIsNotNone(review.actual_score_improvement) - - acc = compute_coordinator_accuracy(self.study_name) - self.assertEqual(acc["n_scored_reviews"], 1) - self.assertTrue(acc["insufficient_data"]) - - def test_validate_search_space_empty_tunable(self): - pinned = { - "learning_rate": {"min": 1e-3, "max": 1e-3, "type": "float_log"}, - "batch_size": {"options": [8], "active": [8], "type": "categorical"}, - } - result = validate_search_space(pinned) - self.assertFalse(result["valid"]) - - -if __name__ == "__main__": - unittest.main() From bd7d1bc4b908283b28d1d4d4fa66911bd45cb1fb Mon Sep 17 00:00:00 2001 From: Ishaan Date: Tue, 30 Jun 2026 10:33:26 -0400 Subject: [PATCH 2/8] implement health check + add telemetry --- src/analytics.py | 715 ++++++++++++++++++++++++++++++++++++---------- src/db_manager.py | 62 +--- src/health.py | 151 ++++++++++ src/hpo_daemon.py | 130 +++------ src/metrics.py | 63 ++-- src/schema.py | 165 +---------- src/validators.py | 61 ---- 7 files changed, 801 insertions(+), 546 deletions(-) create mode 100644 src/health.py delete mode 100644 src/validators.py diff --git a/src/analytics.py b/src/analytics.py index 7d2e399..843e04c 100644 --- a/src/analytics.py +++ b/src/analytics.py @@ -1,25 +1,33 @@ -import math import json -from typing import List, Dict, Any, Optional +import math +import os +from typing import Any, Dict, List, Optional + +import optuna from optuna.trial import TrialState -from .metrics import get_score, get_loss + +from .db_manager import get_db_session, DATABASE_URL +from .hpo_config import load_hpo_config, normalize_trial_params, param_display_name +from .metrics import get_score, get_loss, get_best_trial, score_objective_index, get_completed_trials, get_eval_attr_names +from .schema import TrialResult, SystemConfiguration, CompactedPacket, StudyCard + +logger = __import__('logging').getLogger(__name__) + def compress_loss_curve(history: List[Dict[str, Any]]) -> Dict[str, Any]: - """Compresses a raw epoch-by-epoch history curve into key indicators to save tokens.""" if not history: return {} scores = [h.get("score") for h in history if h.get("score") is not None] losses = [h.get("loss") for h in history if h.get("loss") is not None] - + res = { "initial_score": scores[0] if scores else None, "min_loss": min(losses) if losses else None, "final_score": scores[-1] if scores else None, "final_loss": losses[-1] if losses else None, - "total_epochs": len(history) + "total_epochs": len(history), } - - # Linear slope of the last 10% of epochs (convergence slope) + y = losses if losses else (scores if scores else []) if len(y) >= 2: n = max(2, int(len(y) * 0.1)) @@ -39,56 +47,54 @@ def compress_loss_curve(history: List[Dict[str, Any]]) -> Dict[str, Any]: res["convergence_slope"] = 0.0 return res + def bin_trials(study, db_metrics: Dict[int, Any], search_space: Dict[str, Any]) -> Dict[str, Any]: - """Segments trials into Elite (top 10%), Noise Floor (middle 80%), and Failure modes.""" + from .search_space import _fixed_categorical_params + fixed = _fixed_categorical_params(search_space) trials = list(study.trials) - completed_trials = [] - failed_trials = [] - + completed_trials: List[Dict[str, Any]] = [] + failed_trials: List[Dict[str, Any]] = [] + for t in trials: if t.state == TrialState.COMPLETE: - # Use generic helpers that derive indices from study directions s = get_score(t, study) score = s if s is not None else 0.0 l = get_loss(t, study) loss = l if l is not None else 0.0 - # Fetch from db_metrics if present - metric = db_metrics.get(t.number, {}) + metric = db_metrics.get(t._trial_id, {}) score = metric.get("primary_score") if metric.get("primary_score") is not None else score loss = metric.get("primary_loss") if metric.get("primary_loss") is not None else loss - + + params = {**dict(t.params), **fixed} completed_trials.append({ "trial_id": t.number, - "params": dict(t.params), + "params": params, "primary_score": score, "primary_loss": loss, - "epoch_reached": metric.get("epoch_reached", t.user_attrs.get("latest_epoch", 0)) + "epoch_reached": metric.get("epoch_reached", t.user_attrs.get("latest_epoch", 0)), }) elif t.state in (TrialState.FAIL, TrialState.PRUNED): - # Check OOM or failure status - metric = db_metrics.get(t.number, {}) + metric = db_metrics.get(t._trial_id, {}) oom = metric.get("oom_triggered") or t.user_attrs.get("oom_triggered", False) failure_tag = metric.get("failure_tag") or ("OOM" if oom else "PRUNED" if t.state == TrialState.PRUNED else "FAILED") - failed_trials.append({ "trial_id": t.number, - "params": dict(t.params), - "failure_tag": failure_tag + "params": {**dict(t.params), **fixed}, + "failure_tag": failure_tag, }) - - # Sort completed trials by score descending (higher score is better) + completed_trials.sort(key=lambda x: x["primary_score"] or 0.0, reverse=True) n_completed = len(completed_trials) - - elite = [] - noise_floor = {} - + + elite: List[Dict[str, Any]] = [] + noise_floor: Dict[str, Any] = {} + if n_completed > 0: elite_count = max(1, int(math.ceil(n_completed * 0.1))) elite = completed_trials[:elite_count] middle_trials = completed_trials[elite_count:] - + if middle_trials: scores = [t["primary_score"] for t in middle_trials if t["primary_score"] is not None] n_mid = len(scores) @@ -100,69 +106,467 @@ def bin_trials(study, db_metrics: Dict[int, Any], search_space: Dict[str, Any]) else: median_score = 0.0 variance = 0.0 - + param_ranges = {} - # Gather all param names in search space param_names = set(search_space.keys()) for t in middle_trials: param_names.update(t["params"].keys()) - for p_name in param_names: p_vals = [t["params"].get(p_name) for t in middle_trials if t["params"].get(p_name) is not None] if not p_vals: continue try: - # Numeric ranges numeric_vals = [float(v) for v in p_vals] param_ranges[p_name] = [min(numeric_vals), max(numeric_vals)] except (ValueError, TypeError): - # Categorical counts - counts = {} + counts: Dict[str, int] = {} for v in p_vals: counts[str(v)] = counts.get(str(v), 0) + 1 param_ranges[p_name] = counts - + noise_floor = { "count": len(middle_trials), "median_score": median_score, "score_variance": variance, - "param_ranges": param_ranges + "param_ranges": param_ranges, } else: - noise_floor = { - "count": 0, - "median_score": 0.0, - "score_variance": 0.0, - "param_ranges": {} - } - - # Aggregated failure/OOM modes count matrix - failure_matrix = {} + noise_floor = {"count": 0, "median_score": 0.0, "score_variance": 0.0, "param_ranges": {}} + + failure_matrix: Dict[str, int] = {} for t in failed_trials: - # Construct combinations of parameters for categorization - # Focus on key hyperparams: batch_size, resolution, lr if they exist, or just all params sorted important_params = ["batch_size", "resolution", "lr"] key_parts = [] for p in important_params: if p in t["params"]: key_parts.append(f"{p}={t['params'][p]}") - if not key_parts: - # Fallback to sorting all params key_parts = [f"{k}={v}" for k, v in sorted(t["params"].items())] - param_key = " && ".join(key_parts) if key_parts else "unknown_params" tag = t["failure_tag"] - full_key = f"{param_key} [{tag}]" failure_matrix[full_key] = failure_matrix.get(full_key, 0) + 1 - + + return {"elite": elite, "noise_floor": noise_floor, "failure_matrix": failure_matrix} + + +# --- Train-resolution helper (shared with pruning/pareto) --- + +def trial_train_resolution(trial, train_param: str) -> Optional[int]: + val = trial.params.get(train_param) + if val is None: + return None + try: + return int(val) + except (TypeError, ValueError): + return None + + +# --- fANOVA --- + +def get_fanova_importances(study, config: Dict[str, Any]) -> Dict[str, float]: + complete = get_completed_trials(study) + if len(complete) < 2: + return {} + importances: Dict[str, float] = {} + try: + if len(study.directions) > 1: + _si = score_objective_index(study) + if _si is not None: + importances = optuna.importance.get_param_importances( + study, + target=lambda t, idx=_si: t.values[idx] if (t.values and len(t.values) > idx) else None, + evaluator=optuna.importance.FanovaImportanceEvaluator(), + ) + else: + importances = optuna.importance.get_param_importances( + study, evaluator=optuna.importance.FanovaImportanceEvaluator() + ) + except Exception: + return {} + + aliases = config.get("legacy_param_aliases", {}) + display: Dict[str, float] = {} + for param, value in importances.items(): + canonical = aliases.get(param, param) + label = param_display_name(canonical, config) + display[label] = max(display.get(label, 0.0), float(value)) + return display + + +# --- Pareto --- + +def pareto_trial_numbers_deploy_aware(study, hpo_config: Dict[str, Any]) -> List[int]: + ev = hpo_config.get("eval_protocol", {}) + train_param = ev.get("train_resolution_param", "resolution") + low_warn = ev.get("low_train_res_warning") + low_warn = int(low_warn) if low_warn is not None else None + deploy_only = ev.get("pareto_deploy_resolution_only", True) + score_fixed_key = ev.get("fixed_score_attr", "score_eval_fixed") + + points: List[tuple] = [] + for t in study.trials: + if t.state != TrialState.COMPLETE: + continue + loss_val = get_loss(t, study) + score_val = get_score(t, study) + if loss_val is None or score_val is None: + continue + train_res = trial_train_resolution(t, train_param) + if deploy_only and low_warn is not None and train_res is not None and train_res < low_warn: + continue + if ev.get("enabled"): + fd = t.user_attrs.get(score_fixed_key) + if fd is not None: + score_val = float(fd) + points.append((t.number, float(loss_val), float(score_val))) + + if not points: + try: + return [t.number for t in study.best_trials] + except Exception: + return [] + + pareto: List[int] = [] + for num_i, loss_i, score_i in points: + dominated = False + for num_j, loss_j, score_j in points: + if num_i == num_j: + continue + if loss_j <= loss_i and score_j >= score_i and (loss_j < loss_i or score_j > score_i): + dominated = True + break + if not dominated: + pareto.append(num_i) + return pareto + + +# --- Boundary hits --- + +def check_boundary_hits(study, pareto_numbers: List[int], search_space: Dict[str, Any]) -> Dict[str, Any]: + hits: Dict[str, Any] = {} + pareto_trials = [t for t in study.trials if t.number in pareto_numbers and t.state == TrialState.COMPLETE] + n_pareto = len(pareto_trials) + if n_pareto == 0: + return hits + + for p_name, p_info in search_space.items(): + p_type = p_info.get("type", "") + if p_type not in ("float", "float_log", "int"): + continue + s_min = p_info.get("min") + s_max = p_info.get("max") + if s_min is None or s_max is None or s_max <= s_min: + continue + s_min, s_max = float(s_min), float(s_max) + margin = 0.1 * (s_max - s_min) + near_min_count = 0 + near_max_count = 0 + for t in pareto_trials: + val = t.params.get(p_name) + if val is not None: + val = float(val) + if val <= s_min + margin: + near_min_count += 1 + if val >= s_max - margin: + near_max_count += 1 + total_hits = near_min_count + near_max_count + ratio = total_hits / n_pareto + if ratio > 0.6: + hits[p_name] = { + "near_min_count": near_min_count, + "near_max_count": near_max_count, + "total_pareto": n_pareto, + "hit_ratio": ratio, + "bound_hit": "min" if near_min_count > near_max_count else "max" if near_max_count > near_min_count else "both", + } + return hits + + +# --- Fidelity durations --- + +def compute_fidelity_durations(study, config: Dict[str, Any]) -> Dict[str, Any]: + ev = config.get("eval_protocol", {}) + train_param = ev.get("train_resolution_param", "resolution") + groups: Dict[int, List] = {} + for t in study.trials: + if t.state != TrialState.COMPLETE: + continue + val = t.params.get(train_param) + if val is None: + continue + try: + val = int(val) + except (TypeError, ValueError): + continue + groups.setdefault(val, []).append(t) + + res_stats: Dict[int, Dict[str, Any]] = {} + for val, trials in groups.items(): + durations = [] + epoch_durations = [] + for t in trials: + if t.datetime_start and t.datetime_complete: + dur = (t.datetime_complete - t.datetime_start).total_seconds() + durations.append(dur) + history = t.user_attrs.get("history", []) + epochs = len(history) if history else t.user_attrs.get("latest_epoch") + if not epochs: + epochs = max([h.get("epoch", 1) for h in history] or [1]) + if epochs > 0: + epoch_durations.append(dur / epochs) + if durations: + res_stats[val] = { + "avg_total_duration": sum(durations) / len(durations), + "avg_epoch_duration": sum(epoch_durations) / len(epoch_durations) if epoch_durations else None, + "count": len(durations), + } + + if not res_stats: + return {} + lowest_scale = min(res_stats.keys()) + base_dur = res_stats[lowest_scale]["avg_total_duration"] + for val, stats in res_stats.items(): + if base_dur > 0: + stats["overhead_ratio"] = stats["avg_total_duration"] / base_dur + else: + stats["overhead_ratio"] = 1.0 + return {"fidelity_param": train_param, "lowest_scale": lowest_scale, "scales": res_stats} + + +# --- VRAM telemetry --- + +def fit_vram_model(trials: List, db_metrics: Dict[int, Any], train_param: str) -> Optional[Dict[str, Any]]: + points: List[tuple] = [] + for t in trials: + if t.state != TrialState.COMPLETE: + continue + metric = db_metrics.get(t._trial_id, {}) + oom = metric.get("oom_triggered") or t.user_attrs.get("oom_triggered", False) + if oom: + continue + vram = metric.get("max_vram_gb") or t.user_attrs.get("max_vram_gb") + if vram is None: + continue + bs = t.params.get("batch_size") + res = t.params.get(train_param) + if bs is not None and res is not None: + try: + points.append((float(bs), float(res), float(vram))) + except (ValueError, TypeError): + continue + + if len(points) < 6: + return None + + X = [p[0] * (p[1] ** 2) for p in points] + Y = [p[2] for p in points] + if len(set(X)) < 2: + return None + + n = len(points) + sum_x = sum(X) + sum_y = sum(Y) + sum_xx = sum(x * x for x in X) + sum_xy = sum(X[i] * Y[i] for i in range(n)) + denom = n * sum_xx - sum_x * sum_x + if abs(denom) < 1e-12: + return None + + slope = (n * sum_xy - sum_x * sum_y) / denom + intercept = (sum_y - slope * sum_x) / n + ssr = sum((Y[i] - (slope * X[i] + intercept)) ** 2 for i in range(n)) + rse = (ssr / (n - 2)) ** 0.5 if n > 2 else 0.0 + return {"slope": slope, "intercept": intercept, "n_points": n, "rse": rse} + + +def compute_vram_telemetry(study, db_metrics: Dict[int, Any], search_space: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]: + ev = config.get("eval_protocol", {}) + train_param = ev.get("train_resolution_param", "resolution") + trials = list(study.trials) + model = fit_vram_model(trials, db_metrics, train_param) + + gpu_capacity_gb = 0.0 + gpu_models: List[str] = [] + oom_count = 0 + + for t in trials: + metric = db_metrics.get(t._trial_id, {}) + vram = metric.get("max_vram_gb") or t.user_attrs.get("max_vram_gb") + gpu = metric.get("gpu_model") or t.user_attrs.get("gpu_model") + oom = metric.get("oom_triggered") or t.user_attrs.get("oom_triggered", False) + if vram: + gpu_capacity_gb = max(gpu_capacity_gb, float(vram)) + if gpu: + gpu_models.append(gpu) + if oom: + oom_count += 1 + + gpu_model = max(set(gpu_models), key=gpu_models.count) if gpu_models else "Unknown" + oom_risk = None + + if model and gpu_capacity_gb > 0: + max_bs = None + bs_info = search_space.get("batch_size", {}) + if bs_info.get("type") == "categorical": + active_bs = bs_info.get("active", []) + if active_bs: + max_bs = max(active_bs) + else: + max_bs = bs_info.get("max") + + max_res = None + res_info = search_space.get(train_param, {}) + if res_info.get("type") == "categorical": + active_res = res_info.get("active", []) + if active_res: + max_res = max(active_res) + else: + max_res = res_info.get("max") + + if max_bs is not None and max_res is not None: + predicted_mean_vram = model["slope"] * (float(max_bs) * (float(max_res) ** 2)) + model["intercept"] + margin = max(1.0, 1.96 * model["rse"]) + predicted_max_vram = predicted_mean_vram + margin + if predicted_max_vram > 0.9 * gpu_capacity_gb: + oom_risk = { + "max_batch_size": max_bs, + "max_resolution": max_res, + "predicted_mean_vram_gb": predicted_mean_vram, + "margin_gb": margin, + "predicted_max_vram_gb": predicted_max_vram, + "gpu_capacity_gb": gpu_capacity_gb, + "risk_level": "high" if predicted_max_vram > gpu_capacity_gb else "medium", + } + return { - "elite": elite, - "noise_floor": noise_floor, - "failure_matrix": failure_matrix + "gpu_model": gpu_model, + "gpu_capacity_gb": gpu_capacity_gb, + "oom_count": oom_count, + "vram_model": model, + "bounds_oom_risk": oom_risk, } + +# --- Eval insights --- + +def study_eval_insights(study, config: Dict[str, Any]) -> Dict[str, Any]: + ev = config.get("eval_protocol", {}) + train_param = ev.get("train_resolution_param", "resolution") + fixed_res = ev.get("fixed_resolution") + low_warn = ev.get("low_train_res_warning") + score_fixed_key, _ = get_eval_attr_names(ev) + + complete = get_completed_trials(study) + by_res: Dict[int, List] = {} + warnings: List[Dict[str, Any]] = [] + + for t in complete: + tr = t.params.get(train_param) + if tr is not None: + by_res.setdefault(int(tr), []).append(t) + + res_summary: Dict[int, Dict[str, Any]] = {} + for res, trials in sorted(by_res.items()): + scores = [get_score(t, study) for t in trials] + scores = [s for s in scores if s is not None] + fixed_scores = [t.user_attrs.get(score_fixed_key) for t in trials if t.user_attrs.get(score_fixed_key) is not None] + res_summary[res] = { + "count": len(trials), + "best_score_train": max(scores) if scores else None, + "best_score_fixed": max(fixed_scores) if fixed_scores else None, + } + + valid_deploy_exists = False + if low_warn is not None: + for t in complete: + tr = t.params.get(train_param) + fd = t.user_attrs.get(score_fixed_key) + if tr is not None and int(tr) >= int(low_warn) and fd is not None: + valid_deploy_exists = True + break + + if complete and ev.get("enabled") and fixed_res: + best_train = get_best_trial(complete, study) + if best_train is None: + best_train = complete[0] + train_res = best_train.params.get(train_param) + if train_res is not None and low_warn and int(train_res) < int(low_warn) and not valid_deploy_exists: + warnings.append({ + "code": "low_train_res_pareto", + "trial_number": best_train.number, + "message": f"Pareto-best trial #{best_train.number} trained at scale {train_res}, below warning threshold {low_warn}. Check fixed eval.", + }) + fd = best_train.user_attrs.get(score_fixed_key) + td = get_score(best_train, study) + if fd is not None and td is not None and (td - fd) > 0.08: + warnings.append({ + "code": "train_eval_gap", + "trial_number": best_train.number, + "message": f"Trial #{best_train.number}: train score {td:.3f} vs fixed-eval score {fd:.3f} β€” train scale/resolution may be inflating scores.", + }) + + best_deploy: Any = None + if ev.get("enabled"): + ranked = [t for t in complete if t.user_attrs.get(score_fixed_key) is not None] + if ranked: + best_deploy = max(ranked, key=lambda t: t.user_attrs.get(score_fixed_key)) + + return { + "resolution_summary": res_summary, + "warnings": warnings, + "best_deploy_trial_number": best_deploy.number if best_deploy else None, + "best_deploy_score_fixed": best_deploy.user_attrs.get(score_fixed_key) if best_deploy else None, + } + + +# --- Prune rate clusters --- + +def compute_prune_rate_clusters(study, search_space: Dict[str, Any]) -> Dict[str, Any]: + clusters: Dict[str, Any] = {} + continuous_params = [] + for p_name, p_info in search_space.items(): + if p_info.get("type", "") in ("float", "float_log", "int"): + continuous_params.append(p_name) + + for p_name in continuous_params: + vals: List[float] = [] + states: List[TrialState] = [] + for t in study.trials: + if t.state in (TrialState.COMPLETE, TrialState.PRUNED) and p_name in t.params: + val = t.params[p_name] + if val is not None: + vals.append(float(val)) + states.append(t.state) + if not vals: + continue + v_min, v_max = min(vals), max(vals) + if v_max <= v_min: + continue + w = (v_max - v_min) / 3.0 + bins = [ + {"min": v_min, "max": v_min + w, "total": 0, "pruned": 0}, + {"min": v_min + w, "max": v_min + 2 * w, "total": 0, "pruned": 0}, + {"min": v_min + 2 * w, "max": v_max, "total": 0, "pruned": 0}, + ] + for val, state in zip(vals, states): + if val <= bins[0]["max"]: + bin_idx = 0 + elif val <= bins[1]["max"]: + bin_idx = 1 + else: + bin_idx = 2 + bins[bin_idx]["total"] += 1 + if state == TrialState.PRUNED: + bins[bin_idx]["pruned"] += 1 + for b in bins: + b["prune_rate"] = b["pruned"] / b["total"] if b["total"] > 0 else 0.0 + clusters[p_name] = bins + return clusters + + +# --- Study packet assembly --- + def build_compacted_packet( study_name: str, study, @@ -171,24 +575,11 @@ def build_compacted_packet( config: Dict[str, Any], health_tier: str, health_reason: Optional[str], - past_reviews: List[Dict[str, Any]], - accuracy_stats: Dict[str, Any], project_context: Dict[str, Any], statistical_confidence: str = "low", ) -> Dict[str, Any]: - """Assembles a highly compressed, token-efficient HPO review packet.""" - from .hpo_coordinator import ( - get_fanova_importances, - pareto_trial_numbers_deploy_aware, - check_boundary_hits, - compute_fidelity_durations, - compute_vram_telemetry, - compute_spearman_rank_correlation - ) - trials = list(study.trials) - - # Study status counts + counts = { "total": len(trials), "complete": sum(1 for t in trials if t.state == TrialState.COMPLETE), @@ -196,110 +587,122 @@ def build_compacted_packet( "failed": sum(1 for t in trials if t.state == TrialState.FAIL), "running": sum(1 for t in trials if t.state == TrialState.RUNNING), } - - # 1. Compacted Trial Bins + trial_bins = bin_trials(study, db_metrics, search_space) - - # 2. fANOVA Importances (Top 5 only) + try: raw_importances = get_fanova_importances(study, config) - except Exception as e: + except Exception: raw_importances = {} sorted_importances = sorted(raw_importances.items(), key=lambda x: x[1], reverse=True)[:5] top_fanova = dict(sorted_importances) - - # 3. Spearman correlations (with confidence tags, numeric params only) - spearman_correlations = {} - complete_trials = [t for t in trials if t.state == TrialState.COMPLETE] - if complete_trials: - dice_fixed_key = config.get("eval_protocol", {}).get("fixed_dice_attr", "dice_eval_fixed") - for p_name, p_info in search_space.items(): - # Only analyze numeric parameters - if p_info.get("type") not in ("float", "float_log", "int"): - continue - - paired_x = [] - paired_y = [] - try: - for t in complete_trials: - val = t.params.get(p_name) - if val is None: - continue - - # Score lookup - fd = t.user_attrs.get(dice_fixed_key) - if fd is not None: - score_val = float(fd) - else: - s = get_score(t, study) - score_val = float(s) if s is not None else 0.0 - - paired_x.append(float(val)) - paired_y.append(score_val) - except (ValueError, TypeError): - continue - - if len(set(paired_x)) > 1 and len(paired_x) >= 3: - n_samples = len(paired_x) - confidence = "Low" if n_samples < 8 else "Moderate" if n_samples < 15 else "High" - corr_coef = compute_spearman_rank_correlation(paired_x, paired_y) - spearman_correlations[p_name] = { - "coefficient": round(corr_coef, 4), - "n_samples": n_samples, - "confidence": confidence - } - - # 4. Boundary hits (Pareto-adjacent params only) - pareto_numbers = ( - pareto_trial_numbers_deploy_aware(study, config) if len(study.directions) > 1 else [] - ) - raw_boundary_hits = check_boundary_hits(study, pareto_numbers, search_space) - # Filter to only params that actually hit boundaries (near_min or near_max) - boundary_hits = {k: v for k, v in raw_boundary_hits.items() if v.get("hit_ratio", 0) > 0.0} - # 5. Fidelity durations + pareto_numbers = pareto_trial_numbers_deploy_aware(study, config) if len(study.directions) > 1 else [] + boundary_hits = check_boundary_hits(study, pareto_numbers, search_space) fidelity_durations = compute_fidelity_durations(study, config) - - # 6. VRAM Telemetry (with RSE prediction intervals) vram_telemetry = compute_vram_telemetry(study, db_metrics, search_space, config) - # 7. Past 3 Reviews (Summary + Action + Rating only, not full text if long) - compact_reviews = [] - for r in past_reviews[:3]: - summary_lines = r.get("summary", "").split("\n") - short_summary = summary_lines[0] if summary_lines else "" - if len(r.get("summary", "")) > 150: - short_summary = r.get("summary", "")[:147] + "..." - - compact_reviews.append({ - "id": r.get("id"), - "created_at": r.get("created_at"), - "health_rating": r.get("health_rating"), - "policy_action": r.get("policy_action"), - "trials_evaluated": r.get("trials_evaluated"), - "estimated_score_improvement": r.get("estimated_score_improvement"), - "quality_flagged": r.get("quality_flagged", False), - "outcome_status": r.get("outcome_status"), - "summary": short_summary - }) - return { "study_name": study_name, "counts": counts, "project_context": project_context, - "health": { - "tier": health_tier, - "reason": health_reason - }, + "health": {"tier": health_tier, "reason": health_reason}, "trial_bins": trial_bins, "fanova_importances": top_fanova, - "spearman_correlations": spearman_correlations, "boundary_hits": boundary_hits, "fidelity_durations": fidelity_durations, "vram_telemetry": vram_telemetry, - "past_reviews": compact_reviews, - "coordinator_accuracy": accuracy_stats, "statistical_confidence": statistical_confidence, "metric_score_label": config.get("metric_score_label", "Score"), - "metric_loss_label": config.get("metric_loss_label", "Loss") + "metric_loss_label": config.get("metric_loss_label", "Loss"), } + + +def build_study_packet(study_name: str) -> Dict[str, Any]: + try: + from .health import compute_health_tier, compute_statistical_confidence, count_evaluated_trials + + study = optuna.load_study(study_name=study_name, storage=DATABASE_URL) + n_eval = count_evaluated_trials(study) + + with get_db_session() as session: + cached = session.query(CompactedPacket).filter_by( + study_name=study_name, trials_evaluated=n_eval + ).first() + if cached: + try: + packet = json.loads(cached.packet_json) + n_complete = len(get_completed_trials(study)) + packet["statistical_confidence"] = compute_statistical_confidence(n_complete) + return packet + except Exception as e: + logger.warning(f"Failed to load cached packet for {study_name}: {e}") + + with get_db_session() as session: + db_metrics: Dict[int, Any] = {} + rows = session.query(TrialResult).filter_by(study_name=study_name).all() + for r in rows: + db_metrics[r.trial_id] = r.to_dict() + + from .search_space import load_search_space + search_space = load_search_space(study_name) + config = load_hpo_config(study_name) + + project_context: Dict[str, Any] = {} + context_row = session.query(SystemConfiguration).filter_by( + study_name=study_name, config_key="project_context" + ).first() + if context_row: + try: + project_context = json.loads(context_row.config_value) + except Exception as e: + logger.warning(f"Failed to parse project_context for {study_name}: {e}") + + health_tier, health_reason = compute_health_tier(study, study_name) + + n_complete = len(get_completed_trials(study)) + statistical_confidence = compute_statistical_confidence(n_complete) + + packet = build_compacted_packet( + study_name, study, db_metrics, search_space, config, + health_tier, health_reason, project_context, statistical_confidence, + ) + + with get_db_session() as session: + session.merge(CompactedPacket( + study_name=study_name, + trials_evaluated=n_eval, + packet_json=json.dumps(packet), + )) + + packet["statistical_confidence"] = statistical_confidence + return packet + except Exception as e: + import traceback + traceback.print_exc() + return {"success": False, "error": f"Failed to build study packet: {str(e)}"} + + +# --- Study cards --- + +def load_study_cards(study_name: Optional[str] = None) -> List[Dict[str, Any]]: + with get_db_session() as session: + query = session.query(StudyCard) + if study_name: + query = query.filter_by(study_name=study_name) + cards = query.all() + + result: List[Dict[str, Any]] = [] + for c in cards: + card_dict = c.to_dict() + full_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), c.file_path) + if os.path.exists(full_path): + try: + with open(full_path, "r") as f: + card_dict["markdown_content"] = f.read() + except Exception as e: + logger.warning(f"Failed to read study card {full_path}: {e}") + else: + card_dict["markdown_content"] = "" + result.append(card_dict) + return result diff --git a/src/db_manager.py b/src/db_manager.py index fee7634..8458234 100644 --- a/src/db_manager.py +++ b/src/db_manager.py @@ -1,6 +1,7 @@ import contextlib import logging +import os from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, Session from .schema import Base @@ -28,10 +29,17 @@ def set_sqlite_pragma(dbapi_connection, connection_record): cursor.execute("PRAGMA synchronous=NORMAL") cursor.execute("PRAGMA busy_timeout=30000") cursor.close() -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +SessionLocal = sessionmaker(bind=engine) def init_db(): """Initializes the database, creates tables, and runs additive migrations.""" + # Ensure parent directory exists for the default SQLite path (.data/) + if DATABASE_URL.startswith("sqlite:///"): + db_path = DATABASE_URL.replace("sqlite:///", "") + db_dir = os.path.dirname(db_path) + if db_dir and not os.path.isdir(db_dir): + os.makedirs(db_dir, exist_ok=True) + from sqlalchemy import inspect try: @@ -74,27 +82,11 @@ def init_db(): "health_tier": "VARCHAR(50)", "health_reason": "TEXT", }, - "study_reviews": { - "estimated_score_improvement": "FLOAT", - "cited_best_trial": "INTEGER", - "confidence": "VARCHAR(50) DEFAULT 'high'", - "baseline_best_score": "FLOAT", - "applied_at_completed_count": "INTEGER", - "applied_at": "DATETIME", - "actual_score_improvement": "FLOAT", - "outcome_measured_at": "DATETIME", - "outcome_status": "VARCHAR(30) DEFAULT 'pending'", - "quality_flagged": "BOOLEAN DEFAULT 0", - }, - "agent_reasoning_logs": { - "estimated_score_improvement": "FLOAT", - "actual_score_improvement": "FLOAT", - }, + "study_status": { "health_tier": "VARCHAR(50) DEFAULT 'healthy'", "health_reason": "TEXT", "health_updated_at": "DATETIME", - "nudge_dismissed_trials": "INTEGER", } } @@ -119,44 +111,12 @@ def _apply_additive_migrations(): if col_name in present: continue try: - # Check for old column name to copy data - old_name = None - if col_name == "estimated_score_improvement" and "estimated_dice_improvement" in present: - old_name = "estimated_dice_improvement" - elif col_name == "actual_score_improvement" and "actual_dice_improvement" in present: - old_name = "actual_dice_improvement" - with engine.begin() as conn: conn.execute(text(f'ALTER TABLE {table} ADD COLUMN {col_name} {col_type}')) - if old_name: - conn.execute(text(f'UPDATE {table} SET {col_name} = {old_name}')) except Exception as e: # Best-effort: a concurrent process may have added it already. print(f"Error migrating column {col_name} in {table}: {e}") - # Drop obsolete columns after confirming new columns are present and data is copied - for table, col_map in [ - ("agent_reasoning_logs", {"estimated_dice_improvement": "estimated_score_improvement", "actual_dice_improvement": "actual_score_improvement"}), - ("study_reviews", {"estimated_dice_improvement": "estimated_score_improvement", "actual_dice_improvement": "actual_score_improvement"}), - ]: - if table in existing_tables: - try: - present = {c["name"] for c in inspector.get_columns(table)} - except Exception: - continue - for old_col, new_col in col_map.items(): - if old_col in present: - try: - with engine.begin() as conn: - # If new column exists, migrate remaining NULL values if any - if new_col in present: - conn.execute(text(f"UPDATE {table} SET {new_col} = {old_col} WHERE {new_col} IS NULL")) - # Drop the obsolete column - conn.execute(text(f"ALTER TABLE {table} DROP COLUMN {old_col}")) - print(f"Migration: Dropped obsolete column '{old_col}' from table '{table}'") - except Exception as drop_err: - print(f"Migration: Error dropping obsolete column '{old_col}' from table '{table}': {drop_err}") - def _migrate_segmentation_metrics_to_trial_results(): from sqlalchemy import inspect, text @@ -212,7 +172,7 @@ def _migrate_segmentation_metrics_to_trial_results(): SELECT :study_name, {select_clause} FROM segmentation_metrics """ conn.execute(text(stmt), {"study_name": study_name}) - print(f"Successfully migrated data from segmentation_metrics to trial_results.") + print("Successfully migrated data from segmentation_metrics to trial_results.") except Exception as e: print(f"Error migrating segmentation_metrics data to trial_results: {e}") diff --git a/src/health.py b/src/health.py new file mode 100644 index 0000000..8b24916 --- /dev/null +++ b/src/health.py @@ -0,0 +1,151 @@ +"""Study health monitoring β€” health tier computation, statistical confidence, status file. + +This module replaces the health-monitoring portions of the deleted hpo_coordinator.py. +It contains no LLM-calling code, no review persistence, no coordinator logic. It is a +deterministic, read-only health assessment layer imported by the broker, daemon, and CLI. +""" + +import datetime +import json +import math +import os +from typing import Dict, Any, List, Optional, Tuple + +from optuna.trial import TrialState + +from .db_manager import get_db_session +from .metrics import get_score, get_completed_trials, TERMINAL_STATES + +_MIN_COMPLETED_FOR_FIRST_REVIEW = 5 + + +def compute_statistical_confidence(n_complete: int) -> str: + if n_complete < 10: + return "low" + if n_complete < 20: + return "medium" + return "high" + + +def count_evaluated_trials(study) -> int: + return sum(1 for t in study.trials if t.state in TERMINAL_STATES) + + +def compute_health_tier(study, study_name: str) -> Tuple[str, Optional[str]]: + + trials = list(study.trials) + finished = sorted([t for t in trials if t.state in TERMINAL_STATES], key=lambda t: t.number) + completed = sorted(get_completed_trials(study), key=lambda t: t.number) + + # === Intervene triggers === + + for t in trials: + if t.values: + for v in t.values: + if v is not None and (math.isnan(v) or math.isinf(v)): + return "intervene", f"NaN or Inf detected in reported metrics for Trial #{t.number}" + + try: + from .schema import TrialResult + with get_db_session() as session: + oom_trials = session.query(TrialResult).filter_by(study_name=study_name, oom_triggered=True).all() + if len(oom_trials) >= 2: + trial_params_map = {t._trial_id: t.params for t in study.trials} + oom_combos: Dict[tuple, int] = {} + for r in oom_trials: + params = trial_params_map.get(r.trial_id) + if params: + key = tuple(sorted((k, str(v)) for k, v in params.items())) + oom_combos[key] = oom_combos.get(key, 0) + 1 + if oom_combos[key] >= 2: + params_desc = ", ".join(f"{k}={v}" for k, v in key) + return "intervene", f"OOM cluster detected: parameter combination ({params_desc}) failed with OOM 2+ times" + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"Error checking OOM clusters: {e}") + + if len(completed) >= 5: + improvements: List[int] = [] + best_so_far = -float("inf") + for i, t in enumerate(completed): + score = get_score(t, study) + if score is None: + continue + if score > best_so_far + 1e-4: + best_so_far = score + improvements.append(i) + if len(improvements) >= 2: + intervals = [improvements[j] - improvements[j - 1] for j in range(1, len(improvements))] + avg_interval = sum(intervals) / len(intervals) + trials_since = len(completed) - 1 - improvements[-1] + threshold = max(4, int(math.ceil(2 * avg_interval))) + if trials_since >= threshold: + return "intervene", f"Score stagnation: no improvement over last {trials_since} completed trials (average improvement interval is {avg_interval:.1f} trials, threshold is {threshold})" + + if len(completed) >= 4: + from .hpo_config import load_hpo_config + config = load_hpo_config(study_name) + score_fixed_key = config.get("eval_protocol", {}).get("fixed_score_attr", "score_eval_fixed") + gaps: List[float] = [] + for t in completed: + fd = t.user_attrs.get(score_fixed_key) + td = get_score(t, study) + if fd is not None and td is not None: + gaps.append(float(td) - float(fd)) + if len(gaps) >= 4: + mean_gap = sum(gaps) / len(gaps) + var_gap = sum((g - mean_gap) ** 2 for g in gaps) / len(gaps) + std_gap = var_gap ** 0.5 + if std_gap > 0 and gaps[-1] > mean_gap + 2 * std_gap: + return "intervene", f"Train-eval gap anomaly: latest trial gap ({gaps[-1]:.4f}) exceeds 2 standard deviations of historical gap distribution (mean={mean_gap:.4f}, std={std_gap:.4f}, threshold={mean_gap + 2 * std_gap:.4f})" + + # === Watch triggers === + + if len(finished) >= 5: + recent_finished = finished[-5:] + pruned_count = sum(1 for t in recent_finished if t.state == TrialState.PRUNED) + if pruned_count >= 4: + return "watch", f"High prune rate: {pruned_count}/5 ({pruned_count * 20}%) of recent trials were pruned" + + if len(completed) >= 4: + scores = [get_score(t, study) for t in completed] + scores = [s for s in scores if s is not None] + scores.sort(reverse=True) + top_count = max(1, len(scores) // 4) + top_scores = scores[:top_count] + if len(top_scores) >= 2: + mean_top = sum(top_scores) / len(top_scores) + var_top = sum((x - mean_top) ** 2 for x in top_scores) / len(top_scores) + if var_top < 1e-4: + return "watch", f"Score convergence: top quartile score variance ({var_top:.6f}) is below 1e-4" + + running_trials = [t for t in trials if t.state == TrialState.RUNNING] + latest_completed = completed[-1:] if completed else [] + for t in (running_trials + latest_completed): + t_tier = t.user_attrs.get("health_tier") + t_reason = t.user_attrs.get("health_reason") + if t_tier == "watch" and t_reason: + return "watch", f"Trial #{t.number} warning: {t_reason}" + + return "healthy", "No issues detected. Search space is healthy." + + +def write_ide_status_file(study_name: str, health_tier: str, health_reason: str, study) -> None: + trials_evaluated = count_evaluated_trials(study) + payload = { + "study_name": study_name, + "health_tier": health_tier.lower(), + "health_reason": health_reason, + "trials_evaluated": trials_evaluated, + "review_recommended": health_tier.lower() in ("watch", "intervene"), + "last_updated": datetime.datetime.now(datetime.timezone.utc).isoformat(), + } + from pathlib import Path + root_dir = Path(__file__).resolve().parent.parent + status_file_path = root_dir / ".hpo_status.json" + try: + with open(status_file_path, "w") as f: + json.dump(payload, f, indent=4) + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"Error writing .hpo_status.json: {e}") diff --git a/src/hpo_daemon.py b/src/hpo_daemon.py index 494a321..a5fb3ab 100644 --- a/src/hpo_daemon.py +++ b/src/hpo_daemon.py @@ -1,107 +1,64 @@ -"""Background polling daemon for monitoring study health. +"""Background polling daemon for study health monitoring only. -Runs inside the FastAPI broker process as a background thread. It is intentionally NOT an -autopilot: it reclaims expired trial leases, recomputes each study's health tier, writes the -``.hpo_status.json`` hint file, and (optionally) fires a desktop notification so the human can -open the dashboard. It never calls an LLM and never mutates the search space β€” coordinator -reviews are always human-initiated (dashboard "Apply Proposal" or the MCP tools). +Runs inside the FastAPI broker process as a background thread. It reclaims expired trial +leases, recomputes each study's health tier via ``compute_health_tier``, writes the +``.hpo_status.json`` hint file, and updates ``StudyStatus`` in the database. It never calls +an LLM, never fires desktop notifications, and never recommends coordinator reviews. """ +import logging import time -import subprocess -from typing import Dict +from typing import List from src.db_manager import get_db_session +from src.health import compute_health_tier, write_ide_status_file from src.schema import StudyStatus -from src.hpo_coordinator import compute_review_heuristics -from src.hpo_config import load_hpo_config -DEFAULT_STUDY = "bridge_crack_study" - -# Cooldown to avoid alert spamming: {study_name: (last_alert_time, last_alert_trials)} -_ALERT_COOLDOWN_PERIOD = 300 # 5 minutes -_alert_history: Dict[str, tuple] = {} - - -def trigger_macos_notification(title: str, subtitle: str, message: str): - """Triggers a native macOS desktop alert popup using AppleScript.""" - try: - title_esc = title.replace('"', '\\"') - subtitle_esc = subtitle.replace('"', '\\"') - msg_esc = message.replace('"', '\\"') - - script = f'display notification "{msg_esc}" with title "{title_esc}" subtitle "{subtitle_esc}"' - subprocess.run(["osascript", "-e", script], check=True) - except Exception as e: - print(f"Error triggering macOS desktop notification: {e}") +logger = logging.getLogger(__name__) def check_and_alert_study(study_name: str): - """Recompute a study's health, refresh the status hint file, and notify if warranted. - - Notify-only: when a review is recommended and desktop notifications are enabled, fire a - macOS notification pointing the user at the dashboard. No LLM is ever called here. - """ + """Recompute a study's health tier, write the IDE status file, and persist to DB.""" try: - from .suggest import get_or_create_study + from .suggest import load_study try: - study = get_or_create_study(study_name) + study = load_study(study_name) except Exception: - # Study might not exist yet, skip silently return - hpo_config = load_hpo_config(study_name) - notifs_enabled = hpo_config.get("desktop_notifications_enabled", False) - - from src.hpo_coordinator import study_eval_insights, write_ide_status_file - insights = study_eval_insights(study, hpo_config) - heuristics = compute_review_heuristics(study, insights, hpo_config, study_name) + health_tier, health_reason = compute_health_tier(study, study_name) - n_eval = heuristics["trials_evaluated"] - health_tier = heuristics["health_tier"] - health_reason = heuristics["health_reason"] - - # Refresh the IDE status hint file (informational; agents may read it on demand). write_ide_status_file(study_name, health_tier, health_reason, study) - if not heuristics["review_recommended"]: - # Healthy, or already reviewed/dismissed for the current trial window. - return - - # Cooldown to avoid repeat alerts for the same trial window. - now = time.time() - if study_name in _alert_history: - last_time, last_trials = _alert_history[study_name] - if last_trials == n_eval or (now - last_time) < _ALERT_COOLDOWN_PERIOD: - return - _alert_history[study_name] = (now, n_eval) - - print(f"⚠️ Pathfinder Alert [{study_name.upper()}]: {health_tier.upper()} state. Reason: {health_reason}") - if notifs_enabled: - subtitle = f"Study Health: {health_tier.upper()}" - trigger_macos_notification("Pathfinder", subtitle, f"Trial #{n_eval} | {health_reason} (Open dashboard to review)") + with get_db_session() as session: + status = session.query(StudyStatus).filter_by(study_name=study_name).first() + if status is None: + status = StudyStatus(study_name=study_name) + session.add(status) + status.health_tier = health_tier + status.health_reason = health_reason + session.commit() - except Exception as e: - print(f"Error checking study health: {e}") + except Exception: + logger.exception("Error checking health for study %s", study_name) def reclaim_expired_leases(): - from datetime import datetime + from datetime import datetime, timezone import optuna from src.schema import TrialLease - from .suggest import get_or_create_study + from .suggest import load_study try: with get_db_session() as session: - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) expired = session.query(TrialLease).filter( TrialLease.lease_expires_at < now ).all() if expired: for lease in expired: try: - study = get_or_create_study(lease.study_name) - # Find corresponding trial number in Optuna + study = load_study(lease.study_name) trial_number = None for t in study.trials: if t._trial_id == lease.trial_id: @@ -113,21 +70,23 @@ def reclaim_expired_leases(): ) if trial_obj and trial_obj.state == optuna.trial.TrialState.RUNNING: study.tell(trial_number, state=optuna.trial.TrialState.FAIL) - print( - f"Daemon: Terminated expired leased Trial {trial_number} " - f"(ID {lease.trial_id}) in study '{lease.study_name}'." + logger.info( + "Terminated expired leased Trial %d (ID %s) in study '%s'.", + trial_number, lease.trial_id, lease.study_name, ) - except Exception as e: - print(f"Daemon: Failed to cleanly terminate expired trial ID {lease.trial_id}: {e}") + except Exception: + logger.exception( + "Failed to cleanly terminate expired trial ID %s", lease.trial_id + ) session.delete(lease) session.commit() - except Exception as e: - print(f"Daemon: Error in reclaim_expired_leases: {e}") + except Exception: + logger.exception("Error in reclaim_expired_leases") def run_daemon_loop(interval_seconds: int = 10): - """Indefinite daemon polling loop (notify-only health monitoring + lease reclamation).""" - print(f"Starting background health daemon thread (notify-only, interval: {interval_seconds}s)...") + """Indefinite daemon polling loop (health monitoring + lease reclamation).""" + logger.info("Starting background health daemon thread (interval: %ds)...", interval_seconds) last_reap_time = 0.0 while True: try: @@ -136,21 +95,18 @@ def run_daemon_loop(interval_seconds: int = 10): reclaim_expired_leases() last_reap_time = now - studies = [] + studies: List[str] = [] try: with get_db_session() as session: rows = session.query(StudyStatus).all() studies = [r.study_name for r in rows] - except Exception as e: - print(f"Failed to fetch studies: {e}") - - if not studies: - studies = [DEFAULT_STUDY] + except Exception: + logger.exception("Failed to fetch studies") for name in studies: check_and_alert_study(name) - except Exception as e: - print(f"Daemon loop error: {e}") + except Exception: + logger.exception("Daemon loop error") time.sleep(interval_seconds) diff --git a/src/metrics.py b/src/metrics.py index fdc9476..5c64781 100644 --- a/src/metrics.py +++ b/src/metrics.py @@ -4,8 +4,7 @@ ``directions`` list instead of assuming [minimize, maximize] order. This makes the codebase work for: - single-objective (maximize *or* minimize) - - multi-objective with any number of objectives and any direction ordering - - the original 2-obj [minimize, maximize] setup (fully backward-compatible) + - multi-objective with up to 2 objectives (one maximize, one minimize) """ from typing import List, Optional, Sequence @@ -134,12 +133,12 @@ def get_loss_from_dirs(trial: FrozenTrial, directions: Sequence[StudyDirection]) def _trial_metric_snapshot( trial: FrozenTrial, history: List[dict], - dice_fixed_attr: str, - bce_fixed_attr: str, + score_fixed_attr: str, + loss_fixed_attr: str, directions: Sequence[StudyDirection] = None, ) -> dict: """Score/Loss for dashboard: completed values, else latest epoch / user_attrs.""" - bce = dice = dice_eval_fixed = bce_eval_fixed = None + _score = _loss = _score_eval_fixed = _loss_eval_fixed = None latest_epoch = trial.user_attrs.get("latest_epoch") from optuna.trial import TrialState @@ -147,45 +146,41 @@ def _trial_metric_snapshot( if trial.state == TrialState.COMPLETE and (trial.values or trial.value is not None): if trial.values and len(trial.values) > 1: - bce = get_loss_from_dirs(trial, directions or []) - dice = get_score_from_dirs(trial, directions or []) + _loss = get_loss_from_dirs(trial, directions or []) + _score = get_score_from_dirs(trial, directions or []) else: if directions and directions[0] == StudyDirection.MINIMIZE: - bce = trial.value + _loss = trial.value else: - dice = trial.value + _score = trial.value else: - dice = trial.user_attrs.get("latest_score", trial.user_attrs.get("latest_dice")) - bce = trial.user_attrs.get("latest_loss", trial.user_attrs.get("latest_bce")) - dice_eval_fixed = trial.user_attrs.get("score_eval_fixed", trial.user_attrs.get(dice_fixed_attr)) - bce_eval_fixed = trial.user_attrs.get("loss_eval_fixed", trial.user_attrs.get(bce_fixed_attr)) + _score = trial.user_attrs.get("latest_score") + _loss = trial.user_attrs.get("latest_loss") + _score_eval_fixed = trial.user_attrs.get("score_eval_fixed", trial.user_attrs.get(score_fixed_attr)) + _loss_eval_fixed = trial.user_attrs.get("loss_eval_fixed", trial.user_attrs.get(loss_fixed_attr)) if history: last = max(history, key=lambda e: e.get("epoch", 0)) latest_epoch = latest_epoch or last.get("epoch") - if dice is None: - dice = last.get("score", last.get("dice")) - if bce is None: - bce = last.get("loss", last.get("bce")) - if dice_eval_fixed is None: - dice_eval_fixed = last.get("score_eval_fixed", last.get("dice_eval_fixed")) - if bce_eval_fixed is None: - bce_eval_fixed = last.get("loss_eval_fixed", last.get("bce_eval_fixed")) - - if dice_eval_fixed is None: - dice_eval_fixed = trial.user_attrs.get("score_eval_fixed", trial.user_attrs.get(dice_fixed_attr)) - if bce_eval_fixed is None: - bce_eval_fixed = trial.user_attrs.get("loss_eval_fixed", trial.user_attrs.get(bce_fixed_attr)) + if _score is None: + _score = last.get("score") + if _loss is None: + _loss = last.get("loss") + if _score_eval_fixed is None: + _score_eval_fixed = last.get("score_eval_fixed") + if _loss_eval_fixed is None: + _loss_eval_fixed = last.get("loss_eval_fixed") + + if _score_eval_fixed is None: + _score_eval_fixed = trial.user_attrs.get("score_eval_fixed", trial.user_attrs.get(score_fixed_attr)) + if _loss_eval_fixed is None: + _loss_eval_fixed = trial.user_attrs.get("loss_eval_fixed", trial.user_attrs.get(loss_fixed_attr)) return { - "bce": bce, - "dice": dice, - "score": dice, - "loss": bce, - "dice_eval_fixed": dice_eval_fixed, - "bce_eval_fixed": bce_eval_fixed, - "score_eval_fixed": dice_eval_fixed, - "loss_eval_fixed": bce_eval_fixed, + "score": _score, + "loss": _loss, + "score_eval_fixed": _score_eval_fixed, + "loss_eval_fixed": _loss_eval_fixed, "latest_epoch": latest_epoch, } diff --git a/src/schema.py b/src/schema.py index 5237599..21d0672 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1,7 +1,8 @@ import datetime import json +from datetime import timezone from typing import Optional, List, Dict, Any -from sqlalchemy import String, Integer, Float, DateTime, Text, ForeignKey, Boolean, UniqueConstraint +from sqlalchemy import String, Integer, Float, DateTime, Text, Boolean, UniqueConstraint from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): @@ -27,7 +28,7 @@ class TrialResult(Base): health_tier: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) health_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True) created_at: Mapped[datetime.datetime] = mapped_column( - DateTime, default=datetime.datetime.utcnow + DateTime, default=lambda: datetime.datetime.now(timezone.utc).replace(tzinfo=None) ) def get_history(self) -> List[Dict[str, Any]]: @@ -71,7 +72,7 @@ class TrialMetadata(Base): meta_key: Mapped[str] = mapped_column(String(100), nullable=False) meta_value: Mapped[str] = mapped_column(Text, nullable=False) created_at: Mapped[datetime.datetime] = mapped_column( - DateTime, default=datetime.datetime.utcnow + DateTime, default=lambda: datetime.datetime.now(timezone.utc).replace(tzinfo=None) ) def to_dict(self) -> Dict[str, Any]: @@ -92,7 +93,7 @@ class SystemConfiguration(Base): config_value: Mapped[str] = mapped_column(Text, nullable=False) version: Mapped[int] = mapped_column(Integer, default=1) updated_at: Mapped[datetime.datetime] = mapped_column( - DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow + DateTime, default=lambda: datetime.datetime.now(timezone.utc).replace(tzinfo=None), onupdate=lambda: datetime.datetime.now(timezone.utc).replace(tzinfo=None) ) def to_dict(self) -> Dict[str, Any]: @@ -113,7 +114,7 @@ class CompactedPacket(Base): trials_evaluated: Mapped[int] = mapped_column(Integer, nullable=False) packet_json: Mapped[str] = mapped_column(Text, nullable=False) created_at: Mapped[datetime.datetime] = mapped_column( - DateTime, default=datetime.datetime.utcnow + DateTime, default=lambda: datetime.datetime.now(timezone.utc).replace(tzinfo=None) ) class StudyCard(Base): @@ -126,7 +127,7 @@ class StudyCard(Base): content_hash: Mapped[str] = mapped_column(String(64), nullable=False) metadata_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True) created_at: Mapped[datetime.datetime] = mapped_column( - DateTime, default=datetime.datetime.utcnow + DateTime, default=lambda: datetime.datetime.now(timezone.utc).replace(tzinfo=None) ) def to_dict(self) -> Dict[str, Any]: @@ -140,92 +141,9 @@ def to_dict(self) -> Dict[str, Any]: "created_at": self.created_at.isoformat() if self.created_at else None, } -class AgentReasoningLog(Base): - __tablename__ = "agent_reasoning_logs" - trial_id: Mapped[int] = mapped_column(Integer, primary_key=True) - study_name: Mapped[str] = mapped_column(String(200), nullable=False) - model_version: Mapped[str] = mapped_column(String(100), nullable=False) - prompt_strategy: Mapped[str] = mapped_column(String(100), nullable=False) - predicted_outcome_rationale: Mapped[str] = mapped_column(Text, nullable=False) - estimated_score_improvement: Mapped[float] = mapped_column(Float, nullable=False) - actual_score_improvement: Mapped[Optional[float]] = mapped_column(Float, nullable=True) - created_at: Mapped[datetime.datetime] = mapped_column( - DateTime, default=datetime.datetime.utcnow - ) - - def to_dict(self) -> Dict[str, Any]: - return { - "trial_id": self.trial_id, - "study_name": self.study_name, - "model_version": self.model_version, - "prompt_strategy": self.prompt_strategy, - "predicted_outcome_rationale": self.predicted_outcome_rationale, - "estimated_score_improvement": self.estimated_score_improvement, - "actual_score_improvement": self.actual_score_improvement, - "created_at": self.created_at.isoformat() if self.created_at else None, - } - -class StudyReview(Base): - __tablename__ = "study_reviews" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - study_name: Mapped[str] = mapped_column(String(200), nullable=False) - health_rating: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # 1-5 - summary: Mapped[str] = mapped_column(Text, nullable=False) - policy_action: Mapped[str] = mapped_column(String(60), nullable=False, default="no_change") - model_version: Mapped[str] = mapped_column(String(100), nullable=False, default="unspecified") - prompt_strategy: Mapped[str] = mapped_column(String(100), nullable=False, default="coordinator_review") - reasons_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON list of trigger reasons - trials_evaluated: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # idempotency window key - estimated_score_improvement: Mapped[Optional[float]] = mapped_column(Float, nullable=True) - cited_best_trial: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) - confidence: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default="high") - baseline_best_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True) - applied_at_completed_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) - applied_at: Mapped[Optional[datetime.datetime]] = mapped_column(DateTime, nullable=True) - actual_score_improvement: Mapped[Optional[float]] = mapped_column(Float, nullable=True) - outcome_measured_at: Mapped[Optional[datetime.datetime]] = mapped_column(DateTime, nullable=True) - outcome_status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending") - quality_flagged: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - created_at: Mapped[datetime.datetime] = mapped_column( - DateTime, default=datetime.datetime.utcnow - ) - def get_reasons(self) -> List[Dict[str, Any]]: - if not self.reasons_json: - return [] - try: - return json.loads(self.reasons_json) - except Exception: - return [] - def set_reasons(self, reasons: Optional[List[Dict[str, Any]]]): - self.reasons_json = json.dumps(reasons or []) - - def to_dict(self) -> Dict[str, Any]: - return { - "id": self.id, - "study_name": self.study_name, - "health_rating": self.health_rating, - "summary": self.summary, - "policy_action": self.policy_action, - "model_version": self.model_version, - "prompt_strategy": self.prompt_strategy, - "reasons": self.get_reasons(), - "trials_evaluated": self.trials_evaluated, - "estimated_score_improvement": self.estimated_score_improvement, - "cited_best_trial": self.cited_best_trial, - "confidence": self.confidence or "high", - "baseline_best_score": self.baseline_best_score, - "applied_at_completed_count": self.applied_at_completed_count, - "applied_at": self.applied_at.isoformat() if self.applied_at else None, - "actual_score_improvement": self.actual_score_improvement, - "outcome_measured_at": self.outcome_measured_at.isoformat() if self.outcome_measured_at else None, - "outcome_status": self.outcome_status, - "quality_flagged": self.quality_flagged, - "created_at": self.created_at.isoformat() if self.created_at else None, - } class StudyStatus(Base): __tablename__ = "study_status" @@ -234,7 +152,7 @@ class StudyStatus(Base): health_tier: Mapped[str] = mapped_column(String(50), default="healthy") health_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True) health_updated_at: Mapped[datetime.datetime] = mapped_column( - DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow + DateTime, default=lambda: datetime.datetime.now(timezone.utc).replace(tzinfo=None), onupdate=lambda: datetime.datetime.now(timezone.utc).replace(tzinfo=None) ) nudge_dismissed_trials: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) @@ -247,38 +165,7 @@ def to_dict(self) -> Dict[str, Any]: "nudge_dismissed_trials": self.nudge_dismissed_trials, } -class InvalidProposal(Base): - __tablename__ = "invalid_proposals" - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - study_name: Mapped[str] = mapped_column(String(200), nullable=False) - model_version: Mapped[str] = mapped_column(String(100), nullable=False) - prompt_strategy: Mapped[str] = mapped_column(String(100), nullable=False) - invalid_parameters: Mapped[str] = mapped_column(Text, nullable=False) # JSON string of parameters proposed - validation_error: Mapped[str] = mapped_column(Text, nullable=False) # Reason/exception string - created_at: Mapped[datetime.datetime] = mapped_column( - DateTime, default=datetime.datetime.utcnow - ) - - def get_parameters(self) -> Dict[str, Any]: - try: - return json.loads(self.invalid_parameters) - except Exception: - return {} - - def set_parameters(self, params: Dict[str, Any]): - self.invalid_parameters = json.dumps(params) - - def to_dict(self) -> Dict[str, Any]: - return { - "id": self.id, - "study_name": self.study_name, - "model_version": self.model_version, - "prompt_strategy": self.prompt_strategy, - "invalid_parameters": self.get_parameters(), - "validation_error": self.validation_error, - "created_at": self.created_at.isoformat() if self.created_at else None, - } class TrialLease(Base): __tablename__ = "trial_leases" @@ -296,42 +183,6 @@ def to_dict(self) -> Dict[str, Any]: "lease_expires_at": self.lease_expires_at.isoformat() if self.lease_expires_at else None } -class CoordinatorMetric(Base): - __tablename__ = "coordinator_metrics" - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - study_name: Mapped[str] = mapped_column(String(200), nullable=False) - timestamp: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow) - model: Mapped[str] = mapped_column(String(100), nullable=False) - latency_ms: Mapped[float] = mapped_column(Float, nullable=False) - action_taken: Mapped[str] = mapped_column(String(100), nullable=False) - trials_at_review: Mapped[int] = mapped_column(Integer, nullable=False) - def to_dict(self) -> Dict[str, Any]: - return { - "id": self.id, - "study_name": self.study_name, - "timestamp": self.timestamp.isoformat() if self.timestamp else None, - "model": self.model, - "latency_ms": self.latency_ms, - "action_taken": self.action_taken, - "trials_at_review": self.trials_at_review - } - -class SuggestMetric(Base): - __tablename__ = "suggest_metrics" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - study_name: Mapped[str] = mapped_column(String(200), nullable=False) - timestamp: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow) - latency_ms: Mapped[float] = mapped_column(Float, nullable=False) - source: Mapped[str] = mapped_column(String(50), nullable=False) # new_trial, reclaimed_lease, recycled_running - def to_dict(self) -> Dict[str, Any]: - return { - "id": self.id, - "study_name": self.study_name, - "timestamp": self.timestamp.isoformat() if self.timestamp else None, - "latency_ms": self.latency_ms, - "source": self.source - } diff --git a/src/validators.py b/src/validators.py deleted file mode 100644 index c138eae..0000000 --- a/src/validators.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Study-specific parameter validators. - -Generic studies use search-space-only bounds validation (in hpo_coordinator.py). -Studies with domain-specific constraints register validators here. -""" -from typing import Any, Callable, Dict, Optional -from pydantic import BaseModel, Field, field_validator - - -# --- Registry --- - -_MANUAL_VALIDATORS: Dict[str, Callable] = {} - - -def register_manual_validator(study_name: str, validator: Callable) -> None: - """Register a study-specific validator for manual trial parameters.""" - _MANUAL_VALIDATORS[study_name] = validator - - -def get_manual_validator(study_name: str) -> Optional[Callable]: - """Return a registered validator for ``study_name``, or None.""" - return _MANUAL_VALIDATORS.get(study_name) - - -# --- U-Net bridge-crack validator --- - -class UNetHyperparameters(BaseModel): - learning_rate: float = Field(..., ge=1e-6, le=1e-1) - batch_size: int = Field(..., ge=2, le=128) - resolution: int = Field(..., ge=128, le=2048) - model_capacity: str = Field(..., pattern="^(narrow|wide)$") - loss_weight_ratio: float = Field(..., ge=0.0, le=1.0) - - @field_validator("resolution") - @classmethod - def validate_resolution(cls, v: int) -> int: - if v % 32 != 0: - raise ValueError("Resolution must be a multiple of 32 for U-Net downsampling compatibility.") - return v - - @field_validator("batch_size") - @classmethod - def validate_batch_size(cls, v: int) -> int: - if v not in [2, 4, 8, 16, 32, 64, 128]: - raise ValueError("Batch size must be a power of 2 (e.g. 2, 4, 8, 16, 32, 64, 128).") - return v - - -LEGACY_UNET_PARAMS = { - "learning_rate", - "batch_size", - "resolution", - "model_capacity", - "loss_weight_ratio", -} - - -def validate_unet_params(params: Dict[str, Any]) -> Dict[str, Any]: - """Validate a parameter dict against UNetHyperparameters constraints.""" - valid = UNetHyperparameters(**params) - return {"ok": True, "params": valid.model_dump(), "error": None, "warnings": []} From 3ad058ec49c266090c86978e47b7448c006e302d Mon Sep 17 00:00:00 2001 From: Ishaan Date: Tue, 30 Jun 2026 10:35:10 -0400 Subject: [PATCH 3/8] condense api layer, add cli commands, reduce responsibility on mcp layer --- broker.py | 1 - hpo_cli.py | 579 ++++++++++----------------------------- hpo_mcp_server.py | 461 +------------------------------ src/hpo_client.py | 21 +- src/routers/dashboard.py | 459 +++++++------------------------ src/routers/static.py | 7 - src/routers/worker.py | 4 +- src/settings.py | 2 +- 8 files changed, 272 insertions(+), 1262 deletions(-) diff --git a/broker.py b/broker.py index 22208de..95a99a3 100644 --- a/broker.py +++ b/broker.py @@ -1,7 +1,6 @@ import hmac import os from contextlib import asynccontextmanager -from typing import List from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware diff --git a/hpo_cli.py b/hpo_cli.py index 08017b1..550b489 100644 --- a/hpo_cli.py +++ b/hpo_cli.py @@ -1,17 +1,17 @@ #!/usr/bin/env python3 """Decoupled Control CLI for Pathfinder. -Provides standalone commands to check status, run reviews, and manage pending search space patches. +Provides standalone commands to check status, manage pending search space patches, +export/import studies, generate model cards, and delete studies. """ import os import sys import json import argparse -import requests import csv import sqlite3 import datetime -from typing import Dict, Any, Optional +from datetime import timezone import optuna # Make sure we can import from workspace root and src @@ -21,298 +21,50 @@ from src.schema import ( StudyStatus, SystemConfiguration, - StudyReview, TrialResult, TrialMetadata, CompactedPacket, StudyCard, - AgentReasoningLog, - InvalidProposal, TrialLease, - CoordinatorMetric, - SuggestMetric, -) -from src.hpo_coordinator import ( - compute_review_heuristics, - build_review_prompt, - save_study_review, - count_evaluated_trials, - validate_review_fields, - mark_review_applied, - flag_study_review, ) +from src.health import compute_health_tier, compute_statistical_confidence, count_evaluated_trials +from src.analytics import study_eval_insights, build_study_packet, load_study_cards from src.hpo_config import load_hpo_config -from src.suggest import get_or_create_study, load_study, _enqueue_manual_trial -from src.search_space import load_search_space, _apply_search_space_patch +from src.suggest import load_study +from src.search_space import load_search_space -DEFAULT_STUDY = "bridge_crack_study" +DEFAULT_STUDY = None def get_study_name(args) -> str: """Resolve study name from args, env, or default.""" - return args.study or os.getenv("HPO_STUDY_NAME") or DEFAULT_STUDY - -def call_llm(prompt: str) -> str: - """Call local LLM APIs directly using requests to avoid heavy client dependencies.""" - gemini_key = os.getenv("GEMINI_API_KEY") - anthropic_key = os.getenv("ANTHROPIC_API_KEY") - openai_key = os.getenv("OPENAI_API_KEY") - - if gemini_key: - url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent?key={gemini_key}" - headers = {"Content-Type": "application/json"} - payload = { - "contents": [{"parts": [{"text": prompt}]}], - "generationConfig": { - "responseMimeType": "application/json" - } - } - res = requests.post(url, json=payload, headers=headers, timeout=120) - res.raise_for_status() - data = res.json() - return data["candidates"][0]["content"]["parts"][0]["text"] - - elif anthropic_key: - url = "https://api.anthropic.com/v1/messages" - headers = { - "x-api-key": anthropic_key, - "anthropic-version": "2023-06-01", - "content-type": "application/json" - } - payload = { - "model": "claude-3-5-sonnet-20241022", - "max_tokens": 4096, - "messages": [{"role": "user", "content": prompt}], - "system": "You are a professional ML experiment optimization coordinator. You MUST return JSON only." - } - res = requests.post(url, json=payload, headers=headers, timeout=120) - res.raise_for_status() - data = res.json() - return data["content"][0]["text"] - - elif openai_key: - url = "https://api.openai.com/v1/chat/completions" - headers = { - "Authorization": f"Bearer {openai_key}", - "Content-Type": "application/json" - } - payload = { - "model": "gpt-4o", - "response_format": {"type": "json_object"}, - "messages": [ - {"role": "system", "content": "You are a professional ML experiment optimization coordinator. You MUST return JSON only."}, - {"role": "user", "content": prompt} - ] - } - res = requests.post(url, json=payload, headers=headers, timeout=120) - res.raise_for_status() - data = res.json() - return data["choices"][0]["message"]["content"] - - else: - raise ValueError("No API keys found for GEMINI_API_KEY, ANTHROPIC_API_KEY, or OPENAI_API_KEY.") + name = args.study or os.getenv("HPO_STUDY_NAME") + if not name: + print("No study specified. Set HPO_STUDY_NAME or pass --study.") + sys.exit(1) + return name def cmd_status(args): init_db() study_name = get_study_name(args) try: - study = get_or_create_study(study_name) + study = load_study(study_name) except Exception as e: print(f"Error loading study '{study_name}': {e}") sys.exit(1) - from src.hpo_coordinator import study_eval_insights hpo_config = load_hpo_config(study_name) + health_tier, health_reason = compute_health_tier(study, study_name) insights = study_eval_insights(study, hpo_config) - heuristics = compute_review_heuristics(study, insights, hpo_config, study_name) - print(f"\n==================================================") + print("\n==================================================") print(f"πŸ“Š STUDY STATUS: {study_name}") - print(f"==================================================") + print("==================================================") print(f"Total Trials: {len(study.trials)}") - print(f"Evaluated: {heuristics['trials_evaluated']}") - print(f"Health Tier: {heuristics['health_tier'].upper()}") - print(f"Health Reason: {heuristics['health_reason']}") - print(f"Review Recommended: {heuristics['review_recommended']}") - print(f"Already Dismissed: {heuristics.get('already_dismissed', False)}") - - # Check pending changes - with get_db_session() as session: - pending_row = session.query(SystemConfiguration).filter_by( - study_name=study_name, config_key="pending_search_space" - ).first() - if pending_row: - print(f"Pending Changes: YES (use 'python hpo_cli.py apply' to commit)") - else: - print(f"Pending Changes: NO") - print(f"==================================================\n") - -def cmd_review(args): - init_db() - study_name = get_study_name(args) - study = get_or_create_study(study_name) - - hpo_config = load_hpo_config(study_name) - from src.hpo_coordinator import study_eval_insights - insights = study_eval_insights(study, hpo_config) - heuristics = compute_review_heuristics(study, insights, hpo_config, study_name) - - # Check if review already completed - n_eval = heuristics["trials_evaluated"] - if not args.force and heuristics["already_reviewed"]: - print(f"Info: Study has already been reviewed for trial count {n_eval}. Use --force to override.") - return - - # Check if API keys are set - has_keys = any(os.getenv(k) for k in ("GEMINI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY")) - - if not has_keys: - # Just print the prompt - print(f"No LLM API keys found (GEMINI_API_KEY, ANTHROPIC_API_KEY, or OPENAI_API_KEY).") - print(f"Printing coordinator review prompt below for manual copy-paste:\n") - print("----------------------------------------------------------------------") - print(build_review_prompt(study_name)) - print("----------------------------------------------------------------------") - return - - print("Running background LLM coordinator review...") - prompt = build_review_prompt(study_name) - - try: - response_text = call_llm(prompt) - # Parse JSON output from LLM - review_data = json.loads(response_text) - except Exception as e: - print(f"Failed to generate or parse LLM review: {e}") - sys.exit(1) + print(f"Evaluated: {count_evaluated_trials(study)}") + print(f"Health Tier: {health_tier.upper()}") + print(f"Health Reason: {health_reason}") + print("==================================================\n") - summary = review_data.get("summary", "LLM Generated review") - health_rating = review_data.get("health_rating", 3) - policy_action = review_data.get("policy_action", "no_change") - reasons = review_data.get("reasons", []) - est_imp = review_data.get("estimated_score_improvement") or review_data.get("estimated_dice_improvement") - cited_best = review_data.get("cited_best_trial") - patch = review_data.get("search_space_patch") - manual_trial = review_data.get("manual_trial") - - validation = validate_review_fields(est_imp, cited_best) - if not validation["ok"]: - print(f"Review JSON contract error: {'; '.join(validation['errors'])}") - sys.exit(1) - - print(f"\n==================================================") - print(f"πŸ€– LLM COORDINATOR REVIEW COMPLETED") - print(f"==================================================") - print(f"Health Rating: {health_rating}/5") - print(f"Action: {policy_action.upper()}") - print(f"Summary: {summary}") - if patch: - print(f"Space Patch: {json.dumps(patch)}") - if manual_trial: - print(f"Manual Trial: {json.dumps(manual_trial)}") - print(f"==================================================") - - # Persist the review - try: - result = save_study_review( - study_name, - summary, - health_rating=health_rating, - policy_action=policy_action, - model_version="cli_coordinator", - reasons=reasons, - trials_evaluated=n_eval, - estimated_score_improvement=est_imp, - cited_best_trial=cited_best, - force=args.force - ) - - applied = {} - space = load_search_space(study_name) - - # Save bounds proposal to pending config or apply it - if patch: - with get_db_session() as session: - session.merge(SystemConfiguration( - study_name=study_name, - config_key="pending_search_space", - config_value=json.dumps(patch) - )) - session.commit() - print("Proposed search space patch staged in 'pending_search_space'. Approve on dashboard or run 'python hpo_cli.py apply'.") - - if manual_trial: - applied["manual_trial"] = _enqueue_manual_trial(study, manual_trial, space, summary) - print(f"Enqueued manual trial: {manual_trial}") - - print("Review successfully saved in SQLite.") - except Exception as e: - print(f"Error persisting review: {e}") - sys.exit(1) - -def cmd_apply(args): - init_db() - study_name = get_study_name(args) - - with get_db_session() as session: - pending_row = session.query(SystemConfiguration).filter_by( - study_name=study_name, config_key="pending_search_space" - ).first() - if not pending_row: - print("No pending search space changes found.") - return - - proposed = json.loads(pending_row.config_value) - space = load_search_space(study_name) - - # Merge changes into active space - for key, new_val in proposed.items(): - if key in space: - p_type = space[key].get("type") - if p_type == "categorical": - if "active" in new_val: - space[key]["active"] = new_val["active"] - else: - if "min" in new_val: - space[key]["min"] = float(new_val["min"]) - if "max" in new_val: - space[key]["max"] = float(new_val["max"]) - - session.merge(SystemConfiguration( - study_name=study_name, - config_key="active_search_space", - config_value=json.dumps(space) - )) - session.delete(pending_row) - session.commit() - - mark_review_applied(study_name) - print("Pending search space changes committed successfully.") - - -def cmd_flag_review(args): - init_db() - result = flag_study_review(args.id, flagged=not args.unflag) - if not result.get("success"): - print(f"Error: {result.get('error')}") - sys.exit(1) - state = "flagged" if not args.unflag else "unflagged" - print(f"Review #{args.id} {state}.") - -def cmd_discard(args): - init_db() - study_name = get_study_name(args) - - with get_db_session() as session: - pending_row = session.query(SystemConfiguration).filter_by( - study_name=study_name, config_key="pending_search_space" - ).first() - if not pending_row: - print("No pending search space changes found.") - return - session.delete(pending_row) - session.commit() - - print("Pending search space changes discarded.") def cmd_validate(args): import yaml @@ -408,11 +160,12 @@ def cmd_quickstart(args): f.write(yaml_content) metric_name = "loss" if direction == "minimize" else "score" + broker_url = os.getenv("HPO_BROKER_URL", "http://localhost:8000") worker_content = f"""import sys from src.hpo_client import TrialSession def main(): - session = TrialSession(broker_url="http://localhost:8000", study_name="{study_name}") + session = TrialSession(broker_url="{broker_url}", study_name="{study_name}") trial = session.suggest() {param_name} = trial["params"]["{param_name}"] @@ -448,11 +201,11 @@ def main(): result = init_study_from_manifest_dict(data, force=False) print(result) - print(f"\n==================================================") + print("\n==================================================") print("πŸš€ SUCCESS! Your dummy study is registered.") print("Run the following command in another terminal:") - print(f"\n python quickstart_worker.py") - print(f"==================================================\n") + print("\n python quickstart_worker.py") + print("==================================================\n") sys.exit(0) def cmd_init(args): @@ -524,30 +277,6 @@ def cmd_export(args): study_name = get_study_name(args) fmt = args.format.lower() - if fmt == "sqlite": - if not args.output: - print("βœ— Error: --output file path is required for sqlite format export.") - sys.exit(1) - print("Note: SQLite export copies the entire database file, including all studies.") - db_path = DATABASE_URL.replace("sqlite:///", "") if DATABASE_URL.startswith("sqlite:///") else "hpo_studies.db" - if not os.path.exists(db_path): - print(f"βœ— Error: Source database file '{db_path}' does not exist.") - sys.exit(1) - - os.makedirs(os.path.dirname(os.path.abspath(args.output)) or ".", exist_ok=True) - try: - src_conn = sqlite3.connect(db_path) - dst_conn = sqlite3.connect(args.output) - with dst_conn: - src_conn.backup(dst_conn) - dst_conn.close() - src_conn.close() - print(f"βœ“ Successfully exported database to '{args.output}' via SQLite online backup.") - sys.exit(0) - except Exception as e: - print(f"βœ— Error exporting database: {e}") - sys.exit(1) - try: study = load_study(study_name) except Exception as e: @@ -618,12 +347,7 @@ def cmd_export(args): "system_configuration": [], "compacted_packets": [], "study_cards": [], - "agent_reasoning_logs": [], - "study_reviews": [], "study_status": [], - "invalid_proposals": [], - "coordinator_metrics": [], - "suggest_metrics": [] } from optuna.distributions import distribution_to_json @@ -670,24 +394,9 @@ def cmd_export(args): cards = session.query(StudyCard).filter_by(study_name=study_name).all() export_data["study_cards"] = [c.to_dict() for c in cards] - reasoning = session.query(AgentReasoningLog).filter_by(study_name=study_name).all() - export_data["agent_reasoning_logs"] = [ar.to_dict() for ar in reasoning] - - reviews = session.query(StudyReview).filter_by(study_name=study_name).all() - export_data["study_reviews"] = [sr.to_dict() for sr in reviews] - status = session.query(StudyStatus).filter_by(study_name=study_name).all() export_data["study_status"] = [s.to_dict() for s in status] - proposals = session.query(InvalidProposal).filter_by(study_name=study_name).all() - export_data["invalid_proposals"] = [ip.to_dict() for ip in proposals] - - c_metrics = session.query(CoordinatorMetric).filter_by(study_name=study_name).all() - export_data["coordinator_metrics"] = [cm.to_dict() for cm in c_metrics] - - s_metrics = session.query(SuggestMetric).filter_by(study_name=study_name).all() - export_data["suggest_metrics"] = [sm.to_dict() for sm in s_metrics] - if args.output: os.makedirs(os.path.dirname(os.path.abspath(args.output)) or ".", exist_ok=True) with open(args.output, "w") as f: @@ -750,7 +459,7 @@ def cmd_import(args): print(f"Deleting existing study '{new_study_name}' as --force was specified...") optuna.delete_study(study_name=new_study_name, storage=DATABASE_URL) with get_db_session() as session: - for model in [TrialResult, TrialMetadata, SystemConfiguration, CompactedPacket, StudyCard, AgentReasoningLog, StudyReview, StudyStatus, InvalidProposal, TrialLease, CoordinatorMetric, SuggestMetric]: + for model in [TrialResult, TrialMetadata, SystemConfiguration, CompactedPacket, StudyCard, StudyStatus, TrialLease]: session.query(model).filter_by(study_name=new_study_name).delete() except KeyError: pass @@ -768,7 +477,7 @@ def cmd_import(args): sys.exit(1) try: - print(f"Importing Optuna trials...") + print("Importing Optuna trials...") trial_id_mapping = {} from optuna.trial import FrozenTrial, TrialState from optuna.distributions import json_to_distribution @@ -786,7 +495,7 @@ def cmd_import(args): if t_state_name == "RUNNING": t_state_name = "FAIL" if not dt_complete: - dt_complete = datetime.datetime.utcnow() + dt_complete = datetime.datetime.now(timezone.utc).replace(tzinfo=None) frozen_trial = FrozenTrial( number=t["number"], @@ -807,7 +516,7 @@ def cmd_import(args): new_trial = study.trials[-1] trial_id_mapping[t["trial_id"]] = new_trial._trial_id - print(f"Importing custom Pathfinder tables...") + print("Importing custom Pathfinder tables...") with get_db_session() as session: # Cache invalidation: delete old compacted packets session.query(CompactedPacket).filter_by(study_name=new_study_name).delete() @@ -826,7 +535,7 @@ def cmd_import(args): if new_trial_id is None: continue - created_at = datetime.datetime.fromisoformat(r["created_at"]) if r.get("created_at") else datetime.datetime.utcnow() + created_at = datetime.datetime.fromisoformat(r["created_at"]) if r.get("created_at") else datetime.datetime.now(timezone.utc).replace(tzinfo=None) session.add(TrialResult( trial_id=new_trial_id, study_name=new_study_name, @@ -852,7 +561,7 @@ def cmd_import(args): new_trial_id = trial_id_mapping.get(orig_trial_id) if new_trial_id is None: continue - created_at = datetime.datetime.fromisoformat(m["created_at"]) if m.get("created_at") else datetime.datetime.utcnow() + created_at = datetime.datetime.fromisoformat(m["created_at"]) if m.get("created_at") else datetime.datetime.now(timezone.utc).replace(tzinfo=None) session.add(TrialMetadata( trial_id=new_trial_id, study_name=new_study_name, @@ -861,27 +570,8 @@ def cmd_import(args): created_at=created_at )) - for ar in export_data.get("agent_reasoning_logs", []): - orig_trial_id = ar["trial_id"] - new_trial_id = trial_id_mapping.get(orig_trial_id) - if new_trial_id is None: - continue - created_at = datetime.datetime.fromisoformat(ar["created_at"]) if ar.get("created_at") else datetime.datetime.utcnow() - session.add(AgentReasoningLog( - trial_id=new_trial_id, - study_name=new_study_name, - model_version=ar["model_version"], - prompt_strategy=ar["prompt_strategy"], - predicted_outcome_rationale=ar["predicted_outcome_rationale"], - estimated_score_improvement=ar["estimated_score_improvement"], - actual_score_improvement=ar.get("actual_score_improvement"), - created_at=created_at - )) - - # CompactedPackets: Omitted/cache invalidation (skip importing) - for c in export_data.get("study_cards", []): - created_at = datetime.datetime.fromisoformat(c["created_at"]) if c.get("created_at") else datetime.datetime.utcnow() + created_at = datetime.datetime.fromisoformat(c["created_at"]) if c.get("created_at") else datetime.datetime.now(timezone.utc).replace(tzinfo=None) session.add(StudyCard( study_name=new_study_name, card_type=c["card_type"], @@ -891,36 +581,8 @@ def cmd_import(args): created_at=created_at )) - for sr in export_data.get("study_reviews", []): - created_at = datetime.datetime.fromisoformat(sr["created_at"]) if sr.get("created_at") else datetime.datetime.utcnow() - applied_at = datetime.datetime.fromisoformat(sr["applied_at"]) if sr.get("applied_at") else None - outcome_measured_at = datetime.datetime.fromisoformat(sr["outcome_measured_at"]) if sr.get("outcome_measured_at") else None - - review = StudyReview( - study_name=new_study_name, - health_rating=sr.get("health_rating"), - summary=sr["summary"], - policy_action=sr.get("policy_action", "no_change"), - model_version=sr.get("model_version", "unspecified"), - prompt_strategy=sr.get("prompt_strategy", "coordinator_review"), - trials_evaluated=sr.get("trials_evaluated", 0), - estimated_score_improvement=sr.get("estimated_score_improvement"), - cited_best_trial=sr.get("cited_best_trial"), - confidence=sr.get("confidence", "high"), - baseline_best_score=sr.get("baseline_best_score"), - applied_at_completed_count=sr.get("applied_at_completed_count"), - applied_at=applied_at, - actual_score_improvement=sr.get("actual_score_improvement"), - outcome_measured_at=outcome_measured_at, - outcome_status=sr.get("outcome_status", "pending"), - quality_flagged=sr.get("quality_flagged", False), - created_at=created_at - ) - review.set_reasons(sr.get("reasons", [])) - session.add(review) - for s in export_data.get("study_status", []): - health_updated_at = datetime.datetime.fromisoformat(s["health_updated_at"]) if s.get("health_updated_at") else datetime.datetime.utcnow() + health_updated_at = datetime.datetime.fromisoformat(s["health_updated_at"]) if s.get("health_updated_at") else datetime.datetime.now(timezone.utc).replace(tzinfo=None) session.add(StudyStatus( study_name=new_study_name, health_tier=s.get("health_tier", "healthy"), @@ -928,37 +590,6 @@ def cmd_import(args): health_updated_at=health_updated_at, nudge_dismissed_trials=s.get("nudge_dismissed_trials") )) - - for ip in export_data.get("invalid_proposals", []): - created_at = datetime.datetime.fromisoformat(ip["created_at"]) if ip.get("created_at") else datetime.datetime.utcnow() - session.add(InvalidProposal( - study_name=new_study_name, - model_version=ip["model_version"], - prompt_strategy=ip["prompt_strategy"], - invalid_parameters=json.dumps(ip.get("invalid_parameters", {})), - validation_error=ip["validation_error"], - created_at=created_at - )) - - for cm in export_data.get("coordinator_metrics", []): - timestamp = datetime.datetime.fromisoformat(cm["timestamp"]) if cm.get("timestamp") else datetime.datetime.utcnow() - session.add(CoordinatorMetric( - study_name=new_study_name, - timestamp=timestamp, - model=cm["model"], - latency_ms=cm["latency_ms"], - action_taken=cm["action_taken"], - trials_at_review=cm["trials_at_review"] - )) - - for sm in export_data.get("suggest_metrics", []): - timestamp = datetime.datetime.fromisoformat(sm["timestamp"]) if sm.get("timestamp") else datetime.datetime.utcnow() - session.add(SuggestMetric( - study_name=new_study_name, - timestamp=timestamp, - latency_ms=sm["latency_ms"], - source=sm["source"] - )) except Exception as err: print(f"βœ— Error during import execution: {err}") print("Rolling back database transaction and deleting half-imported study...") @@ -999,6 +630,103 @@ def cmd_backup(args): print(f"βœ— Backup failed: {e}") sys.exit(1) +def cmd_modelcard(args): + import hashlib + import os as _os + + study_name = args.study_name + init_db() + + packet = build_study_packet(study_name) + + if not packet or packet.get("success") is False: + print(f"Error: Could not build study packet for '{study_name}'") + sys.exit(1) + + lines = [] + lines.append(f"# Model Card: {study_name}") + lines.append("") + lines.append(f"**Generated:** {datetime.datetime.now(timezone.utc).replace(tzinfo=None).isoformat()}") + lines.append("") + + lines.append("## Executive Summary") + counts = packet.get("counts", {}) + lines.append(f"- Total Trials: {counts.get('total', 0)}") + lines.append(f"- Completed: {counts.get('complete', 0)}") + lines.append(f"- Pruned: {counts.get('pruned', 0)}") + lines.append(f"- Failed: {counts.get('failed', 0)}") + lines.append(f"- Statistical Confidence: {packet.get('statistical_confidence', 'unknown')}") + lines.append("") + + lines.append("## Best Parameters") + trial_bins = packet.get("trial_bins", {}) + elite = trial_bins.get("elite", []) + if elite: + best = elite[0] + lines.append(f"- Best Trial: #{best.get('trial_id')}") + lines.append(f"- Best Score: {best.get('primary_score')}") + lines.append(f"- Best Loss: {best.get('primary_loss')}") + lines.append("") + lines.append("### Best Trial Parameters") + for k, v in best.get("params", {}).items(): + lines.append(f"- `{k}`: {v}") + lines.append("") + + fanova = packet.get("fanova_importances", {}) + if fanova: + lines.append("## fANOVA Importances") + for param, importance in sorted(fanova.items(), key=lambda x: x[1], reverse=True): + lines.append(f"- `{param}`: {importance:.4f}") + lines.append("") + + vram = packet.get("vram_telemetry", {}) + if vram: + lines.append("## VRAM Telemetry") + lines.append(f"- GPU Model: {vram.get('gpu_model', 'Unknown')}") + lines.append(f"- GPU Capacity: {vram.get('gpu_capacity_gb', 'N/A')} GB") + lines.append(f"- OOM Count: {vram.get('oom_count', 0)}") + oom_risk = vram.get("bounds_oom_risk") + if oom_risk: + lines.append(f"- OOM Risk Level: {oom_risk.get('risk_level', 'N/A')}") + lines.append(f"- Predicted Max VRAM: {oom_risk.get('predicted_max_vram_gb', 'N/A'):.2f} GB") + lines.append("") + + health = packet.get("health", {}) + lines.append("## Health") + lines.append(f"- Tier: {health.get('tier', 'unknown')}") + lines.append(f"- Reason: {health.get('reason', 'N/A')}") + lines.append("") + + content = "\n".join(lines) + + _os.makedirs("studies", exist_ok=True) + file_path = f"studies/{study_name}_model_card.md" + with open(file_path, "w") as f: + f.write(content) + + content_hash = hashlib.sha256(content.encode()).hexdigest() + with get_db_session() as session: + session.add(StudyCard( + study_name=study_name, + card_type="model_card", + file_path=file_path, + content_hash=content_hash, + metadata_json=json.dumps({"generated_at": datetime.datetime.now(timezone.utc).replace(tzinfo=None).isoformat()}) + )) + session.commit() + + print(f"Model card saved to {file_path} (hash: {content_hash[:12]}...)") + +def cmd_delete(args): + study_name = args.study_name + if not args.confirm: + print("Use --confirm to permanently delete the study") + sys.exit(0) + + from src.onboarding import delete_study_internal + result = delete_study_internal(study_name=study_name, confirm=True) + print(result["message"]) + def main(): parser = argparse.ArgumentParser(description="Pathfinder CLI Control") subparsers = parser.add_subparsers(dest="command", required=True) @@ -1007,30 +735,12 @@ def main(): p_status = subparsers.add_parser("status", help="Get study health, trial counts, and pending status") p_status.add_argument("--study", help="Study name") - # Review - p_review = subparsers.add_parser("review", help="Execute coordinator review or output review prompt") - p_review.add_argument("--study", help="Study name") - p_review.add_argument("--force", action="store_true", help="Force review generation even if already completed for current trials") - - # Apply - p_apply = subparsers.add_parser("apply", help="Commit pending search bounds configuration") - p_apply.add_argument("--study", help="Study name") - - # Discard - p_discard = subparsers.add_parser("discard", help="Discard pending search bounds configuration") - p_discard.add_argument("--study", help="Study name") - - # Flag review quality - p_flag = subparsers.add_parser("flag-review", help="Flag a coordinator review as low-quality (excluded from MAE)") - p_flag.add_argument("--id", type=int, required=True, help="StudyReview row id") - p_flag.add_argument("--unflag", action="store_true", help="Remove quality flag") - # Validate p_validate = subparsers.add_parser("validate", help="Check manifest for errors") p_validate.add_argument("manifest", help="Path to manifest YAML file") # Quickstart - p_quickstart = subparsers.add_parser("quickstart", help="Interactive wizard to generate and initialize a dummy study") + subparsers.add_parser("quickstart", help="Interactive wizard to generate and initialize a dummy study") # Init p_init = subparsers.add_parser("init", help="Validate + register study") @@ -1044,8 +754,8 @@ def main(): # Export p_export = subparsers.add_parser("export", help="Export HPO study trials and config") p_export.add_argument("--study", help="Study name") - p_export.add_argument("--format", choices=["json", "csv", "sqlite"], default="json", help="Export format (default: json)") - p_export.add_argument("--output", help="File path to save the export (required for csv and sqlite)") + p_export.add_argument("--format", choices=["json", "csv"], default="json", help="Export format (default: json)") + p_export.add_argument("--output", help="File path to save the export (required for csv)") # Import p_import = subparsers.add_parser("import", help="Import HPO study trials and config from JSON file") @@ -1057,18 +767,19 @@ def main(): p_backup = subparsers.add_parser("backup", help="Create a safe online backup of the SQLite database") p_backup.add_argument("--output", help="Custom backup file path") + # Modelcard + p_modelcard = subparsers.add_parser("modelcard", help="Generate a model card for a study") + p_modelcard.add_argument("study_name", help="Study name to generate model card for") + + # Delete + p_delete = subparsers.add_parser("delete", help="Permanently delete a study and all its data") + p_delete.add_argument("study_name", help="Study name to delete") + p_delete.add_argument("--confirm", action="store_true", help="Confirm permanent deletion") + args = parser.parse_args() if args.command == "status": cmd_status(args) - elif args.command == "review": - cmd_review(args) - elif args.command == "apply": - cmd_apply(args) - elif args.command == "discard": - cmd_discard(args) - elif args.command == "flag-review": - cmd_flag_review(args) elif args.command == "validate": cmd_validate(args) elif args.command == "quickstart": @@ -1083,6 +794,10 @@ def main(): cmd_import(args) elif args.command == "backup": cmd_backup(args) + elif args.command == "modelcard": + cmd_modelcard(args) + elif args.command == "delete": + cmd_delete(args) if __name__ == "__main__": main() diff --git a/hpo_mcp_server.py b/hpo_mcp_server.py index 9c560d7..ee9ffb3 100644 --- a/hpo_mcp_server.py +++ b/hpo_mcp_server.py @@ -3,479 +3,57 @@ import datetime import hashlib from typing import Optional, Dict, Any, List -import requests -import optuna -from optuna.trial import TrialState from mcp.server.fastmcp import FastMCP mcp = FastMCP("Pathfinder") from src.db_manager import init_db, get_db_session, DATABASE_URL -from src.schema import ( - TrialResult, - TrialMetadata, - SystemConfiguration, - CompactedPacket, - StudyCard, - AgentReasoningLog, - StudyReview, - StudyStatus -) -from src.hpo_config import load_hpo_config, normalize_trial_params -from src.analytics import build_compacted_packet -from src.hpo_coordinator import ( - compute_health_tier, - count_evaluated_trials, - POLICY_ACTIONS, - build_review_packet, - load_study_cards -) - -from src.search_space import ( - load_search_space, - save_search_space, - _apply_search_space_patch, -) -from src.suggest import get_or_create_study, _enqueue_manual_trial - -from src.hpo_coordinator import _validate_manual_parameters # --- MCP TOOLS --- -from src.onboarding import ( - initialize_study as core_initialize_study, - delete_study_internal, - init_study_from_manifest_dict -) - -@mcp.tool() -def initialize_study( - study_name: str, - active_search_space: Dict[str, Any], - hpo_config: Dict[str, Any], - project_context: Optional[Dict[str, Any]] = None, - source_files: Optional[Dict[str, str]] = None, - multi_objective: bool = True, - directions: Optional[List[str]] = None -) -> str: - """Initializes a new study: creates Optuna study and stores search space, config, context, and source files in DB.""" - return core_initialize_study( - study_name=study_name, - active_search_space=active_search_space, - hpo_config=hpo_config, - project_context=project_context, - source_files=source_files, multi_objective=multi_objective, - directions=directions - ) - @mcp.tool() def get_study_data(study_name: str) -> Dict[str, Any]: """Returns the compacted HPO review packet, utilizing a lazy materialization cache layer.""" - return build_review_packet(study_name) - -@mcp.tool() -def validate_search_space( - space_config: Dict[str, Any], - hpo_config: Optional[Dict[str, Any]] = None, - project_context: Optional[Dict[str, Any]] = None, - historical_fail_patterns: Optional[List[str]] = None, -) -> Dict[str, Any]: - """Validate search space bounds, tunable coverage, and metric label consistency.""" - errors = [] - warnings = [] - tunable_count = 0 - - for param_name, spec in space_config.items(): - if param_name.startswith("_") or not isinstance(spec, dict): - continue - ptype = spec.get("type") - if ptype in ("float", "float_log", "int"): - lo = spec.get("min") - hi = spec.get("max") - if lo is None or hi is None: - errors.append(f"Parameter '{param_name}' is missing required min/max bounds.") - else: - try: - lo_f = float(lo) - hi_f = float(hi) - if lo_f >= hi_f: - errors.append(f"Parameter '{param_name}': min ({lo}) must be strictly less than max ({hi}).") - if ptype == "float_log" and lo_f <= 0: - errors.append(f"Parameter '{param_name}' is log-scale and must have a min bound strictly greater than 0.") - if ptype == "float_log" and hi_f > 0 and lo_f > 0: - import math - span_orders = math.log10(hi_f) - math.log10(lo_f) - if span_orders > 6: - warnings.append( - f"Parameter '{param_name}' log-span spans {span_orders:.1f} orders of magnitude (>6); consider narrowing." - ) - if hi_f > lo_f: - tunable_count += 1 - except (ValueError, TypeError): - errors.append(f"Parameter '{param_name}' has non-numeric min/max bounds.") - elif ptype == "categorical": - options = spec.get("options", []) - active = spec.get("active", options) - if not options: - errors.append(f"Categorical parameter '{param_name}' must specify allowed options.") - elif len(active) == 0: - errors.append(f"Categorical parameter '{param_name}' must have at least one active option.") - elif len(active) == 1: - warnings.append(f"Categorical parameter '{param_name}' has only 1 active choice ({active[0]}), effectively pinning it.") - elif len(active) > 1: - tunable_count += 1 - - # Check active in options - invalid_active = [x for x in active if x not in options] - if invalid_active: - errors.append(f"Categorical parameter '{param_name}' active options {invalid_active} are not in choices: {options}") - - # Check known OOM-risk combinations - batch_size_spec = space_config.get("batch_size", {}) - resolution_spec = space_config.get("resolution", {}) - - max_bs = None - if batch_size_spec.get("type") == "categorical": - max_bs = max(batch_size_spec.get("active", [0])) - elif batch_size_spec.get("type") in ("int", "float"): - max_bs = batch_size_spec.get("max") - - max_res = None - if resolution_spec.get("type") == "categorical": - max_res = max(resolution_spec.get("active", [0])) - elif resolution_spec.get("type") in ("int", "float"): - max_res = resolution_spec.get("max") - - if max_bs is not None and max_res is not None: - try: - if float(max_bs) >= 64 and float(max_res) >= 1024: - warnings.append(f"High risk configuration: batch_size={max_bs} combined with resolution={max_res} has historically high OOM risk.") - except (ValueError, TypeError): - pass - - if tunable_count == 0: - errors.append("Search space has no tunable parameters (all bounds pinned or single-choice categoricals).") - - if project_context: - ctx = project_context if isinstance(project_context, dict) else {} - declared_score = ctx.get("metric_score_name") or ctx.get("score_metric") - declared_loss = ctx.get("metric_loss_name") or ctx.get("loss_metric") - if hpo_config and (declared_score or declared_loss): - if declared_score and not hpo_config.get("metric_score_label"): - warnings.append("project_context declares a score metric but hpo_config.metric_score_label is missing.") - if declared_loss and not hpo_config.get("metric_loss_label"): - warnings.append("project_context declares a loss metric but hpo_config.metric_loss_label is missing.") - - return { - "valid": len(errors) == 0, - "errors": errors, - "warnings": warnings - } - -@mcp.tool() -def update_search_space(study_name: str, space_config: Dict[str, Any], apply: bool = False) -> str: - """Propose or apply updates to the active search space. - - Every change is validated against the canonical search space (legacy parameter aliases - already normalized). With apply=False the change is staged as ``pending_search_space`` for - the human to approve on the dashboard; apply=True commits immediately. Unrecognized - parameters, out-of-bounds categorical choices, and no-op proposals return an explicit error - string instead of being silently dropped. - """ - current_space = load_search_space(study_name) - validated_proposals: Dict[str, Any] = {} - - for param_name, new_val in space_config.items(): - if param_name not in current_space: - return f"Error: Hyperparameter '{param_name}' is not recognized in the search space." - param_type = current_space[param_name].get("type") - proposal: Dict[str, Any] = {} - if param_type == "categorical": - if "active" in new_val: - allowed = current_space[param_name].get("options", []) - invalid_options = [x for x in new_val["active"] if x not in allowed] - if invalid_options: - return f"Error: Active choices {invalid_options} for {param_name} are not in options: {allowed}" - if len(new_val["active"]) == 0: - return f"Error: Categorical parameter {param_name} must have at least one active option." - proposal["active"] = new_val["active"] - else: - if "min" in new_val: - proposal["min"] = float(new_val["min"]) - if "max" in new_val: - proposal["max"] = float(new_val["max"]) - if not proposal: - return ( - f"Error: No valid changes for '{param_name}'. Provide 'active' for categorical " - f"parameters, or 'min'/'max' for numeric parameters." - ) - validated_proposals[param_name] = proposal - - if apply: - for key, new_val in validated_proposals.items(): - current_space[key].update(new_val) - save_search_space(current_space, study_name) - with get_db_session() as session: - pending = session.query(SystemConfiguration).filter_by( - study_name=study_name, config_key="pending_search_space" - ).first() - if pending: - session.delete(pending) - from src.hpo_coordinator import mark_review_applied - mark_review_applied(study_name) - return "Search space changes committed successfully." - - with get_db_session() as session: - session.merge(SystemConfiguration( - study_name=study_name, - config_key="pending_search_space", - config_value=json.dumps(validated_proposals), - )) - return "Search space changes proposed successfully. They must be approved via the dashboard before they take effect." - -@mcp.tool() -def delete_study(study_name: str, confirm: bool = False) -> Dict[str, Any]: - """Permanently delete a study: its Optuna trials and ALL custom metadata rows.""" - return delete_study_internal(study_name=study_name, confirm=confirm) + from src.analytics import build_study_packet + return build_study_packet(study_name) -@mcp.tool() -def generate_model_card(study_name: str) -> Dict[str, Any]: - """Generates an end-of-study synthesis, writes MODEL_CARD.md to disk, and indexes it in DB.""" - try: - # Load data - packet = get_study_data(study_name) - if "error" in packet: - return packet - - best_params = {} - best_score = 0.0 - elite = packet.get("trial_bins", {}).get("elite", []) - if elite: - best_params = elite[0].get("params", {}) - best_score = elite[0].get("primary_score", 0.0) - - # Construct beautiful model card Markdown - card_content = f"""# Study Model Card: {study_name} - -## Executive Summary -This model card synthesizes results for study `{study_name}`. - -- **Best Achieved Score ({packet.get('metric_score_label', 'Score')}):** {best_score:.4f} -- **Optimal Hyperparameters:** -{chr(10).join(f" - `{k}`: {v}" for k, v in best_params.items())} - -## Search Space Performance -- **Total Trials Evaluated:** {packet.get('counts', {}).get('total', 0)} -- **Successful Runs:** {packet.get('counts', {}).get('complete', 0)} -- **Pruned Runs:** {packet.get('counts', {}).get('pruned', 0)} -- **Failed/OOM Runs:** {packet.get('counts', {}).get('failed', 0)} - -### Key Parameter Importances (fANOVA) -{chr(10).join(f"- `{k}`: {v:.4f}" for k, v in packet.get('fanova_importances', {}).items())} - -## Telemetry Profile -- **GPU Device:** {packet.get('vram_telemetry', {}).get('gpu_model', 'Unknown')} -- **Peak VRAM Recorded:** {packet.get('vram_telemetry', {}).get('gpu_capacity_gb', 0.0):.2f} GB -- **OOM Failures:** {packet.get('vram_telemetry', {}).get('oom_count', 0)} - ---- -*Generated by Pathfinder on {datetime.datetime.utcnow().isoformat()}* -""" - - # Write to studies directory in workspace - studies_dir = os.path.join(os.path.dirname(__file__), "studies") - os.makedirs(studies_dir, exist_ok=True) - - file_path = os.path.join(studies_dir, f"{study_name}_model_card.md") - with open(file_path, "w") as f: - f.write(card_content) - - # Hash calculation - sha = hashlib.sha256(card_content.encode("utf-8")).hexdigest() - - # Save card index to database - with get_db_session() as session: - session.merge(StudyCard( - study_name=study_name, - card_type="model_card", - file_path=os.path.relpath(file_path, os.path.dirname(__file__)), - content_hash=sha, - metadata_json=json.dumps({ - "best_score": best_score, - "best_params": best_params, - "total_trials": packet.get("counts", {}).get("total", 0) - }) - )) - - return { - "success": True, - "file_path": file_path, - "content_hash": sha, - "message": f"Model card written to disk and database index updated." - } - except Exception as e: - return {"success": False, "error": f"Failed to generate model card: {str(e)}"} - -@mcp.tool() -def submit_agent_review( - study_name: str, - summary: str, - health_rating: int, - policy_action: str = "no_change", - model_version: str = "coordinator", - prompt_strategy: str = "coordinator_review", - reasons: Optional[List[Dict[str, Any]]] = None, - estimated_score_improvement: Optional[float] = None, - cited_best_trial: Optional[int] = None, - search_space_patch: Optional[Dict[str, Any]] = None, - manual_trial: Optional[Dict[str, Any]] = None, - force: bool = False, -) -> Dict[str, Any]: - """Persists a coordinator review. Idempotent per trial window. Matches the HTTP route logic.""" - try: - from src.hpo_coordinator import ( - save_study_review, - count_evaluated_trials, - POLICY_ACTIONS, - validate_review_fields, - ) - - if policy_action not in POLICY_ACTIONS: - return { - "success": False, - "error": f"Invalid policy_action '{policy_action}'. Valid options are: {', '.join(POLICY_ACTIONS)}" - } - - study = get_or_create_study(study_name) - space = load_search_space(study_name) - trials_evaluated = count_evaluated_trials(study) - - if manual_trial: - val_res = _validate_manual_parameters(manual_trial, study_name) - if not val_res["ok"]: - with get_db_session() as session: - from src.schema import InvalidProposal - session.add(InvalidProposal( - study_name=study_name, - model_version=model_version or "coordinator", - prompt_strategy=prompt_strategy or "coordinator_review", - invalid_parameters=json.dumps(manual_trial), - validation_error=val_res["error"] - )) - return {"success": False, "error": f"Invalid manual parameters: {val_res['error']}"} - - validation = validate_review_fields(estimated_score_improvement, cited_best_trial) - if not validation["ok"]: - return {"success": False, "error": "; ".join(validation["errors"])} - - result = save_study_review( - study_name, - summary, - health_rating=health_rating, - policy_action=policy_action or "no_change", - model_version=model_version or "coordinator", - prompt_strategy=prompt_strategy or "coordinator_review", - reasons=reasons, - trials_evaluated=trials_evaluated, - estimated_score_improvement=estimated_score_improvement, - cited_best_trial=cited_best_trial, - force=force, - ) - - applied = {} - if not result.get("duplicate"): - if search_space_patch: - applied["search_space"] = _apply_search_space_patch(search_space_patch, space, study_name) - if manual_trial: - applied["manual_trial"] = _enqueue_manual_trial(study, manual_trial, space, summary) - - result["applied"] = applied - return result - except Exception as e: - import traceback - traceback.print_exc() - return {"success": False, "error": str(e)} - -@mcp.tool() -def validate_integration(study_name: str) -> Dict[str, Any]: - """Validates that a study is correctly initialized and configured in SQLite.""" - try: - from src.db_manager import get_db_session - from src.schema import SystemConfiguration, StudyStatus - import optuna - - status = {} - try: - study = optuna.load_study(study_name=study_name, storage=DATABASE_URL) - status["optuna_study_exists"] = True - status["study_directions"] = [d.name for d in study.directions] - status["total_trials"] = len(study.trials) - except Exception as e: - status["optuna_study_exists"] = False - status["optuna_study_error"] = str(e) - - with get_db_session() as session: - space = session.query(SystemConfiguration).filter_by( - study_name=study_name, config_key="active_search_space" - ).first() - config = session.query(SystemConfiguration).filter_by( - study_name=study_name, config_key="hpo_config" - ).first() - status["db_search_space_configured"] = space is not None - status["db_hpo_config_configured"] = config is not None - - status_row = session.query(StudyStatus).filter_by(study_name=study_name).first() - if status_row: - status["health_tier"] = status_row.health_tier - status["health_reason"] = status_row.health_reason - else: - status["health_tier"] = "unknown" - - broker_url = os.getenv("HPO_BROKER_URL", "http://localhost:8000") - status["broker_url"] = broker_url - try: - resp = requests.get(f"{broker_url.rstrip('/')}/health", timeout=3) - status["broker_online"] = resp.status_code == 200 - except Exception as e: - status["broker_online"] = False - status["broker_error"] = str(e) - - status["success"] = status.get("optuna_study_exists", False) and status.get("db_search_space_configured", False) - return status - except Exception as e: - return {"success": False, "error": str(e)} - @mcp.tool() def get_study_cards(study_name: Optional[str] = None) -> List[Dict[str, Any]]: """Retrieves generated study cards (model cards, recaps) from the database to enable cross-study queries.""" + from src.analytics import load_study_cards return load_study_cards(study_name) + @mcp.tool() def validate_manifest(yaml_str: str) -> Dict[str, Any]: """Mechanically validate a manifest YAML string against the Pathfinder schema rules.""" import yaml from src.manifest import validate_manifest as core_validate + try: data = yaml.safe_load(yaml_str) except Exception as e: return {"success": False, "errors": [f"Invalid YAML structure: {str(e)}"], "warnings": []} - + if not isinstance(data, dict): return {"success": False, "errors": ["Manifest root must be a dictionary"], "warnings": []} errors, warnings = core_validate(data) return {"success": len(errors) == 0, "errors": errors, "warnings": warnings} + @mcp.tool() def init_from_manifest(yaml_str: str, force: bool = False) -> Dict[str, Any]: """Validate and register a new HPO study from a manifest YAML string, with deep overwrite cleanup on force=True.""" import yaml + from src.onboarding import init_study_from_manifest_dict + try: data = yaml.safe_load(yaml_str) except Exception as e: return {"success": False, "error": f"Invalid YAML structure: {str(e)}"} - + if not isinstance(data, dict): return {"success": False, "error": "Manifest root must be a dictionary"} @@ -485,12 +63,14 @@ def init_from_manifest(yaml_str: str, force: bool = False) -> Dict[str, Any]: except Exception as e: return {"success": False, "error": str(e)} + @mcp.tool() def export_manifest(study_name: str) -> str: """Export the active search space, HPO config, and context of an existing study as a valid manifest YAML string.""" from src.manifest import export_manifest_yaml return export_manifest_yaml(study_name) + # --- MCP PROMPT RESOURCES --- @mcp.resource("hpo://prompts/grill") @@ -503,26 +83,11 @@ def resource_grill() -> str: 1. Draft a YAML manifest configuration (e.g. `train.hpo.yaml`). 2. Call `validate_manifest(yaml_str)` to check for errors/warnings mechanically. 3. Call `init_from_manifest(yaml_str)` to register the study in SQLite and Optuna. -4. Call `validate_integration(study_name)` to confirm the broker is healthy and integration is ready. +4. Call `get_study_data(study_name)` to confirm the study is accessible and healthy. Worker integration reference: `docs/INTEGRATION.md`. Do not write json space config files to disk. """ -@mcp.resource("hpo://prompts/review") -def resource_review() -> str: - """7-step episodic coordinator review (human-initiated only).""" - return """# Pathfinder Coordinator Review (7 Steps) - -Follow AGENTS.md. Trigger only when the user explicitly requests a review (watch/intervene nudges are not automatic). - -1. `get_study_data(study_name)` β€” packet includes fANOVA, past_reviews, coordinator_accuracy, statistical_confidence. -2. Interpret metrics using dynamic labels from project_context; heed statistical_confidence caveat when low/medium. -3. VRAM safety via vram_telemetry bounds_oom_risk; check past_reviews (ignore quality_flagged). -4. Self-regulate only if coordinator_accuracy.n_scored_reviews >= 3 and MAE > 0.05. -5. `update_search_space(..., apply=False)` to stage bounds (human approves on dashboard). -6. `submit_agent_review` with required estimated_score_improvement and cited_best_trial. -7. `generate_model_card(study_name)` when wrapping up. -""" if __name__ == "__main__": init_db() diff --git a/src/hpo_client.py b/src/hpo_client.py index fa11076..e777b31 100644 --- a/src/hpo_client.py +++ b/src/hpo_client.py @@ -3,10 +3,9 @@ This is the entire contract a worker needs to participate in a study: suggest -> report_epoch (per epoch) -> complete -It has no ML / framework dependencies (no torch, cv2, DeepCrack, or UNet). Bring your own -training loop and call these three methods. The root `colab_worker.py` is the full -bridge-crack reference implementation; cloners should use this client plus -`templates/worker_minimal.py` instead of forking that file. +It has no ML / framework dependencies (no torch, cv2, or model-specific code). Bring your own +training loop and call these three methods. Use `templates/worker_minimal.py` as a starting +point for your own project. Environment: HPO_BROKER_URL Base URL of the broker (e.g. an ngrok tunnel). Required for HTTP mode. @@ -18,12 +17,12 @@ session = TrialSession() # reads HPO_BROKER_URL / HPO_STUDY_NAME trial = session.suggest() # {trial_id, trial_number, params} for epoch in range(num_epochs): - dice, bce = train_one_epoch(trial["params"]) - if session.report_epoch(epoch, dice, bce): # True => broker says prune - session.complete(epoch, dice, bce, state="PRUNED") + score, loss = train_one_epoch(trial["params"]) + if session.report_epoch(epoch, score, loss): # True => broker says prune + session.complete(epoch, score, loss, state="PRUNED") break else: - session.complete(epoch, dice, bce, weights_path="model.pt", history=session.history) + session.complete(epoch, score, loss, weights_path="model.pt", history=session.history) """ import os from typing import Any, Dict, List, Optional @@ -332,15 +331,11 @@ def report_epoch( "epoch": epoch, "score": score, "loss": loss, - # Backwards compatibility keys for UI / charts - "dice": score, - "bce": loss, } if score_eval_fixed is not None: entry["score_eval_fixed"] = score_eval_fixed - entry["dice_eval_fixed"] = score_eval_fixed + if loss_eval_fixed is not None: entry["loss_eval_fixed"] = loss_eval_fixed - entry["bce_eval_fixed"] = loss_eval_fixed self.history.append(entry) data = self._post("/api/report_epoch", payload) diff --git a/src/routers/dashboard.py b/src/routers/dashboard.py index 97e13a8..35bccce 100644 --- a/src/routers/dashboard.py +++ b/src/routers/dashboard.py @@ -2,7 +2,7 @@ import json import traceback import logging -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, Any from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse from pydantic import BaseModel @@ -12,12 +12,8 @@ from ..db_manager import get_db_session, get_or_create_study_status from ..settings import settings from ..schema import ( - StudyStatus, TrialResult, SystemConfiguration, - CoordinatorMetric, - SuggestMetric, - InvalidProposal, ) from ..hpo_config import ( load_hpo_config, @@ -25,36 +21,26 @@ normalize_trial_params, param_display_name, ) -from ..metrics import score_objective_index, _trial_metric_snapshot +from ..metrics import _trial_metric_snapshot from ..search_space import ( - _migrate_search_space, load_search_space, - _apply_search_space_patch, handle_api_get_search_space, handle_api_update_search_space, + _fixed_categorical_params, ) from ..pruning import _effective_train_resolution -from ..suggest import ( - get_or_create_study, - load_study, - _enqueue_manual_trial, -) +from ..suggest import load_study from ..leases import ( _reap_stale_running_trials, _reap_expired_leases, ) -from ..hpo_coordinator import ( +from ..health import compute_health_tier, compute_statistical_confidence, count_evaluated_trials +from ..analytics import ( study_eval_insights as _study_eval_insights, pareto_trial_numbers_deploy_aware as _pareto_trial_numbers_deploy_aware, - build_review_packet, - save_study_review, - get_recent_study_reviews, - count_evaluated_trials, - compute_review_heuristics, - compute_statistical_confidence, - validate_review_fields, - mark_review_applied, - flag_study_review, + build_study_packet, + load_study_cards, + get_fanova_importances, ) logger = logging.getLogger(__name__) @@ -66,25 +52,6 @@ class LoginRequest(BaseModel): token: str -class InitFromManifestRequest(BaseModel): - yaml: str - - -class AgentReviewRequest(BaseModel): - study_name: str - summary: str - health_rating: Optional[int] = None # 1-5 - policy_action: Optional[str] = "no_change" # no_change | update_active_search_space | enqueue_one_manual_trial - model_version: Optional[str] = "coordinator" - prompt_strategy: Optional[str] = "coordinator_review" - reasons: Optional[List[Dict[str, Any]]] = None - search_space_patch: Optional[Dict[str, Any]] = None - manual_trial: Optional[Dict[str, Any]] = None - estimated_score_improvement: Optional[float] = None - cited_best_trial: Optional[int] = None - force: Optional[bool] = False - - @router.post("/login") def api_login(req: LoginRequest, request: Request): """Exchange the shared token for an httpOnly session cookie (dashboard login).""" @@ -137,161 +104,27 @@ def api_update_search_space(space: Dict[str, Any], study_name: Optional[str] = N @router.get("/study_health") def api_get_study_health(study_name: str): try: - study = get_or_create_study(study_name) + study = load_study(study_name) with get_db_session() as session: _reap_stale_running_trials(study, study_name, session) except Exception as reap_err: print(f"study_health reap skipped for '{study_name}': {reap_err}") - with get_db_session() as session: - status = session.query(StudyStatus).filter_by(study_name=study_name).first() - is_dismissed = False - if status: - try: - study = get_or_create_study(study_name) - trials_evaluated = count_evaluated_trials(study) - if status.nudge_dismissed_trials == trials_evaluated: - is_dismissed = True - except Exception as e: - logger.warning(f"Failed to load study {study_name} when checking dismissal status: {e}") - return { - "study_name": study_name, - "health_tier": status.health_tier, - "health_reason": status.health_reason, - "health_updated_at": status.health_updated_at.isoformat() if status.health_updated_at else None, - "is_dismissed": is_dismissed - } - return { - "study_name": study_name, - "health_tier": "healthy", - "health_reason": "No status found, defaulting to healthy.", - "health_updated_at": None, - "is_dismissed": False - } + study = load_study(study_name) + health_tier, health_reason = compute_health_tier(study, study_name) + trials_evaluated = count_evaluated_trials(study) - -@router.get("/pending_changes") -def api_get_pending_changes(study_name: Optional[str] = None): - if not study_name: - study_name = settings.study_name with get_db_session() as session: - row = session.query(SystemConfiguration).filter_by( - study_name=study_name, config_key="pending_search_space" - ).first() - if row: - try: - return {"proposed_changes": json.loads(row.config_value)} - except Exception as e: - return {"proposed_changes": None, "error": str(e)} - return {"proposed_changes": None} - - -@router.post("/apply_pending_changes") -def api_apply_pending_changes(study_name: Optional[str] = None): - if not study_name: - study_name = settings.study_name - try: - with get_db_session() as session: - pending_row = session.query(SystemConfiguration).filter_by( - study_name=study_name, config_key="pending_search_space" - ).first() - if not pending_row: - raise HTTPException(status_code=400, detail="No pending changes found.") - - proposed = json.loads(pending_row.config_value) - current = load_search_space(study_name) - - for key, new_val in proposed.items(): - if key not in current: - raise HTTPException(status_code=400, detail=f"Parameter {key} not in active search space.") - - p_type = current[key].get("type") - if p_type == "categorical": - if "active" in new_val: - allowed = current[key].get("options", []) - invalid = [x for x in new_val["active"] if x not in allowed] - if invalid: - raise HTTPException(status_code=400, detail=f"Invalid active options for {key}: {invalid}") - if not new_val["active"]: - raise HTTPException(status_code=400, detail=f"Must keep at least one active option for {key}.") - current[key]["active"] = new_val["active"] - else: - if "min" in new_val: - current[key]["min"] = float(new_val["min"]) - if "max" in new_val: - current[key]["max"] = float(new_val["max"]) - - session.merge(SystemConfiguration( - study_name=study_name, - config_key="active_search_space", - config_value=json.dumps(_migrate_search_space(current)) - )) - session.delete(pending_row) - mark_review_applied(study_name) - return {"success": True, "space": current} - except HTTPException as he: - raise he - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to apply pending changes: {str(e)}") + status = get_or_create_study_status(session, study_name) + status.health_tier = health_tier + status.health_reason = health_reason - -@router.post("/discard_pending_changes") -def api_discard_pending_changes(study_name: Optional[str] = None): - if not study_name: - study_name = settings.study_name - try: - with get_db_session() as session: - row = session.query(SystemConfiguration).filter_by( - study_name=study_name, config_key="pending_search_space" - ).first() - if row: - session.delete(row) - return {"success": True} - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to discard pending changes: {str(e)}") - - -@router.post("/validate_manifest") -def api_validate_manifest(req: InitFromManifestRequest): - import yaml - from ..manifest import validate_manifest - try: - data = yaml.safe_load(req.yaml) - except Exception as e: - return {"success": False, "errors": [f"Invalid YAML structure: {str(e)}"], "warnings": []} - - if not isinstance(data, dict): - return {"success": False, "errors": ["Manifest root must be a dictionary"], "warnings": []} - - errors, warnings = validate_manifest(data) - return {"success": len(errors) == 0, "errors": errors, "warnings": warnings} - - -@router.post("/init_from_manifest") -def api_init_from_manifest(req: InitFromManifestRequest, force: bool = False): - import yaml - from ..manifest import validate_manifest - from ..onboarding import init_study_from_manifest_dict - - try: - data = yaml.safe_load(req.yaml) - except Exception as e: - raise HTTPException(status_code=400, detail=f"Invalid YAML structure: {str(e)}") - - if not isinstance(data, dict): - raise HTTPException(status_code=400, detail="Manifest root must be a dictionary") - - errors, warnings = validate_manifest(data) - if errors: - return {"success": False, "errors": errors, "warnings": warnings} - - try: - result = init_study_from_manifest_dict(data, force=force) - return {"success": True, "study_name": data["study_name"], "message": result, "warnings": warnings} - except ValueError as ve: - raise HTTPException(status_code=400, detail=str(ve)) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + return { + "study_name": study_name, + "health_tier": health_tier, + "health_reason": health_reason, + "trials_evaluated": trials_evaluated, + } @router.get("/studies") @@ -303,41 +136,10 @@ def api_list_studies(): return {"success": False, "error": str(e)} -@router.get("/study_setup") -def api_study_setup(study_name: str): - try: - with get_db_session() as session: - context_row = session.query(SystemConfiguration).filter_by( - study_name=study_name, config_key="project_context" - ).first() - hpo_config_row = session.query(SystemConfiguration).filter_by( - study_name=study_name, config_key="hpo_config" - ).first() - context_val = context_row.config_value if context_row else None - hpo_config_val = hpo_config_row.config_value if hpo_config_row else None - - context = json.loads(context_val) if context_val else {} - hpo_config = json.loads(hpo_config_val) if hpo_config_val else {} - - is_reference = (study_name == "bridge_crack_study") and ("worker_entrypoint" not in context) - - return { - "success": True, - "study_name": study_name, - "worker_entrypoint": context.get("worker_entrypoint"), - "worker_env": context.get("worker_env"), - "is_reference": is_reference, - "manifest_metrics": hpo_config.get("manifest_metrics"), - "colab_snippet": context.get("colab_snippet") - } - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - @router.get("/study_details") def api_study_details(study_name: str): try: - study = get_or_create_study(study_name) + study = load_study(study_name) with get_db_session() as session: _reap_expired_leases(study, study_name, session) @@ -374,8 +176,8 @@ def api_study_details(study_name: str): hpo_config = load_hpo_config(study_name) space = load_search_space(study_name) ev = hpo_config.get("eval_protocol", {}) - dice_fixed_attr = ev.get("fixed_dice_attr", "dice_eval_fixed") - bce_fixed_attr = ev.get("fixed_bce_attr", "bce_eval_fixed") + score_fixed_attr = ev.get("fixed_score_attr", "score_eval_fixed") + loss_fixed_attr = ev.get("fixed_loss_attr", "loss_eval_fixed") train_param = ev.get("train_resolution_param", "resolution") trials_list = [] @@ -387,9 +189,12 @@ def api_study_details(study_name: str): if not history and t._trial_id in metrics_dict: history = metrics_dict[t._trial_id] - metrics = _trial_metric_snapshot(t, history, dice_fixed_attr, bce_fixed_attr, study.directions) + metrics = _trial_metric_snapshot(t, history, score_fixed_attr, loss_fixed_attr, study.directions) train_res = _effective_train_resolution(t, hpo_config, space) norm_params = normalize_trial_params(dict(t.params), hpo_config) + for k, v in _fixed_categorical_params(space).items(): + if k not in norm_params: + norm_params[k] = v if train_res is not None and train_param not in norm_params: norm_params[train_param] = train_res @@ -404,12 +209,8 @@ def api_study_details(study_name: str): "params_display": { param_display_name(k, hpo_config): v for k, v in norm_params.items() }, - "bce": metrics["bce"], - "dice": metrics["dice"], "score": metrics["score"], "loss": metrics["loss"], - "dice_eval_fixed": metrics["dice_eval_fixed"], - "bce_eval_fixed": metrics["bce_eval_fixed"], "score_eval_fixed": metrics["score_eval_fixed"], "loss_eval_fixed": metrics["loss_eval_fixed"], "train_resolution": train_res, @@ -441,8 +242,8 @@ def api_study_details(study_name: str): pareto_trial_numbers = _pareto_trial_numbers_deploy_aware(study, hpo_config) insights = _study_eval_insights(study, hpo_config) - review = compute_review_heuristics(study, insights, hpo_config, study_name) n_complete = sum(1 for t in study.trials if t.state == TrialState.COMPLETE) + health_tier, health_reason = compute_health_tier(study, study_name) return { "study_name": study_name, @@ -452,10 +253,9 @@ def api_study_details(study_name: str): "study_directions": [d.name for d in study.directions], "hpo_config": hpo_config, "eval_insights": insights, - "review": review, + "health": {"tier": health_tier, "reason": health_reason}, "statistical_confidence": compute_statistical_confidence(n_complete), "completed_count": n_complete, - "past_reviews": get_recent_study_reviews(study_name, limit=10), } except Exception as e: traceback.print_exc() @@ -465,12 +265,11 @@ def api_study_details(study_name: str): @router.get("/fanova") def api_fanova(study_name: str): try: - study = get_or_create_study(study_name) + study = load_study(study_name) complete_trials = [t for t in study.trials if t.state == TrialState.COMPLETE] if len(complete_trials) < 2: return {"success": False, "message": "Need at least 2 completed trials for importance analysis"} config = load_hpo_config(study_name) - from ..hpo_coordinator import get_fanova_importances display = get_fanova_importances(study, config) return {"success": True, "importances": display} @@ -478,11 +277,11 @@ def api_fanova(study_name: str): return {"success": False, "message": str(e)} -@router.get("/review_packet") -def api_review_packet(study_name: str): +@router.get("/study_packet") +def api_study_packet(study_name: str): """Read-only context for the IDE coordinator: Pareto, fANOVA, eval insights, drift reasons.""" try: - return build_review_packet(study_name) + return build_study_packet(study_name) except Exception as e: traceback.print_exc() raise HTTPException(status_code=500, detail=str(e)) @@ -492,14 +291,15 @@ def api_review_packet(study_name: str): def api_pareto_front(study_name: str): """Exposes Pareto front trials for export in the GUI dashboard.""" try: - study = get_or_create_study(study_name) + study = load_study(study_name) if len(study.directions) < 2: return {"success": True, "pareto_front": []} hpo_config = load_hpo_config(study_name) + space = load_search_space(study_name) ev = hpo_config.get("eval_protocol", {}) - dice_fixed_attr = ev.get("fixed_dice_attr", "dice_eval_fixed") - bce_fixed_attr = ev.get("fixed_bce_attr", "bce_eval_fixed") + score_fixed_attr = ev.get("fixed_score_attr", "score_eval_fixed") + loss_fixed_attr = ev.get("fixed_loss_attr", "loss_eval_fixed") with get_db_session() as session: metrics = session.query(TrialResult).filter_by(study_name=study_name).all() @@ -512,14 +312,15 @@ def api_pareto_front(study_name: str): if not history and t._trial_id in metrics_dict: history = metrics_dict[t._trial_id] - metrics_vals = _trial_metric_snapshot(t, history, dice_fixed_attr, bce_fixed_attr, study.directions) + metrics_vals = _trial_metric_snapshot(t, history, score_fixed_attr, loss_fixed_attr, study.directions) norm_params = normalize_trial_params(dict(t.params), hpo_config) + for k, v in _fixed_categorical_params(space).items(): + if k not in norm_params: + norm_params[k] = v pareto_trials.append({ "number": t.number, "trial_id": t._trial_id, - "bce": metrics_vals["bce"], - "dice": metrics_vals["dice"], "score": metrics_vals["score"], "loss": metrics_vals["loss"], "params": norm_params @@ -531,24 +332,6 @@ def api_pareto_front(study_name: str): raise HTTPException(status_code=500, detail=str(e)) -@router.post("/dismiss_coordinator_nudge") -def api_dismiss_coordinator_nudge(study_name: str): - """Dismisses the coordinator nudge for the current trial window by persisting it in SQLite.""" - try: - study = get_or_create_study(study_name) - trials_evaluated = count_evaluated_trials(study) - - with get_db_session() as session: - status = get_or_create_study_status(session, study_name) - status.nudge_dismissed_trials = trials_evaluated - session.commit() - - return {"success": True, "dismissed_trials": trials_evaluated} - except Exception as e: - traceback.print_exc() - raise HTTPException(status_code=500, detail=str(e)) - - @router.get("/study_cards") def api_get_study_cards(study_name: Optional[str] = None): """Exposes generated study cards and their markdown content for dashboard retrieval.""" @@ -559,71 +342,6 @@ def api_get_study_cards(study_name: Optional[str] = None): raise HTTPException(status_code=500, detail=str(e)) -@router.post("/agent_review") -def api_agent_review(req: AgentReviewRequest): - """Persist a coordinator review. Idempotent per trial window unless force=True.""" - try: - study = load_study(req.study_name) - space = load_search_space(req.study_name) - trials_evaluated = count_evaluated_trials(study) - - if req.manual_trial: - from ..hpo_coordinator import _validate_manual_parameters - val_res = _validate_manual_parameters(req.manual_trial, req.study_name) - if not val_res["ok"]: - with get_db_session() as session: - session.add(InvalidProposal( - study_name=req.study_name, - model_version=req.model_version or "coordinator", - prompt_strategy=req.prompt_strategy or "coordinator_review", - invalid_parameters=json.dumps(req.manual_trial), - validation_error=val_res["error"] - )) - return {"success": False, "error": f"Invalid manual parameters: {val_res['error']}"} - - validation = validate_review_fields(req.estimated_score_improvement, req.cited_best_trial) - if not validation["ok"]: - return {"success": False, "error": "; ".join(validation["errors"])} - result = save_study_review( - req.study_name, - req.summary, - health_rating=req.health_rating, - policy_action=req.policy_action or "no_change", - model_version=req.model_version or "coordinator", - prompt_strategy=req.prompt_strategy or "coordinator_review", - reasons=req.reasons, - trials_evaluated=trials_evaluated, - estimated_score_improvement=req.estimated_score_improvement, - cited_best_trial=req.cited_best_trial, - force=bool(req.force), - ) - - applied = {} - if not result.get("duplicate"): - if req.search_space_patch: - applied["search_space"] = _apply_search_space_patch(req.search_space_patch, space, req.study_name) - if req.manual_trial: - applied["manual_trial"] = _enqueue_manual_trial(study, req.manual_trial, space, req.summary) - - result["applied"] = applied - return result - except HTTPException: - raise - except Exception as e: - traceback.print_exc() - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/flag_review") -def api_flag_review(review_id: int, flagged: bool = True): - """Mark a coordinator review as low-quality (excluded from accuracy MAE).""" - try: - return flag_study_review(review_id, flagged=flagged) - except Exception as e: - traceback.print_exc() - raise HTTPException(status_code=500, detail=str(e)) - - @router.get("/tunnel_url") def api_get_tunnel_url(): """Returns the active remote broker URL if established.""" @@ -656,7 +374,7 @@ def api_config_audit(study_name: Optional[str] = None): db_space = load_search_space(study_name) try: - study = get_or_create_study(study_name) + study = load_study(study_name) for t in study.trials: if t.state not in (TrialState.COMPLETE, TrialState.RUNNING): continue @@ -699,42 +417,67 @@ def api_config_audit(study_name: Optional[str] = None): return report -@router.get("/metrics/coordinator") -def api_metrics_coordinator(study_name: Optional[str] = None): - if not study_name: - study_name = settings.study_name - with get_db_session() as session: - rows = session.query(CoordinatorMetric).filter_by(study_name=study_name).all() - return {"success": True, "metrics": [r.to_dict() for r in rows]} +@router.post("/quickstart_demo") +def api_quickstart_demo(request: Request): + from ..onboarding import init_study_from_manifest_dict + from threading import Thread + from simulators.training_worker import run_training_worker + DEMO_STUDY = "demo_segmentation_study" -@router.get("/metrics/suggest") -def api_metrics_suggest(study_name: Optional[str] = None): - if not study_name: - study_name = settings.study_name - with get_db_session() as session: - rows = session.query(SuggestMetric).filter_by(study_name=study_name).all() - return {"success": True, "metrics": [r.to_dict() for r in rows]} + # If the demo study already exists and has trials, just redirect β€” no re-spawn needed + try: + existing = optuna.load_study(study_name=DEMO_STUDY, storage=settings.database_url) + if len(existing.trials) > 0: + return {"success": True, "study_name": DEMO_STUDY} + except KeyError: + pass # Study doesn't exist yet β€” proceed + + DEMO_MANIFEST = { + "study_name": DEMO_STUDY, + "metrics": { + "primary_score": "score", + "objectives": [ + {"name": "loss", "direction": "minimize", "label": "Loss"}, + {"name": "score", "direction": "maximize", "label": "Score"}, + ], + }, + "params": [ + {"name": "learning_rate", "type": "float_log", "min": 0.0001, "max": 0.1}, + {"name": "batch_size", "type": "categorical", "options": [4, 8, 16, 32]}, + {"name": "resolution", "type": "categorical", "options": [256, 512, 1024]}, + {"name": "loss_weight_ratio", "type": "float", "min": 0.0, "max": 1.0}, + {"name": "model_capacity", "type": "categorical", "options": ["narrow", "wide"]}, + ], + "worker": {"entrypoint": "python simulators/training_worker.py"}, + } + + init_study_from_manifest_dict(DEMO_MANIFEST, force=True) + + broker_url = settings.broker_url or str(request.base_url).rstrip("/") + Thread( + target=run_training_worker, + args=(DEMO_STUDY,), + kwargs={"max_trials": 5, "broker_url": broker_url}, + daemon=True, + ).start() + return {"success": True, "study_name": DEMO_STUDY} -@router.get("/mcp_info") -def api_mcp_info(): + +@router.get("/worker_snippet") +def api_worker_snippet(study_name: str): + broker_url = settings.broker_url or "http://localhost:8000" + secret_token = settings.secret_token return { "success": True, - "mcp_server_name": "pathfinder", - "active_study": settings.study_name, - "mcp_tools": [ - "initialize_study", - "get_study_data", - "validate_search_space", - "update_search_space", - "delete_study", - "generate_model_card", - "submit_agent_review", - "validate_integration", - "get_study_cards", - "validate_manifest", - "init_from_manifest", - "export_manifest", - ] + "broker_url": broker_url, + "study_name": study_name, + "auth_required": bool(secret_token), + "snippet": ( + f"export HPO_BROKER_URL={broker_url}\n" + f"export HPO_STUDY_NAME={study_name}\n" + + (f"export HPO_SECRET_TOKEN={secret_token}\n" if secret_token else "") + + "python your_worker.py" + ), } diff --git a/src/routers/static.py b/src/routers/static.py index ed8b531..c8ebc2d 100644 --- a/src/routers/static.py +++ b/src/routers/static.py @@ -40,13 +40,6 @@ def get_styles(): ) raise HTTPException(status_code=404, detail="styles.css not found") -@router.get("/colab_worker.py") -def get_colab_worker(): - worker_path = os.path.join(_base_dir, "colab_worker.py") - if os.path.exists(worker_path): - return FileResponse(worker_path, media_type="text/x-python", filename="colab_worker.py") - raise HTTPException(status_code=404, detail="colab_worker.py not found") - @router.get("/hpo_client.py") def get_hpo_client(): client_path = os.path.join(_base_dir, "src", "hpo_client.py") diff --git a/src/routers/worker.py b/src/routers/worker.py index bc0ff0a..a48bcb3 100644 --- a/src/routers/worker.py +++ b/src/routers/worker.py @@ -12,13 +12,13 @@ def api_suggest_trial_help(): "error": "Method not allowed: use POST, not GET", "post_url": "/api/suggest_trial", "body_example": { - "study_name": "bridge_crack_study", + "study_name": "my_study", "reasoning": "Autonomous worker suggestion request.", }, "curl_example": ( 'curl -X POST "$BROKER_URL/api/suggest_trial" ' '-H "Content-Type: application/json" ' - '-d \'{"study_name":"bridge_crack_study"}\'' + '-d \'{"study_name":"my_study"}\'' ), } diff --git a/src/settings.py b/src/settings.py index 5a97a82..deea16c 100644 --- a/src/settings.py +++ b/src/settings.py @@ -2,7 +2,7 @@ from typing import Optional, List class Settings: - DEFAULT_DB_FILENAME = "hpo_studies.db" + DEFAULT_DB_FILENAME = ".data/hpo_studies.db" @property def database_url(self) -> str: From 27ef294686e017adadb2b95775a45f7aba682d1c Mon Sep 17 00:00:00 2001 From: Ishaan Date: Tue, 30 Jun 2026 10:36:11 -0400 Subject: [PATCH 4/8] refactor suggestion resampling and pruning --- src/hpo_config.py | 34 +++++---- src/leases.py | 20 +++-- src/manifest.py | 9 +-- src/onboarding.py | 7 +- src/pruning.py | 28 +++---- src/search_space.py | 127 ++++++++++++++------------------ src/suggest.py | 175 +++++++++++++++++++++----------------------- 7 files changed, 198 insertions(+), 202 deletions(-) diff --git a/src/hpo_config.py b/src/hpo_config.py index cd5fb74..06babfd 100644 --- a/src/hpo_config.py +++ b/src/hpo_config.py @@ -17,7 +17,6 @@ "metric_loss_label": "Loss", "metric_score_label": "Score", "metric_names": {"score": "score", "loss": "loss"}, - "desktop_notifications_enabled": False, "validation_rules": { "score_min": None, "loss_min": None, @@ -28,10 +27,10 @@ "enabled": False, "fixed_resolution": None, "train_resolution_param": "resolution", - "fixed_dice_attr": "score_eval_fixed", - "fixed_bce_attr": "loss_eval_fixed", - "dice_train_label": "Score (train)", - "dice_fixed_label": "Score (eval)", + "fixed_score_attr": "score_eval_fixed", + "fixed_loss_attr": "loss_eval_fixed", + "score_train_label": "Score (train)", + "score_fixed_label": "Score (eval)", "use_fixed_metric_for_pruning": True, "prune_min_epoch": 5, "prune_compare_same_resolution_only": True, @@ -45,7 +44,6 @@ LEGACY_DEFAULT_HPO_CONFIG: Dict[str, Any] = { "metric_loss_label": "BCE", "metric_score_label": "Dice", - "desktop_notifications_enabled": False, "validation_rules": { "score_min": 0.0, "loss_min": 0.0, @@ -83,6 +81,8 @@ def load_hpo_config(study_name: Optional[str] = None) -> Dict[str, Any]: from .settings import settings if not study_name: study_name = settings.study_name + if not study_name or not study_name.strip(): + return copy.deepcopy(DEFAULT_HPO_CONFIG) # Try loading study-specific config from DB data = None @@ -103,7 +103,7 @@ def load_hpo_config(study_name: Optional[str] = None) -> Dict[str, Any]: # If we fell back to _global and the current study is NOT a legacy U-Net study, # check if the global config is legacy (version 1). If it is, ignore it to # prevent legacy poisoning of generic studies. - if row.study_name == "_global" and study_name not in ("seg_v1", "bridge_crack_study"): + if row.study_name == "_global": if loaded_data.get("config_version", 1) == 1: loaded_data = None if loaded_data: @@ -113,16 +113,14 @@ def load_hpo_config(study_name: Optional[str] = None) -> Dict[str, Any]: # Fallback to loading default template from disk if DB fails or has no config if not data: - is_legacy = study_name in ("seg_v1", "bridge_crack_study") - data = LEGACY_DEFAULT_HPO_CONFIG if is_legacy else DEFAULT_HPO_CONFIG + data = DEFAULT_HPO_CONFIG # Seed it into DB for this study so it exists in DB try: save_hpo_config(data, study_name) except Exception as e: logger.warning(f"Failed to seed hpo_config in DB: {e}") - is_legacy_name = study_name in ("seg_v1", "bridge_crack_study") - config_version = data.get("config_version", 1 if is_legacy_name else 2) + config_version = data.get("config_version", 2) defaults = LEGACY_DEFAULT_HPO_CONFIG if config_version == 1 else DEFAULT_HPO_CONFIG try: @@ -150,11 +148,12 @@ def save_hpo_config(config: Dict[str, Any], study_name: Optional[str] = None) -> from .settings import settings if not study_name: study_name = settings.study_name + if not study_name or not study_name.strip(): + raise ValueError("study_name cannot be empty.") # Enforce config_version on save to prevent client downgrades config = dict(config) - is_legacy = study_name in ("seg_v1", "bridge_crack_study") - config["config_version"] = config.get("config_version", 1 if is_legacy else 2) + config["config_version"] = config.get("config_version", 2) try: from .db_manager import get_db_session @@ -176,6 +175,15 @@ def save_hpo_config(config: Dict[str, Any], study_name: Optional[str] = None) -> except Exception as e: print(f"Error saving hpo_config to DB: {e}") + # Bust cached study packets β€” config changes invalidate analytics + try: + from .db_manager import get_db_session + from .schema import CompactedPacket + with get_db_session() as session: + session.query(CompactedPacket).filter_by(study_name=study_name).delete() + except Exception: + pass + def param_display_name(param: str, config: Optional[Dict[str, Any]] = None, study_name: Optional[str] = None) -> str: config = config or load_hpo_config(study_name) diff --git a/src/leases.py b/src/leases.py index a65e571..7f6072b 100644 --- a/src/leases.py +++ b/src/leases.py @@ -1,5 +1,5 @@ import traceback -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional from pydantic import BaseModel from fastapi import HTTPException @@ -16,7 +16,7 @@ def _reap_stale_running_trials(study, study_name: str, session) -> int: """Fail RUNNING trials whose worker lease expired or has no active lease.""" - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) expired_trial_ids = [ row.trial_id for row in session.query(TrialLease.trial_id).filter( @@ -35,6 +35,13 @@ def _reap_stale_running_trials(study, study_name: str, session) -> int: for t in study.trials: if t.state != TrialState.RUNNING: continue + # Skip trials that were created very recently (no lease row yet) to avoid + # reaping a trial that a concurrent suggest call is still setting up. + if t._trial_id not in active_leased_ids and t._trial_id not in expired_trial_ids: + if t.datetime_start is not None: + age = (now - t.datetime_start.replace(tzinfo=None)).total_seconds() + if age < LEASE_TTL_SECONDS: + continue stale = t._trial_id in expired_trial_ids or t._trial_id not in active_leased_ids if not stale: continue @@ -66,7 +73,7 @@ def _try_claim_lease(session, study_name: str, trial_id: int, worker_id: str, sole mechanism preventing two workers from being handed the same trial. Safe under concurrency: SQLite (WAL + busy_timeout) serializes writers and Postgres locks the row. """ - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) new_expiry = now + timedelta(seconds=ttl_seconds) updated = session.query(TrialLease).filter( TrialLease.trial_id == trial_id, @@ -101,8 +108,7 @@ def _lease_is_owned(study_name: str, trial_id: int, worker_id: Optional[str]) -> Workers prove ownership with the ``worker_id`` returned from /api/suggest_trial before they may report epochs or complete an in-flight trial. Terminal trials skip this check (see callers): idempotent retries and post-prune completes must still record results even - though the lease was already deleted. Note: compared against naive UTC because lease - timestamps are stored via ``datetime.utcnow()``. + though the lease was already deleted. Lease timestamps are stored as naive UTC. """ if not worker_id: return False @@ -112,7 +118,7 @@ def _lease_is_owned(study_name: str, trial_id: int, worker_id: Optional[str]) -> ).first() if lease is None: return False - if lease.lease_expires_at is not None and lease.lease_expires_at < datetime.utcnow(): + if lease.lease_expires_at is not None and lease.lease_expires_at < datetime.now(timezone.utc).replace(tzinfo=None): return False return True @@ -132,7 +138,7 @@ def handle_api_heartbeat(req: HeartbeatRequest): leased_to=req.worker_id ).first() if lease: - lease.lease_expires_at = datetime.utcnow() + timedelta(seconds=LEASE_TTL_SECONDS) + lease.lease_expires_at = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(seconds=LEASE_TTL_SECONDS) session.commit() return {"success": True, "message": "Heartbeat acknowledged"} return {"success": False, "message": "Lease not found or expired"} diff --git a/src/manifest.py b/src/manifest.py index 372d114..1d284e5 100644 --- a/src/manifest.py +++ b/src/manifest.py @@ -459,15 +459,14 @@ def _manifest_to_hpo_config(data: Dict[str, Any]) -> Dict[str, Any]: "config_version": 2, "metric_loss_label": loss_label, "metric_score_label": score_label, - "desktop_notifications_enabled": False, "eval_protocol": { "enabled": eval_proto_enabled, "fixed_resolution": fixed_res, "train_resolution_param": train_res_param, - "fixed_dice_attr": score_eval_attr, - "fixed_bce_attr": loss_eval_attr, - "dice_train_label": f"{score_label} (train)", - "dice_fixed_label": f"{score_label} (eval)", + "fixed_score_attr": score_eval_attr, + "fixed_loss_attr": loss_eval_attr, + "score_train_label": f"{score_label} (train)", + "score_fixed_label": f"{score_label} (eval)", "use_fixed_metric_for_pruning": eval_proto_enabled, "prune_min_epoch": 5, "prune_compare_same_resolution_only": True, diff --git a/src/onboarding.py b/src/onboarding.py index 7c6970f..9c50e74 100644 --- a/src/onboarding.py +++ b/src/onboarding.py @@ -1,4 +1,3 @@ -import os import json import logging import optuna @@ -150,7 +149,11 @@ def init_study_from_manifest_dict(data: Dict[str, Any], force: bool = False) -> if study_exists and force: # Call the thorough delete_study tool to purge all trials and metadata - delete_study_internal(study_name=study_name, confirm=True) + result = delete_study_internal(study_name=study_name, confirm=True) + if not result.get("success"): + raise RuntimeError( + f"Failed to delete existing study '{study_name}': {result.get('error', 'unknown error')}" + ) metrics = data["metrics"] active_search_space = _manifest_params_to_search_space(data["params"]) diff --git a/src/pruning.py b/src/pruning.py index 3454661..dfaa088 100644 --- a/src/pruning.py +++ b/src/pruning.py @@ -2,7 +2,7 @@ from typing import Optional, Dict, Any, List from optuna.trial import TrialState -from src.hpo_coordinator import trial_train_resolution as _trial_train_resolution +from src.analytics import trial_train_resolution as _trial_train_resolution def _effective_train_resolution( @@ -24,7 +24,7 @@ def _effective_train_resolution( def _epoch_composite_score(study, trial, epoch: int, ev: Dict[str, Any]) -> Optional[float]: - """Composite score at epoch, Z-score normalized against study rolling history at the same epoch, fallback to (dice - bce).""" + """Composite score at epoch, Z-score normalized against study rolling history at the same epoch, fallback to (score - loss).""" use_fixed = ev.get("enabled") and ev.get("use_fixed_metric_for_pruning") # 1. Retrieve current trial's metrics at this epoch @@ -34,12 +34,12 @@ def _epoch_composite_score(study, trial, epoch: int, ev: Dict[str, Any]) -> Opti if isinstance(history, list): for entry in history: if entry.get("epoch") == epoch: - if use_fixed and (entry.get("score_eval_fixed") is not None or entry.get("dice_eval_fixed") is not None): - curr_score = entry.get("score_eval_fixed", entry.get("dice_eval_fixed")) - curr_loss = entry.get("loss_eval_fixed", entry.get("bce_eval_fixed", entry.get("loss", entry.get("bce", 0.0)))) + if use_fixed and entry.get("score_eval_fixed") is not None: + curr_score = entry.get("score_eval_fixed") + curr_loss = entry.get("loss_eval_fixed", entry.get("loss", 0.0)) else: - curr_score = entry.get("score", entry.get("dice")) - curr_loss = entry.get("loss", entry.get("bce")) + curr_score = entry.get("score") + curr_loss = entry.get("loss") break if curr_score is None or curr_loss is None: @@ -57,12 +57,12 @@ def _epoch_composite_score(study, trial, epoch: int, ev: Dict[str, Any]) -> Opti if isinstance(t_history, list): for entry in t_history: if entry.get("epoch") == epoch: - if use_fixed and (entry.get("score_eval_fixed") is not None or entry.get("dice_eval_fixed") is not None): - s = entry.get("score_eval_fixed", entry.get("dice_eval_fixed")) - l = entry.get("loss_eval_fixed", entry.get("bce_eval_fixed", entry.get("loss", entry.get("bce", 0.0)))) + if use_fixed and entry.get("score_eval_fixed") is not None: + s = entry.get("score_eval_fixed") + l = entry.get("loss_eval_fixed", entry.get("loss", 0.0)) else: - s = entry.get("score", entry.get("dice")) - l = entry.get("loss", entry.get("bce")) + s = entry.get("score") + l = entry.get("loss") if s is not None and l is not None: scores.append(float(s)) losses.append(float(l)) @@ -70,8 +70,8 @@ def _epoch_composite_score(study, trial, epoch: int, ev: Dict[str, Any]) -> Opti # 3. Z-score normalize if we have enough history (>= 10 values) if len(scores) < 10: - # Not enough history: return score only (conservative fallback) - return float(curr_score) + # Not enough history: simple linear composite (score - loss) + return float(curr_score) - float(curr_loss) if curr_loss is not None else float(curr_score) score_mean, score_std = np.mean(scores), np.std(scores) loss_mean, loss_std = np.mean(losses), np.std(losses) diff --git a/src/search_space.py b/src/search_space.py index f429611..4f80448 100644 --- a/src/search_space.py +++ b/src/search_space.py @@ -5,22 +5,16 @@ logger = logging.getLogger(__name__) from fastapi import HTTPException -import optuna -from optuna.distributions import CategoricalDistribution from optuna.trial import TrialState from src.db_manager import get_db_session -from src.schema import SystemConfiguration +from src.schema import SystemConfiguration, CompactedPacket from src.hpo_config import load_hpo_config -from src.hpo_coordinator import mark_review_applied # Default search space definition DEFAULT_SEARCH_SPACE = { "learning_rate": {"min": 1e-5, "max": 1e-2, "type": "float_log"}, - "batch_size": {"options": [2, 4, 8, 16, 32, 64], "active": [2, 4, 8, 16, 32, 64], "type": "categorical"}, - "resolution": {"options": [256, 512, 1024], "active": [256, 512, 1024], "type": "categorical"}, - "model_capacity": {"options": ["narrow", "wide"], "active": ["narrow", "wide"], "type": "categorical"}, - "loss_weight_ratio": {"min": 0.0, "max": 1.0, "type": "float"}, + "batch_size": {"options": [16, 32, 64, 128], "active": [16, 32, 64, 128], "type": "categorical"}, } @@ -40,7 +34,9 @@ def load_search_space(study_name: Optional[str] = None) -> Dict[str, Any]: from .settings import settings if not study_name: study_name = settings.study_name - + if not study_name or not study_name.strip(): + return DEFAULT_SEARCH_SPACE.copy() + try: with get_db_session() as session: row = session.query(SystemConfiguration).filter_by( @@ -64,6 +60,8 @@ def save_search_space(space: Dict[str, Any], study_name: Optional[str] = None): from .settings import settings if not study_name: study_name = settings.study_name + if not study_name or not study_name.strip(): + raise ValueError("study_name cannot be empty.") try: with get_db_session() as session: row = session.query(SystemConfiguration).filter_by( @@ -82,6 +80,13 @@ def save_search_space(space: Dict[str, Any], study_name: Optional[str] = None): except Exception as e: print(f"Error saving search space to DB: {e}") + # Bust cached study packets β€” search space changes invalidate analytics + try: + with get_db_session() as session: + session.query(CompactedPacket).filter_by(study_name=study_name).delete() + except Exception: + pass + def _expected_search_params(space: Dict[str, Any]) -> List[str]: return [k for k, v in space.items() if isinstance(v, dict) and v.get("type")] @@ -103,14 +108,22 @@ def _worker_ready_params(trial, space: Dict[str, Any]) -> Dict[str, Any]: def _cleanup_stuck_running_trials(study, space: Dict[str, Any]) -> None: - """Fail RUNNING trials that never received a full parameter set (crashed mid-suggest).""" + """Fail RUNNING trials that never received a full parameter set (crashed mid-suggest). + + Trials that started less than LEASE_TTL_SECONDS ago are skipped to avoid a race + where worker A's trial was just created by ``study.ask()`` but hasn't yet had + its parameters written when worker B's ``/api/suggest_trial`` triggers cleanup. + """ + from .leases import LEASE_TTL_SECONDS + from datetime import datetime, timedelta, timezone + cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(seconds=LEASE_TTL_SECONDS) for t in list(study.trials): - if t.state == TrialState.RUNNING and not _trial_has_full_params( - _worker_ready_params(t, space), space - ): - print( - f"Failing stuck RUNNING trial #{t.number} (incomplete params: {list(t.params.keys())})" - ) + if t.state != TrialState.RUNNING: + continue + if not _trial_has_full_params(_worker_ready_params(t, space), space): + if t.datetime_start is not None and t.datetime_start.replace(tzinfo=None) > cutoff: + continue + print(f"Failing stuck RUNNING trial #{t.number} (incomplete params: {list(t.params.keys())})") try: study.tell(t.number, state=TrialState.FAIL) except Exception as exc: @@ -152,21 +165,6 @@ def _finalize_trial_params(params: Dict[str, Any], space: Dict[str, Any]) -> Dic return out -def _persist_fixed_categorical_params(study, trial, space: Dict[str, Any]) -> None: - """Write single-active categoricals into Optuna storage so dashboards see them.""" - fixed = _fixed_categorical_params(space) - for param, value in fixed.items(): - if param in trial.params: - continue - cfg = space.get(param, {}) - choices = tuple(_study_categorical_choices(study, param, cfg)) - dist = CategoricalDistribution(choices=choices) - # Optuna RDB storage expects internal index (0..n-1), not external choice value. - internal = float(dist.to_internal_repr(value)) - study._storage.set_trial_param(trial._trial_id, param, internal, dist) - study._storage.set_trial_user_attr(trial._trial_id, param, value) - - def _enqueue_single_active_categoricals(study, space: Dict[str, Any]) -> None: """Optional hint for Optuna; workers still receive fixed values via _finalize_trial_params.""" fixed = _fixed_categorical_params(space) @@ -185,7 +183,7 @@ def _suggest_categorical_compatible(study, trial, param: str, cfg: Dict[str, Any trial.suggest_categorical(param, choices) -def _validate_params_against_active(params: Dict[str, Any], space: Dict[str, Any]) -> List[str]: +def _validate_categorical_against_active(params: Dict[str, Any], space: Dict[str, Any]) -> List[str]: """Return list of human-readable violations when TPE samples outside active constraints.""" errors = [] for param, cfg in space.items(): @@ -210,12 +208,14 @@ def suggest_params_from_space(study, trial, space: Dict[str, Any]) -> Dict[str, trial.suggest_float(param, float(cfg["min"]), float(cfg["max"]), log=True) elif ptype == "float": trial.suggest_float(param, float(cfg["min"]), float(cfg["max"])) + elif ptype == "int": + trial.suggest_int(param, int(cfg["min"]), int(cfg["max"])) elif ptype == "categorical": _suggest_categorical_compatible(study, trial, param, cfg) else: raise ValueError(f"Unsupported parameter type '{ptype}' for '{param}'.") params = _finalize_trial_params(trial.params, space) - violations = _validate_params_against_active(params, space) + violations = _validate_categorical_against_active(params, space) if violations: raise ValueError( "Sampled parameters outside active search bounds: " @@ -246,7 +246,6 @@ def _apply_search_space_patch(patch: Dict[str, Any], space: Dict[str, Any], stud if "max" in new_val: cfg["max"] = float(new_val["max"]) save_search_space(space, study_name) - mark_review_applied(study_name) return "Search space updated." @@ -301,40 +300,26 @@ def handle_api_update_search_space(space: Dict[str, Any], study_name: Optional[s detail=f"Hyperparameter '{param_name}' is not recognized in the search space." ) - # Save to pending_search_space in DB - try: - with get_db_session() as session: - # Check if there is already a pending configuration - pending_row = session.query(SystemConfiguration).filter_by( - study_name=study_name, config_key="pending_search_space" - ).first() - - if validated_proposals: - if pending_row: - # Merge with existing pending configuration - existing_pending = json.loads(pending_row.config_value) - for key, val in validated_proposals.items(): - if key in existing_pending: - existing_pending[key].update(val) - else: - existing_pending[key] = val - pending_row.config_value = json.dumps(existing_pending) - pending_row.version += 1 - else: - session.add(SystemConfiguration( - study_name=study_name, - config_key="pending_search_space", - config_value=json.dumps(validated_proposals), - version=1 - )) - else: - # If proposals are empty (reverted to current), delete pending row if it exists - if pending_row: - session.delete(pending_row) - session.commit() - except HTTPException as he: - raise he - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to save pending changes: {str(e)}") - - return {"success": True, "space": current, "pending": validated_proposals} + # Apply validated proposals directly to active search space + if validated_proposals: + try: + for param_name, proposal in validated_proposals.items(): + current[param_name].update(proposal) + # Validate min < max for all numeric params after merge + for param_name, cfg in current.items(): + if not isinstance(cfg, dict) or cfg.get("type") not in ("float", "float_log", "int"): + continue + lo = cfg.get("min") + hi = cfg.get("max") + if lo is not None and hi is not None and float(lo) >= float(hi): + raise HTTPException( + status_code=400, + detail=f"Invalid bounds for '{param_name}': min ({lo}) must be strictly less than max ({hi}).", + ) + save_search_space(current, study_name) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to update search space: {str(e)}") + + return {"success": True, "space": current} diff --git a/src/suggest.py b/src/suggest.py index 0e2d150..d4baec5 100644 --- a/src/suggest.py +++ b/src/suggest.py @@ -1,8 +1,7 @@ import time import json -from datetime import datetime, timedelta -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, Any from pydantic import BaseModel from fastapi import HTTPException import optuna @@ -11,7 +10,6 @@ from sqlalchemy import text from src.db_manager import get_db_session, DATABASE_URL -from src.schema import AgentReasoningLog, SuggestMetric, TrialLease from src.hpo_config import load_hpo_config, normalize_trial_params from src.search_space import ( load_search_space, @@ -20,22 +18,30 @@ _worker_ready_params, _enqueue_single_active_categoricals, suggest_params_from_space, - _persist_fixed_categorical_params, _finalize_trial_params, _expected_search_params, ) -from src.leases import _reap_expired_leases, _try_claim_lease +from src.leases import _reap_expired_leases, _try_claim_lease, delete_lease_by_trial_id def get_or_create_study(study_name: str): try: return optuna.load_study(study_name=study_name, storage=DATABASE_URL) except KeyError: - print(f"Study '{study_name}' not found. Initializing new multi-objective study...") + print(f"Study '{study_name}' not found. Checking stored config for directions...") + directions = None + try: + cfg = load_hpo_config(study_name) + directions = cfg.get("directions") + except Exception: + pass + if not directions: + directions = ["minimize", "maximize"] + print("No stored directions found. Defaulting to multi-objective (minimize, maximize).") return optuna.create_study( study_name=study_name, storage=DATABASE_URL, - directions=["minimize", "maximize"], + directions=directions, load_if_exists=True ) @@ -83,14 +89,25 @@ def _repair_categorical_param_indices(session: Session, study_name: str) -> int: try: dist_data = json.loads(dist_json) choices = dist_data["attributes"]["choices"] - idx = int(float(param_value)) - if 0 <= idx < len(choices): - continue - external = float(param_value) - if external not in choices and int(external) not in choices: + # Determine if param_value is stored as an internal index or an external value. + # If it can be parsed as a float, check whether it matches a choice BY VALUE first. + try: + external = float(param_value) + except (ValueError, TypeError): + external = param_value + # If the stored value equals one of the choices literally, treat it as an + # external value and convert to its internal index. Otherwise, assume it is + # already a valid internal index. + if external in choices: + internal = choices.index(external) + elif isinstance(external, float) and int(external) in choices: + internal = choices.index(int(external)) + else: + # Already an internal index or unrecognised; skip repair. + idx = int(float(param_value)) + if 0 <= idx < len(choices): + continue continue - value = int(external) if int(external) in choices else external - internal = choices.index(value) session.execute( text( "UPDATE trial_params SET param_value = :v WHERE param_id = :id" @@ -108,13 +125,19 @@ def handle_api_suggest_trial(req: SuggestRequest): trial = None start_time = time.time() try: + # Load the study first to verify it exists. This raises a clean 404 if the study is uninitialized. + study = load_study(req.study_name) + # C2 Fix: thread session explicitly with get_db_session() as session: - repaired = _repair_categorical_param_indices(session, req.study_name) - if repaired: - print(f"Repaired {repaired} corrupt categorical param row(s) in study '{req.study_name}'.") - - study = load_study(req.study_name) + try: + repaired = _repair_categorical_param_indices(session, req.study_name) + if repaired: + print(f"Repaired {repaired} corrupt categorical param row(s) in study '{req.study_name}'.") + except Exception as e: + # Fallback: if there is a database issue during repair, do not crash suggestion + import logging + logging.getLogger(__name__).warning(f"Failed to repair categorical param indices: {e}") space = load_search_space(req.study_name) hpo_config = load_hpo_config(req.study_name) _cleanup_stuck_running_trials(study, space) @@ -151,44 +174,53 @@ def handle_api_suggest_trial(req: SuggestRequest): if not trial: source = "new_trial" _enqueue_single_active_categoricals(study, space) - trial = study.ask() - trial_id = trial._trial_id - # Lease newly created trial immediately (fresh trial_id, so this always wins). - with get_db_session() as session: - _try_claim_lease(session, req.study_name, trial_id, req.worker_id or "anonymous") + # Optuna does not support narrowing categorical distributions after the first + # trial. TPE always samples from the full historical choice set. When a user + # deactivates a choice (dashboard Settings), we must handle TPE sampling an + # inactive value. We retry up to 20 times, failing each attempt in Optuna. + # This tells TPE that the deactivated region is unproductive β€” a best-effort + # heuristic given Optuna's static-distribution design. On the final attempt, + # we substitute a random active choice instead of 500-ing the worker. + MAX_RESAMPLE_ATTEMPTS = 20 + for attempt in range(MAX_RESAMPLE_ATTEMPTS): + trial = study.ask() + trial_id = trial._trial_id - try: - params = suggest_params_from_space(study, trial, space) - _persist_fixed_categorical_params(study, trial, space) - except Exception: - try: - study.tell(trial.number, state=TrialState.FAIL) - except Exception: - pass with get_db_session() as session: - delete_lease_by_trial_id(session, trial_id) - session.commit() - raise - - with get_db_session() as session: - existing = ( - session.query(AgentReasoningLog).filter_by(trial_id=trial_id).first() - ) - if not existing: - est_imp = req.estimated_score_improvement - session.add( - AgentReasoningLog( - trial_id=trial_id, - study_name=req.study_name, - model_version=req.agent_model or "optuna-tpe", - prompt_strategy=req.prompt_strategy or "tpe_sampler", - predicted_outcome_rationale=req.reasoning or "Autonomous worker suggestion request.", - estimated_score_improvement=float(est_imp if est_imp is not None else 0.0), - ) - ) - session.commit() + claimed = _try_claim_lease(session, req.study_name, trial_id, req.worker_id or "anonymous") + assert claimed, f"Lease claim failed for trial {trial_id}" + try: + params = suggest_params_from_space(study, trial, space) + break + except ValueError: + # TPE sampled an inactive categorical + if attempt < MAX_RESAMPLE_ATTEMPTS - 1: + # Fail the trial and retry with a fresh one + try: + study.tell(trial.number, state=TrialState.FAIL) + except Exception: + pass + with get_db_session() as session: + delete_lease_by_trial_id(session, trial_id) + session.commit() + continue + # Final attempt β€” fallback: pick random active values for violated categoricals + import random + fixed = _finalize_trial_params(dict(trial.params), space) + for param, cfg in space.items(): + if isinstance(cfg, dict) and cfg.get("type") == "categorical": + active = list(cfg.get("active") or cfg.get("options") or []) + if active and fixed.get(param) not in active: + fallback_choice = random.choice(active) + fixed[param] = fallback_choice + print( + f"WARNING: Trial {trial.number} β€” {param} " + f"outside active {active}. Falling back to {fallback_choice!r}." + ) + params = fixed + params = _finalize_trial_params(params, space) missing = [p for p in _expected_search_params(space) if p not in params] if missing: @@ -204,19 +236,8 @@ def handle_api_suggest_trial(req: SuggestRequest): detail=f"Trial {trial.number} missing parameters {missing}. Expected {_expected_search_params(space)}.", ) - # Log SuggestMetric end_time = time.time() latency_ms = (end_time - start_time) * 1000 - try: - with get_db_session() as session: - session.add(SuggestMetric( - study_name=req.study_name, - latency_ms=latency_ms, - source=source - )) - session.commit() - except Exception as metric_err: - print(f"Error logging suggest metric: {metric_err}") config = hpo_config return { @@ -233,29 +254,3 @@ def handle_api_suggest_trial(req: SuggestRequest): raise HTTPException(status_code=500, detail=str(e)) -def _enqueue_manual_trial(study, manual: Dict[str, Any], space: Dict[str, Any], summary: str = "AI Coordinator suggested manual trial.") -> str: - """Enqueue one coordinator-proposed trial; TPE still drives every other suggest.""" - config = load_hpo_config(study.study_name) - params = normalize_trial_params(dict(manual), config) - missing = [p for p in _expected_search_params(space) if p not in params] - if missing: - return f"Manual trial missing params {missing}; not enqueued." - try: - study.enqueue_trial(params) - waiting = [t for t in study.trials if t.state == TrialState.WAITING] - if waiting: - new_trial = max(waiting, key=lambda t: t._trial_id) - with get_db_session() as session: - session.add( - AgentReasoningLog( - trial_id=new_trial._trial_id, - study_name=study.study_name, - model_version="coordinator", - prompt_strategy="coordinator_review", - predicted_outcome_rationale=summary, - estimated_score_improvement=0.0 - ) - ) - return f"Enqueued manual trial: {params}." - except Exception as e: - return f"Could not enqueue manual trial: {e}" From c976a3691211a37595f0221efb515f326da26e17 Mon Sep 17 00:00:00 2001 From: Ishaan Date: Tue, 30 Jun 2026 10:37:11 -0400 Subject: [PATCH 5/8] simplify dashboard, read-only functionality now --- web/index.html | 117 +++--------- web/js/chart.min.js | 20 ++ web/js/charts_modal.js | 8 +- web/js/charts_pareto.js | 4 +- web/js/charts_pathways_math.js | 4 +- web/js/charts_pathways_render.js | 4 +- web/js/charts_timeline.js | 8 +- web/js/charts_utils.js | 14 +- web/js/export.js | 10 +- web/js/health.js | 2 +- web/js/main.js | 53 ++++-- web/js/modal.js | 185 ------------------ web/js/onboarding.js | 313 ------------------------------- web/js/settings.js | 101 +--------- web/js/table_filters.js | 8 +- web/js/table_infra.js | 6 +- web/js/table_render.js | 48 +++-- web/js/theme.js | 2 +- web/js/utils.js | 9 + web/js/worker_setup.js | 231 ----------------------- web/styles.css | 2 +- 21 files changed, 153 insertions(+), 996 deletions(-) create mode 100644 web/js/chart.min.js delete mode 100644 web/js/modal.js delete mode 100644 web/js/onboarding.js delete mode 100644 web/js/worker_setup.js diff --git a/web/index.html b/web/index.html index 3b663e8..6a2c48e 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ Pathfinder - +
@@ -16,7 +16,7 @@
- + - + +
@@ -80,7 +90,7 @@

Dashboard

How to run a training worker:

  1. Configure your search space and evaluation protocol in the Search Space tab.
  2. -
  3. Go to the Worker Setup tab to copy the worker integration script (Custom or Colab).
  4. +
  5. Set HPO_BROKER_URL and HPO_STUDY_NAME on your training machine. See docs/INTEGRATION.md for details.
  6. Run the worker script on your GPU machine. The worker will connect to this broker, request trials, and report epoch metrics.
@@ -111,7 +121,7 @@

Show audit history
- +

@@ -123,6 +133,14 @@

Active Search Space
Loading space bounds...
+
+ Remote Worker Setup +
+

export HPO_BROKER_URL=http://your-broker:8000

+

export HPO_STUDY_NAME=your_study_name

+

See docs/INTEGRATION.md for full worker setup instructions.

+
+
@@ -155,7 +173,7 @@

- +
TrialStateScore (train)Score (eval)LossParameters...
TrialStateScore (train)Score (eval)LossParameters...
Loading study results…
@@ -182,13 +200,6 @@

-
@@ -231,42 +242,6 @@

-
-
- -
-
-
-
Remote GPU?

export HPO_SECRET_TOKEN=… then python broker.py --daemon --tunnel.

Colab must send that token as X-HPO-Token on the worker download.

-
Local only?

python broker.py --daemon β€” no token, no tunnel.

-
-
-
Custom Training Script Integration
-
-
-

-                                
Customize worker_minimal.py and return (score, loss). The client wrapper handles ASHA early-stopping, CUDA OOM detection, and telemetry.
-
-
- - -
-
-

- + diff --git a/web/js/chart.min.js b/web/js/chart.min.js new file mode 100644 index 0000000..9a07c2f --- /dev/null +++ b/web/js/chart.min.js @@ -0,0 +1,20 @@ +/** + * Skipped minification because the original files appears to be already minified. + * Original file: /npm/chart.js@4.4.0/dist/chart.umd.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +/*! + * Chart.js v4.4.0 + * https://www.chartjs.org + * (c) 2023 Chart.js Contributors + * Released under the MIT License + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Go},get Decimation(){return Qo},get Filler(){return ma},get Legend(){return ya},get SubTitle(){return ka},get Title(){return Ma},get Tooltip(){return Ba}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)t-e)).pop(),e}function N(t){return!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;sl&&h=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return nt[s][e]et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;ss&&t[n-1]>i;)n--;return s>0||n{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(ot.forEach((e=>{delete t[e]})),delete t._chartjs)}function lt(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const ht="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function ct(t,e){let i=[],s=!1;return function(...n){i=n,s||(s=!0,ht.call(window,(()=>{s=!1,t.apply(e,i)})))}}function dt(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const ut=t=>"start"===t?"left":"end"===t?"right":"center",ft=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,gt=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function pt(t,e,i){const s=e.length;let n=0,o=s;if(t._sorted){const{iScale:a,_parsed:r}=t,l=a.axis,{min:h,max:c,minDefined:d,maxDefined:u}=a.getUserBounds();d&&(n=J(Math.min(it(r,l,h).lo,i?s:it(e,l,a.getPixelForValue(h)).lo),0,s-1)),o=u?J(Math.max(it(r,a.axis,c,!0).hi+1,i?0:it(e,l,a.getPixelForValue(c),!0).hi+1),n,s)-n:s-n}return{start:n,count:o}}function mt(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}class bt{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=ht.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}var xt=new bt; +/*! + * @kurkle/color v0.3.2 + * https://github.com/kurkle/color#readme + * (c) 2023 Jukka Kurkela + * Released under the MIT License + */function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=Ft(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function qt(t,e){return t?Object.assign(e||{},t):t}function Kt(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Mt(t[3]))):(e=qt(t,{r:0,g:0,b:0,a:1})).a=Mt(e.a),e}function Gt(t){return"r"===t.charAt(0)?function(t){const e=$t.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?vt(t):yt(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?vt(i):yt(i,0,255)),s=255&(e[4]?vt(s):yt(s,0,255)),n=255&(e[6]?vt(n):yt(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):Bt(t)}class Zt{constructor(t){if(t instanceof Zt)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=Kt(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*St[s[1]],g:255&17*St[s[2]],b:255&17*St[s[3]],a:5===o?17*St[s[4]]:255}:7!==o&&9!==o||(n={r:St[s[1]]<<4|St[s[2]],g:St[s[3]]<<4|St[s[4]],b:St[s[5]]<<4|St[s[6]],a:9===o?St[s[7]]<<4|St[s[8]]:255})),i=n||jt(t)||Gt(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=qt(this._rgb);return t&&(t.a=wt(t.a)),t}set rgb(t){this._rgb=Kt(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${wt(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?At(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=It(t),i=e[0],s=kt(e[1]),n=kt(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${wt(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=Ut(wt(t.r)),n=Ut(wt(t.g)),o=Ut(wt(t.b));return{r:Mt(Yt(s+i*(Ut(wt(e.r))-s))),g:Mt(Yt(n+i*(Ut(wt(e.g))-n))),b:Mt(Yt(o+i*(Ut(wt(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new Zt(this.rgb)}alpha(t){return this._rgb.a=Mt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=_t(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Xt(this._rgb,2,t),this}darken(t){return Xt(this._rgb,2,-t),this}saturate(t){return Xt(this._rgb,1,t),this}desaturate(t){return Xt(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=It(t);i[0]=Vt(i[0]+e),i=Ft(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function Jt(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function Qt(t){return Jt(t)?t:new Zt(t)}function te(t){return Jt(t)?t:new Zt(t).saturate(.5).darken(.1).hexString()}const ee=["x","y","borderWidth","radius","tension"],ie=["color","borderColor","backgroundColor"];const se=new Map;function ne(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=se.get(i);return s||(s=new Intl.NumberFormat(t,e),se.set(i,s)),s}(e,i).format(t)}const oe={values:t=>n(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=z(Math.abs(o)),r=isNaN(a)?1:Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),ne(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=i[e].significand||t/Math.pow(10,Math.floor(z(t)));return[1,2,3,5,10,15].includes(s)||e>.8*i.length?oe.numeric.call(this,t,e,i):""}};var ae={formatters:oe};const re=Object.create(null),le=Object.create(null);function he(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;et.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>te(e.backgroundColor),this.hoverBorderColor=(t,e)=>te(e.borderColor),this.hoverColor=(t,e)=>te(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return ce(this,t,e)}get(t){return he(this,t)}describe(t,e){return ce(le,t,e)}override(t,e){return ce(re,t,e)}route(t,e,i,s){const n=he(this,t),a=he(this,i),r="_"+e;Object.defineProperties(n,{[r]:{value:n[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[r],e=a[s];return o(t)?Object.assign({},e,t):l(t,e)},set(t){this[r]=t}}})}apply(t){t.forEach((t=>t(this)))}}var ue=new de({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:ie},numbers:{type:"number",properties:ee}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ae.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function fe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ge(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function pe(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const me=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function be(t,e){return me(t).getPropertyValue(e)}const xe=["top","right","bottom","left"];function _e(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=xe[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}const ye=(t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot);function ve(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=me(i),o="border-box"===n.boxSizing,a=_e(n,"padding"),r=_e(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(ye(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const Me=t=>Math.round(10*t)/10;function we(t,e,i,s){const n=me(t),o=_e(n,"margin"),a=pe(n.maxWidth,t,"clientWidth")||T,r=pe(n.maxHeight,t,"clientHeight")||T,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=ge(t);if(o){const t=o.getBoundingClientRect(),a=me(o),r=_e(a,"border","width"),l=_e(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=pe(a.maxWidth,o,"clientWidth"),n=pe(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||T,maxHeight:n||T}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=_e(n,"border","width"),e=_e(n,"padding");h-=e.width+t.width,c-=e.height+t.height}h=Math.max(0,h-o.width),c=Math.max(0,s?h/s:c-o.height),h=Me(Math.min(h,a,l.maxWidth)),c=Me(Math.min(c,r,l.maxHeight)),h&&!c&&(c=Me(h/2));return(void 0!==e||void 0!==i)&&s&&l.height&&c>l.height&&(c=l.height,h=Me(Math.floor(c*s))),{width:h,height:c}}function ke(t,e,i){const s=e||1,n=Math.floor(t.height*s),o=Math.floor(t.width*s);t.height=Math.floor(t.height),t.width=Math.floor(t.width);const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const Se=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};window.addEventListener("test",null,e),window.removeEventListener("test",null,e)}catch(t){}return t}();function Pe(t,e){const i=be(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function De(t){return!t||s(t.size)||s(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Ce(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function Oe(t,e,i,s){let o=(s=s||{}).data=s.data||{},a=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},a=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let r=0;const l=i.length;let h,c,d,u,f;for(h=0;hi.length){for(h=0;h0&&t.stroke()}}function Re(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.xe.top-i&&t.y0&&""!==r.strokeColor;let c,d;for(t.save(),t.font=a.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]),s(e.rotation)||t.rotate(e.rotation),e.color&&(t.fillStyle=e.color),e.textAlign&&(t.textAlign=e.textAlign),e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,r),c=0;ct[0])){const o=i||t;void 0===s&&(s=ti("_fallback",t));const a={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:s,_getTarget:n,override:i=>je([i,...t],e,o,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>qe(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=ti(Ue(o,t),i),void 0!==n)return Xe(t,n)?Je(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(t,e,i,s){const a={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ye(t,s),setContext:e=>$e(t,e,i,s),override:n=>$e(t.override(n),e,i,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>qe(t,e,(()=>function(t,e,i){const{_proxy:s,_context:a,_subProxy:r,_descriptors:l}=t;let h=s[e];S(h)&&l.isScriptable(e)&&(h=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t);let l=e(o,a||s);r.delete(t),Xe(t,l)&&(l=Je(n._scopes,n,t,l));return l}(e,h,t,i));n(h)&&h.length&&(h=function(t,e,i,s){const{_proxy:n,_context:a,_subProxy:r,_descriptors:l}=i;if(void 0!==a.index&&s(t))return e[a.index%e.length];if(o(e[0])){const i=e,s=n._scopes.filter((t=>t!==i));e=[];for(const o of i){const i=Je(s,n,t,o);e.push($e(i,a,r&&r[t],l))}}return e}(e,h,t,l.isIndexable));Xe(e,h)&&(h=$e(h,a,r&&r[e],l));return h}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Ye(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:S(i)?i:()=>i,isIndexable:S(s)?s:()=>s}}const Ue=(t,e)=>t?t+w(e):e,Xe=(t,e)=>o(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function qe(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e))return t[e];const s=i();return t[e]=s,s}function Ke(t,e,i){return S(t)?t(e,i):t}const Ge=(t,e)=>!0===t?e:"string"==typeof t?M(e,t):void 0;function Ze(t,e,i,s,n){for(const o of e){const e=Ge(i,o);if(e){t.add(e);const o=Ke(e._fallback,i,n);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}return!1}function Je(t,e,i,s){const a=e._rootScopes,r=Ke(e._fallback,i,s),l=[...t,...a],h=new Set;h.add(s);let c=Qe(h,l,i,r||i,s);return null!==c&&((void 0===r||r===i||(c=Qe(h,l,r,c,s),null!==c))&&je(Array.from(h),[""],a,r,(()=>function(t,e,i){const s=t._getTarget();e in s||(s[e]={});const a=s[e];if(n(a)&&o(i))return i;return a||{}}(e,i,s))))}function Qe(t,e,i,s,n){for(;i;)i=Ze(t,e,i,s,n);return i}function ti(t,e){for(const i of e){if(!i)continue;const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function ii(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function ai(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=q(o,n),l=q(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function ri(t,e="x"){const i=oi(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=ni(t,0);for(a=0;a!t.skip))),"monotone"===e.cubicInterpolationMode)ri(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*O/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*O/i)+1,fi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*E),easeOutSine:t=>Math.sin(t*E),easeInOutSine:t=>-.5*(Math.cos(C*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){const e=.1125;return ci(t)?t:t<.5?.5*di(2*t,e,.45):.5+.5*ui(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-fi.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*fi.easeInBounce(2*t):.5*fi.easeOutBounce(2*t-1)+.5};function gi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function mi(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=gi(t,n,i),r=gi(n,o,i),l=gi(o,e,i),h=gi(a,r,i),c=gi(r,l,i);return gi(h,c,i)}const bi=/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/,xi=/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/;function _i(t,e){const i=(""+t).match(bi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}const yi=t=>+t||0;function vi(t,e){const i={},s=o(e),n=s?Object.keys(e):e,a=o(t)?s?i=>l(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of n)i[t]=yi(a(t));return i}function Mi(t){return vi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return vi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function ki(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function Si(t,e){t=t||{},e=e||ue.font;let i=l(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=l(t.style,e.style);s&&!(""+s).match(xi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const n={family:l(t.family,e.family),lineHeight:_i(l(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:l(t.weight,e.weight),string:""};return n.string=De(n),n}function Pi(t,e,i,s){let o,a,r,l=!0;for(o=0,a=t.length;oi&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function Ci(t,e){return Object.assign(Object.create(t),e)}function Oi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function Ai(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Ti(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Li(t){return"angle"===t?{between:Z,compare:K,normalize:G}:{between:tt,compare:(t,e)=>t-e,normalize:t=>t}}function Ei({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function Ri(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=Li(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=Li(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;hx||l(n,b,p)&&0!==r(n,b),v=()=>!x||0===r(o,p)||l(o,b,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==b&&(x=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(Ei({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,b=p));return null!==_&&g.push(Ei({start:_,end:d,loop:u,count:a,style:f})),g}function Ii(t,e){const i=[],s=t.segments;for(let n=0;nn&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return Fi(t,[{start:a,end:r,loop:o}],i,e);return Fi(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r{t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Xi={evaluateInteractionItems:Hi,modes:{index(t,e,i,s){const n=ve(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;tji(t,ve(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return Yi(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>Ui(t,ve(e,t),"x",i.intersect,s),y:(t,e,i,s)=>Ui(t,ve(e,t),"y",i.intersect,s)}};const qi=["left","top","right","bottom"];function Ki(t,e){return t.filter((t=>t.pos===e))}function Gi(t,e){return t.filter((t=>-1===qi.indexOf(t.pos)&&t.box.axis===e))}function Zi(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function Ji(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!qi.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function ss(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;ot.box.fullSize)),!0),s=Zi(Ki(e,"left"),!0),n=Zi(Ki(e,"right")),o=Zi(Ki(e,"top"),!0),a=Zi(Ki(e,"bottom")),r=Gi(e,"x"),l=Gi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:Ki(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;u(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),f=Object.assign({},n);ts(f,ki(s));const g=Object.assign({maxPadding:f,w:o,h:a,x:n.left,y:n.top},n),p=Ji(l.concat(h),d);ss(r.fullSize,g,d,p),ss(l,g,d,p),ss(h,g,d,p)&&ss(l,g,d,p),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(g),os(r.leftAndTop,g,d,p),g.x+=g.w,g.y+=g.h,os(r.rightAndBottom,g,d,p),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},u(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})}))}};class rs{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class ls extends rs{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const hs="$chartjs",cs={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},ds=t=>null===t||""===t;const us=!!Se&&{passive:!0};function fs(t,e,i){t.canvas.removeEventListener(e,i,us)}function gs(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function ps(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.addedNodes,s),e=e&&!gs(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function ms(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.removedNodes,s),e=e&&!gs(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const bs=new Map;let xs=0;function _s(){const t=window.devicePixelRatio;t!==xs&&(xs=t,bs.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function ys(t,e,i){const s=t.canvas,n=s&&ge(s);if(!n)return;const o=ct(((t,e)=>{const s=n.clientWidth;i(t,e),s{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||o(i,s)}));return a.observe(n),function(t,e){bs.size||window.addEventListener("resize",_s),bs.set(t,e)}(t,o),a}function vs(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){bs.delete(t),bs.size||window.removeEventListener("resize",_s)}(t)}function Ms(t,e,i){const s=t.canvas,n=ct((e=>{null!==t.ctx&&i(function(t,e){const i=cs[t.type]||t.type,{x:s,y:n}=ve(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t);return function(t,e,i){t.addEventListener(e,i,us)}(s,e,n),n}class ws extends rs{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t[hs]={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",ds(n)){const e=Pe(t,"width");void 0!==e&&(t.width=e)}if(ds(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=Pe(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[hs])return!1;const i=e[hs].initial;["height","width"].forEach((t=>{const n=i[t];s(n)?e.removeAttribute(t):e.setAttribute(t,n)}));const n=i.style||{};return Object.keys(n).forEach((t=>{e.style[t]=n[t]})),e.width=e.width,delete e[hs],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:ps,detach:ms,resize:ys}[e]||Ms;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:vs,detach:vs,resize:vs}[e]||fs)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return we(t,e,i,s)}isAttached(t){const e=ge(t);return!(!e||!e.isConnected)}}function ks(t){return!fe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?ls:ws}var Ss=Object.freeze({__proto__:null,BasePlatform:rs,BasicPlatform:ls,DomPlatform:ws,_detectPlatform:ks});const Ps="transparent",Ds={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=Qt(t||Ps),n=s.valid&&Qt(e||Ps);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class Cs{constructor(t,e,i,s){const n=e[i];s=Pi([t.to,s,n,t.from]);const o=Pi([t.from,n,s]);this._active=!0,this._fn=t.fn||Ds[t.type||typeof o],this._easing=fi[t.easing]||fi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=Pi([t.to,e,s,t.from]),this._from=Pi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t{const a=t[s];if(!o(a))return;const r={};for(const t of e)r[t]=a[t];(n(a.properties)&&a.properties||[s]).forEach((t=>{t!==s&&i.has(t)||i.set(t,r)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new Cs(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(xt.add(this._chart,i),!0):void 0}}function As(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function Ts(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n0||!i&&e<0)return n.index}return null}function zs(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;ti[t].axis===e)).shift()}function Vs(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Bs=t=>"reset"===t||"none"===t,Ws=(t,e)=>e?t:Object.assign({},t);class Ns{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Es(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Vs(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=l(i.xAxisID,Fs(t,"x")),o=e.yAxisID=l(i.yAxisID,Fs(t,"y")),a=e.rAxisID=l(i.rAxisID,Fs(t,"r")),r=e.indexAxis,h=e.iAxisID=s(r,n,o,a),c=e.vAxisID=s(r,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(h),e.vScale=this.getScaleForId(c)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&rt(this._data,this),t._stacked&&Vs(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(o(e))this._data=function(t){const e=Object.keys(t),i=new Array(e.length);let s,n,o;for(s=0,n=e.length;s0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=s,i._sorted=!0,d=s;else{d=n(s[t])?this.parseArrayData(i,s,t,e):o(s[t])?this.parseObjectData(i,s,t,e):this.parsePrimitiveData(i,s,t,e);const a=()=>null===c[l]||f&&c[l]t&&!e.hidden&&e._stacked&&{keys:Ts(i,!0),values:null})(e,i,this.chart),h={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:c,max:d}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(r);let u,f;function g(){f=s[u];const e=f[r.axis];return!a(f[t.axis])||c>e||d=0;--u)if(!g()){this.updateRangeFromParsed(h,t,f,l);break}return h}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,o;for(s=0,n=e.length;s=0&&tthis.getContext(i,s,e)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Ws(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new Os(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Bs(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),s=this._sharedOptions,n=this.getSharedOptions(i),o=this.includeOptions(e,n)||n!==s;return this.updateSharedOptions(n,e,i),{sharedOptions:n,includeOptions:o}}updateElement(t,e,i,s){Bs(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Bs(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}function js(t,e){const i=t.options.ticks,n=function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),o=Math.min(i.maxTicksLimit||n,n),a=i.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;io)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;nn)return e}return Math.max(n,1)}(a,e,o);if(r>0){let t,i;const n=r>1?Math.round((h-l)/(r-1)):null;for($s(e,c,d,s(n)?0:l-n,l),t=0,i=r-1;t"top"===e||"left"===e?t[e]+i:t[e]-i,Us=(t,e)=>Math.min(e||t,t);function Xs(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;oa+r)))return h}function Ks(t){return t.drawTicks?t.tickLength:0}function Gs(t,e){if(!t.display)return 0;const i=Si(t.font,e),s=ki(t.padding);return(n(t.text)?t.text.length:1)*i.lineHeight+s.height}function Zs(t,e,i){let s=ut(t);return(i&&"right"!==e||!i&&"right"===e)&&(s=(t=>"left"===t?"right":"right"===t?"left":t)(s)),s}class Js extends Hs{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=r(t,Number.POSITIVE_INFINITY),e=r(e,Number.NEGATIVE_INFINITY),i=r(i,Number.POSITIVE_INFINITY),s=r(s,Number.NEGATIVE_INFINITY),{min:r(t,i),max:r(e,s),minDefined:a(t),maxDefined:a(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const a=this.getMatchingVisibleMetas();for(let r=0,l=a.length;rs?s:i,s=n&&i>s?i:s,{min:r(i,r(s,i)),max:r(s,r(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Di(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=J(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Ks(t.grid)-e.padding-Gs(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=Y(Math.min(Math.asin(J((h.highest.height+6)/o,-1,1)),Math.asin(J(a/r,-1,1))-Math.asin(J(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){d(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){d(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=Gs(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Ks(n)+o):(t.height=this.maxHeight,t.width=Ks(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=$(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){d(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,i;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,i=t.length;e{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n({width:r[t]||0,height:l[t]||0});return{first:P(0),last:P(e-1),widest:P(k),highest:P(S),widths:r,heights:l}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return Q(this._alignToPixels?Ae(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*s?a/i:r/s:r*s0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:n,position:a,border:r}=s,h=n.offset,c=this.isHorizontal(),d=this.ticks.length+(h?1:0),u=Ks(n),f=[],g=r.setContext(this.getContext()),p=g.display?g.width:0,m=p/2,b=function(t){return Ae(i,t,p)};let x,_,y,v,M,w,k,S,P,D,C,O;if("top"===a)x=b(this.bottom),w=this.bottom-u,S=x-m,D=b(t.top)+m,O=t.bottom;else if("bottom"===a)x=b(this.top),D=t.top,O=b(t.bottom)-m,w=x+m,S=this.top+u;else if("left"===a)x=b(this.right),M=this.right-u,k=x-m,P=b(t.left)+m,C=t.right;else if("right"===a)x=b(this.left),P=t.left,C=b(t.right)-m,M=x+m,k=this.left+u;else if("x"===e){if("center"===a)x=b((t.top+t.bottom)/2+.5);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}D=t.top,O=t.bottom,w=x+m,S=w+u}else if("y"===e){if("center"===a)x=b((t.left+t.right)/2);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}M=x-m,k=M-u,P=t.left,C=t.right}const A=l(s.ticks.maxTicksLimit,d),T=Math.max(1,Math.ceil(d/A));for(_=0;_e.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:s,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");ue.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&ue.describe(e,t.descriptors)}(t,o,i),this.override&&ue.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in ue[s]&&(delete ue[s][i],this.override&&delete re[i])}}class tn{constructor(){this.controllers=new Qs(Ns,"datasets",!0),this.elements=new Qs(Hs,"elements"),this.plugins=new Qs(Object,"plugins"),this.scales=new Qs(Js,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):u(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=w(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;et.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function nn(t,e){return e||!1!==t?!0===t?{}:t:null}function on(t,{plugin:e,local:i},s,n){const o=t.pluginScopeKeys(e),a=t.getOptionScopes(s,o);return i&&e.defaults&&a.push(e.defaults),t.createResolver(a,n,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function an(t,e){const i=ue.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function rn(t){if("x"===t||"y"===t||"r"===t)return t}function ln(t,...e){if(rn(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.length>1&&rn(t[0].toLowerCase());if(e)return e}var i;throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function hn(t,e,i){if(i[e+"AxisID"]===t)return{axis:e}}function cn(t,e){const i=re[t.type]||{scales:{}},s=e.scales||{},n=an(t.type,e),a=Object.create(null);return Object.keys(s).forEach((e=>{const r=s[e];if(!o(r))return console.error(`Invalid scale configuration for scale: ${e}`);if(r._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${e}`);const l=ln(e,r,function(t,e){if(e.data&&e.data.datasets){const i=e.data.datasets.filter((e=>e.xAxisID===t||e.yAxisID===t));if(i.length)return hn(t,"x",i[0])||hn(t,"y",i[0])}return{}}(e,t),ue.scales[r.type]),h=function(t,e){return t===e?"_index_":"_value_"}(l,n),c=i.scales||{};a[e]=x(Object.create(null),[{axis:l},r,c[l],c[h]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,o=i.indexAxis||an(n,e),r=(re[n]||{}).scales||{};Object.keys(r).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,o),n=i[e+"AxisID"]||e;a[n]=a[n]||Object.create(null),x(a[n],[{axis:e},s[n],r[t]])}))})),Object.keys(a).forEach((t=>{const e=a[t];x(e,[ue.scales[e.type],ue.scale])})),a}function dn(t){const e=t.options||(t.options={});e.plugins=l(e.plugins,{}),e.scales=cn(t,e)}function un(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const fn=new Map,gn=new Set;function pn(t,e){let i=fn.get(t);return i||(i=e(),fn.set(t,i),gn.add(i)),i}const mn=(t,e,i)=>{const s=M(e,i);void 0!==s&&t.add(s)};class bn{constructor(t){this._config=function(t){return(t=t||{}).data=un(t.data),dn(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=un(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),dn(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return pn(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return pn(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return pn(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return pn(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>mn(r,t,e)))),e.forEach((t=>mn(r,s,t))),e.forEach((t=>mn(r,re[n]||{},t))),e.forEach((t=>mn(r,ue,t))),e.forEach((t=>mn(r,le,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),gn.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,re[e]||{},ue.datasets[e]||{},{type:e},ue,le]}resolveNamedOptions(t,e,i,s=[""]){const o={$shared:!0},{resolver:a,subPrefixes:r}=xn(this._resolverCache,t,s);let l=a;if(function(t,e){const{isScriptable:i,isIndexable:s}=Ye(t);for(const o of e){const e=i(o),a=s(o),r=(a||e)&&t[o];if(e&&(S(r)||_n(r))||a&&n(r))return!0}return!1}(a,e)){o.$shared=!1;l=$e(a,i=S(i)?i():i,this.createResolver(t,i,r))}for(const t of e)o[t]=l[t];return o}createResolver(t,e,i=[""],s){const{resolver:n}=xn(this._resolverCache,t,i);return o(e)?$e(n,e,void 0,s):n}}function xn(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:je(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const _n=t=>o(t)&&Object.getOwnPropertyNames(t).reduce(((e,i)=>e||S(t[i])),!1);const yn=["top","bottom","left","right","chartArea"];function vn(t,e){return"top"===t||"bottom"===t||-1===yn.indexOf(t)&&"x"===e}function Mn(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function wn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function kn(t){const e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function Sn(t){return fe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const Pn={},Dn=t=>{const e=Sn(t);return Object.values(Pn).filter((t=>t.canvas===e)).pop()};function Cn(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}function On(t,e,i){return t.options.clip?t[i]:e[i]}class An{static defaults=ue;static instances=Pn;static overrides=re;static registry=en;static version="4.4.0";static getChart=Dn;static register(...t){en.add(...t),Tn()}static unregister(...t){en.remove(...t),Tn()}constructor(t,e){const s=this.config=new bn(e),n=Sn(t),o=Dn(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const a=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||ks(n)),this.platform.updateConfig(s);const r=this.platform.acquireContext(n,a.aspectRatio),l=r&&r.canvas,h=l&&l.height,c=l&&l.width;this.id=i(),this.ctx=r,this.canvas=l,this.width=c,this.height=h,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new sn,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=dt((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],Pn[this.id]=this,r&&l?(xt.listen(this,"complete",wn),xt.listen(this,"progress",kn),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return s(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return en}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ke(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Te(this.canvas,this.ctx),this}stop(){return xt.stop(this),this}resize(t,e){xt.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ke(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),d(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){u(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=ln(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),u(n,(e=>{const n=e.options,o=n.id,a=ln(o,n),r=l(n.type,e.dtype);void 0!==n.position&&vn(n.position,a)===vn(e.dposition)||(n.position=e.dposition),s[o]=!0;let h=null;if(o in i&&i[o].type===r)h=i[o];else{h=new(en.getScale(r))({id:o,type:r,ctx:this.ctx,chart:this}),i[h.id]=h}h.init(n,t)})),u(s,((t,e)=>{t||delete i[e]})),u(i,(t=>{as.configure(this,t,t.options),as.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;te.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(Mn("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){u(this.scales,(t=>{as.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);P(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){Cn(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;tt.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;as.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],u(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i=t._clip,s=!i.disabled,n=function(t,e){const{xScale:i,yScale:s}=t;return i&&s?{left:On(i,e,"left"),right:On(i,e,"right"),top:On(s,e,"top"),bottom:On(s,e,"bottom")}:e}(t,this.chartArea),o={meta:t,index:t.index,cancelable:!0};!1!==this.notifyPlugins("beforeDatasetDraw",o)&&(s&&Ie(e,{left:!1===i.left?0:n.left-i.left,right:!1===i.right?this.width:n.right+i.right,top:!1===i.top?0:n.top-i.top,bottom:!1===i.bottom?this.height:n.bottom+i.bottom}),t.controller.draw(),s&&ze(e),o.cancelable=!1,this.notifyPlugins("afterDatasetDraw",o))}isPointInArea(t){return Re(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Xi.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Ci(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);k(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),xt.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};u(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){u(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},u(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!f(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(t){return 1===this._plugins._cache.filter((e=>e.plugin.id===t)).length}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=D(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,d(n.onHover,[t,a,this],this),r&&d(n.onClick,[t,a,this],this));const h=!f(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}function Tn(){return u(An.instances,(t=>t._plugins.invalidate()))}function Ln(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class En{static override(t){Object.assign(En.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return Ln()}parse(){return Ln()}format(){return Ln()}add(){return Ln()}diff(){return Ln()}startOf(){return Ln()}endOf(){return Ln()}}var Rn={_date:En};function In(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;et-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(k(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;sMath.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,s):e[i.axis]=i.parse(t,s),e}function Fn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;ht.x,i="left",s="right"):(e=t.base"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data;if(e.labels.length&&e.datasets.length){const{labels:{pointStyle:i,color:s}}=t.legend.options;return e.labels.map(((e,n)=>{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let n,a,r=t=>+i[t];if(o(i[t])){const{key:t="value"}=this._parsing;r=e=>+M(i[e],t)}for(n=t,a=t+e;nZ(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>Z(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(E,c,u),b=g(C,h,d),x=g(C+E,c,u);s=(p-b)/2,n=(m-x)/2,o=-(p+b)/2,a=-(m+x)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(u,d,r),b=(i.width-o)/f,x=(i.height-o)/g,_=Math.max(Math.min(b,x)/2,0),y=c(this.options.radius,_),v=(y-Math.max(y*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=m*y,s.total=this.calculateTotal(),this.outerRadius=y-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/O)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:f,includeOptions:g}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p0&&!isNaN(t)?O*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(se.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*C;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?$(this.resolveDataElementOptions(t,e).angle||i):0}}var Yn=Object.freeze({__proto__:null,BarController:class extends Ns{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return Fn(t,e,i,s)}parseArrayData(t,e,i,s){return Fn(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:n,vScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l="x"===n.axis?a:r,h="x"===o.axis?a:r,c=[];let d,u,f,g;for(d=i,u=i+s;dt.controller.options.grouped)),o=i.options.stacked,a=[],r=t=>{const i=t.controller.getParsed(e),n=i&&i[t.vScale.axis];if(s(n)||isNaN(n))return!0};for(const i of n)if((void 0===e||!r(i))&&((!1===o||-1===a.indexOf(i.stack)||void 0===o&&void 0===i.stack)&&a.push(i.stack),i.index===t))break;return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n=i?1:-1)}(u,e,r)*a,f===r&&(b-=u/2);const t=e.getPixelForDecimal(0),s=e.getPixelForDecimal(1),o=Math.min(t,s),h=Math.max(t,s);b=Math.max(Math.min(b,h),o),d=b+u,i&&!c&&(l._stacks[e.axis]._visualValues[n]=e.getValueForPixel(d)-e.getValueForPixel(b))}if(b===e.getPixelForValue(r)){const t=F(u)*e.getLineWidthForValue(r)/2;b+=t,u-=t}return{size:u,base:b,head:d,center:d+u/2}}_calculateBarIndexPixels(t,e){const i=e.scale,n=this.options,o=n.skipNull,a=l(n.maxBarThickness,1/0);let r,h;if(e.grouped){const i=o?this._getStackCount(t):e.stackCount,l="flex"===n.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart.data.labels||[],{xScale:s,yScale:n}=e,o=this.getParsed(t),a=s.getLabelForValue(o.x),r=n.getLabelForValue(o.y),l=o._custom;return{label:i[t]||"",value:"("+a+", "+r+(l?", "+l:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,{sharedOptions:r,includeOptions:l}=this._getSharedOptions(e,s),h=o.axis,c=a.axis;for(let d=e;d0&&this.getParsed(e-1);for(let i=0;i<_;++i){const g=t[i],_=b?g:{};if(i=x){_.skip=!0;continue}const v=this.getParsed(i),M=s(v[f]),w=_[u]=a.getPixelForValue(v[u],i),k=_[f]=o||M?r.getBasePixel():r.getPixelForValue(l?this.applyStack(r,v,l):v[f],i);_.skip=isNaN(w)||isNaN(k)||M,_.stop=i>0&&Math.abs(v[u]-y[u])>m,p&&(_.parsed=v,_.raw=h.data[i]),d&&(_.options=c||this.resolveDataElementOptions(i,g.active?"active":n)),b||this.updateElement(g,i,_,n),y=v}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends jn{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:$n,RadarController:class extends Ns{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a0&&this.getParsed(e-1);for(let c=e;c0&&Math.abs(i[f]-_[f])>b,m&&(p.parsed=i,p.raw=h.data[c]),u&&(p.options=d||this.resolveDataElementOptions(c,e.active?"active":n)),x||this.updateElement(e,c,p,n),_=i}this.updateSharedOptions(d,n,c)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let t=0;for(let i=e.length-1;i>=0;--i)t=Math.max(t,e[i].size(this.resolveDataElementOptions(i))/2);return t>0&&t}const i=t.dataset,s=i.options&&i.options.borderWidth||0;if(!e.length)return s;const n=e[0].size(this.resolveDataElementOptions(0)),o=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(s,n,o)/2}}});function Un(t,e,i,s){const n=vi(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return J(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:J(n.innerStart,0,a),innerEnd:J(n.innerEnd,0,a)}}function Xn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function qn(t,e,i,s,n,o){const{x:a,y:r,startAngle:l,pixelMargin:h,innerRadius:c}=e,d=Math.max(e.outerRadius+s+i-h,0),u=c>0?c+s+i+h:0;let f=0;const g=n-l;if(s){const t=((c>0?c-s:0)+(d>0?d-s:0))/2;f=(g-(0!==t?g*t/(t+s):g))/2}const p=(g-Math.max(.001,g*d-i/C)/d)/2,m=l+p+f,b=n-p-f,{outerStart:x,outerEnd:_,innerStart:y,innerEnd:v}=Un(e,u,d,b-m),M=d-x,w=d-_,k=m+x/M,S=b-_/w,P=u+y,D=u+v,O=m+y/P,A=b-v/D;if(t.beginPath(),o){const e=(k+S)/2;if(t.arc(a,r,d,k,e),t.arc(a,r,d,e,S),_>0){const e=Xn(w,S,a,r);t.arc(e.x,e.y,_,S,b+E)}const i=Xn(D,b,a,r);if(t.lineTo(i.x,i.y),v>0){const e=Xn(D,A,a,r);t.arc(e.x,e.y,v,b+E,A+Math.PI)}const s=(b-v/u+(m+y/u))/2;if(t.arc(a,r,u,b-v/u,s,!0),t.arc(a,r,u,s,m+y/u,!0),y>0){const e=Xn(P,O,a,r);t.arc(e.x,e.y,y,O+Math.PI,m-E)}const n=Xn(M,m,a,r);if(t.lineTo(n.x,n.y),x>0){const e=Xn(M,k,a,r);t.arc(e.x,e.y,x,m-E,k)}}else{t.moveTo(a,r);const e=Math.cos(k)*d+a,i=Math.sin(k)*d+r;t.lineTo(e,i);const s=Math.cos(S)*d+a,n=Math.sin(S)*d+r;t.lineTo(s,n)}t.closePath()}function Kn(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r,options:l}=e,{borderWidth:h,borderJoinStyle:c,borderDash:d,borderDashOffset:u}=l,f="inner"===l.borderAlign;if(!h)return;t.setLineDash(d||[]),t.lineDashOffset=u,f?(t.lineWidth=2*h,t.lineJoin=c||"round"):(t.lineWidth=h,t.lineJoin=c||"bevel");let g=e.endAngle;if(o){qn(t,e,i,s,g,n);for(let e=0;en?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+E,s-E),t.closePath(),t.clip()}(t,e,g),o||(qn(t,e,i,s,g,n),t.stroke())}function Gn(t,e,i=e){t.lineCap=l(i.borderCapStyle,e.borderCapStyle),t.setLineDash(l(i.borderDash,e.borderDash)),t.lineDashOffset=l(i.borderDashOffset,e.borderDashOffset),t.lineJoin=l(i.borderJoinStyle,e.borderJoinStyle),t.lineWidth=l(i.borderWidth,e.borderWidth),t.strokeStyle=l(i.borderColor,e.borderColor)}function Zn(t,e,i){t.lineTo(i.x,i.y)}function Jn(t,e,i={}){const s=t.length,{start:n=0,end:o=s-1}=i,{start:a,end:r}=e,l=Math.max(n,a),h=Math.min(o,r),c=nr&&o>r;return{count:s,start:l,loop:e.loop,ilen:h(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[x(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[x(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(ig&&(g=i),m=(b*m+e)/++b):(_(),t.lineTo(e,i),u=s,b=0,f=g=i),p=i}_()}function eo(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?to:Qn}const io="function"==typeof Path2D;function so(t,e,i,s){io&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Gn(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=eo(e);for(const r of n)Gn(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class no extends Hs{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;hi(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=zi(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=Ii(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?pi:t.tension||"monotone"===t.cubicInterpolationMode?mi:gi}(i);let l,h;for(l=0,h=o.length;l"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.getProps(["x","y"],i),{angle:n,distance:o}=X(s,{x:t,y:e}),{startAngle:a,endAngle:r,innerRadius:h,outerRadius:c,circumference:d}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),u=(this.options.spacing+this.options.borderWidth)/2,f=l(d,r-a)>=O||Z(n,a,r),g=tt(o,h+u,c+u);return f&&g}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/4,n=(e.spacing||0)/2,o=e.circular;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>O?Math.floor(i/O):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();const a=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(a)*s,Math.sin(a)*s);const r=s*(1-Math.sin(Math.min(C,i||0)));t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor,function(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r}=e;let l=e.endAngle;if(o){qn(t,e,i,s,l,n);for(let e=0;e("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}function po(t){const e=this.getLabels();return t>=0&&ts=e?s:t,a=t=>n=i?n:t;if(t){const t=F(s),e=F(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=0===n?1:Math.abs(.05*n);a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n=function(t,e){const i=[],{bounds:n,step:o,min:a,max:r,precision:l,count:h,maxTicks:c,maxDigits:d,includeBounds:u}=t,f=o||1,g=c-1,{min:p,max:m}=e,b=!s(a),x=!s(r),_=!s(h),y=(m-p)/(d+1);let v,M,w,k,S=B((m-p)/g/f)*f;if(S<1e-14&&!b&&!x)return[{value:p},{value:m}];k=Math.ceil(m/S)-Math.floor(p/S),k>g&&(S=B(k*S/g/f)*f),s(l)||(v=Math.pow(10,l),S=Math.ceil(S*v)/v),"ticks"===n?(M=Math.floor(p/S)*S,w=Math.ceil(m/S)*S):(M=p,w=m),b&&x&&o&&H((r-a)/o,S/1e3)?(k=Math.round(Math.min((r-a)/S,c)),S=(r-a)/k,M=a,w=r):_?(M=b?a:M,w=x?r:w,k=h-1,S=(w-M)/k):(k=(w-M)/S,k=V(k,Math.round(k),S/1e3)?Math.round(k):Math.ceil(k));const P=Math.max(U(S),U(M));v=Math.pow(10,s(l)?P:l),M=Math.round(M*v)/v,w=Math.round(w*v)/v;let D=0;for(b&&(u&&M!==a?(i.push({value:a}),Mr)break;i.push({value:t})}return x&&u&&w!==r?i.length&&V(i[i.length-1].value,r,mo(r,y,t))?i[i.length-1].value=r:i.push({value:r}):x&&w!==r||i.push({value:w}),i}({maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&j(n,this,"value"),t.reverse?(n.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),n}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const s=(i-e)/Math.max(t.length-1,1)/2;e-=s,i+=s}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return ne(t,this.chart.options.locale,this.options.ticks.format)}}class xo extends bo{static id="linear";static defaults={ticks:{callback:ae.formatters.numeric}};determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?t:0,this.max=a(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=$(this.options.ticks.minRotation),s=(t?Math.sin(i):Math.cos(i))||.001,n=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,n.lineHeight/s))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const _o=t=>Math.floor(z(t)),yo=(t,e)=>Math.pow(10,_o(t)+e);function vo(t){return 1===t/Math.pow(10,_o(t))}function Mo(t,e,i){const s=Math.pow(10,i),n=Math.floor(t/s);return Math.ceil(e/s)-n}function wo(t,{min:e,max:i}){e=r(t.min,e);const s=[],n=_o(e);let o=function(t,e){let i=_o(e-t);for(;Mo(t,e,i)>10;)i++;for(;Mo(t,e,i)<10;)i--;return Math.min(i,_o(t))}(e,i),a=o<0?Math.pow(10,Math.abs(o)):1;const l=Math.pow(10,o),h=n>o?Math.pow(10,n):0,c=Math.round((e-h)*a)/a,d=Math.floor((e-h)/l/10)*l*10;let u=Math.floor((c-d)/Math.pow(10,o)),f=r(t.min,Math.round((h+d+u*Math.pow(10,o))*a)/a);for(;f=10?u=u<15?15:20:u++,u>=20&&(o++,u=2,a=o>=0?1:a),f=Math.round((h+d+u*Math.pow(10,o))*a)/a;const g=r(t.max,f);return s.push({value:g,major:vo(g),significand:u}),s}class ko extends Js{static id="logarithmic";static defaults={ticks:{callback:ae.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){const i=bo.prototype.parse.apply(this,[t,e]);if(0!==i)return a(i)&&i>0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?Math.max(0,t):null,this.max=a(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!a(this._userMin)&&(this.min=t===yo(this.min,0)?yo(this.min,-1):yo(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t;i===s&&(i<=0?(n(1),o(10)):(n(yo(i,-1)),o(yo(s,1)))),i<=0&&n(yo(s,-1)),s<=0&&o(yo(i,1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=wo({min:this._userMin,max:this._userMax},this);return"ticks"===t.bounds&&j(e,this,"value"),t.reverse?(e.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),e}getLabelForValue(t){return void 0===t?"0":ne(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=z(t),this._valueRange=z(this.max)-z(t)}getPixelForValue(t){return void 0!==t&&0!==t||(t=this.min),null===t||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(z(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}function So(t){const e=t.ticks;if(e.display&&t.display){const t=ki(e.backdropPadding);return l(e.font&&e.font.size,ue.font.size)+t.height}return 0}function Po(t,e,i,s,n){return t===s||t===n?{start:e-i/2,end:e+i/2}:tn?{start:e-i,end:e}:{start:e,end:e+i}}function Do(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),s=[],o=[],a=t._pointLabels.length,r=t.options.pointLabels,l=r.centerPointLabels?C/a:0;for(let u=0;ue.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.starte.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function Oo(t,e,i){const s=t.drawingArea,{extra:n,additionalAngle:o,padding:a,size:r}=i,l=t.getPointPosition(e,s+n+a,o),h=Math.round(Y(G(l.angle+E))),c=function(t,e,i){90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e);return t}(l.y,r.h,h),d=function(t){if(0===t||180===t)return"center";if(t<180)return"left";return"right"}(h),u=function(t,e,i){"right"===i?t-=e:"center"===i&&(t-=e/2);return t}(l.x,r.w,d);return{visible:!0,x:l.x,y:c,textAlign:d,left:u,top:c,right:u+r.w,bottom:c+r.h}}function Ao(t,e){if(!e)return!0;const{left:i,top:s,right:n,bottom:o}=t;return!(Re({x:i,y:s},e)||Re({x:i,y:o},e)||Re({x:n,y:s},e)||Re({x:n,y:o},e))}function To(t,e,i){const{left:n,top:o,right:a,bottom:r}=i,{backdropColor:l}=e;if(!s(l)){const i=wi(e.borderRadius),s=ki(e.backdropPadding);t.fillStyle=l;const h=n-s.left,c=o-s.top,d=a-n+s.width,u=r-o+s.height;Object.values(i).some((t=>0!==t))?(t.beginPath(),He(t,{x:h,y:c,w:d,h:u,radius:i}),t.fill()):t.fillRect(h,c,d,u)}}function Lo(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,O);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;ot,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){const t=this._padding=ki(So(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!1);this.min=a(t)&&!isNaN(t)?t:0,this.max=a(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/So(this.options))}generateTickLabels(t){bo.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map(((t,e)=>{const i=d(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?Do(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return G(t*(O/(this._pointLabels.length||1))+$(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(s(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(s(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t=0;n--){const e=t._pointLabelItems[n];if(!e.visible)continue;const o=s.setContext(t.getPointLabelContext(n));To(i,o,e);const a=Si(o.font),{x:r,y:l,textAlign:h}=e;Ne(i,t._pointLabels[n],r,l+a.lineHeight/2,a,{color:o.color,textAlign:h,textBaseline:"middle"})}}(this,o),s.display&&this.ticks.forEach(((t,e)=>{if(0!==e){r=this.getDistanceFromCenterForValue(t.value);const i=this.getContext(e),a=s.setContext(i),l=n.setContext(i);!function(t,e,i,s,n){const o=t.ctx,a=e.circular,{color:r,lineWidth:l}=e;!a&&!s||!r||!l||i<0||(o.save(),o.strokeStyle=r,o.lineWidth=l,o.setLineDash(n.dash),o.lineDashOffset=n.dashOffset,o.beginPath(),Lo(t,i,a,s),o.closePath(),o.stroke(),o.restore())}(this,a,r,o,l)}})),i.display){for(t.save(),a=o-1;a>=0;a--){const s=i.setContext(this.getPointLabelContext(a)),{color:n,lineWidth:o}=s;o&&n&&(t.lineWidth=o,t.strokeStyle=n,t.setLineDash(s.borderDash),t.lineDashOffset=s.borderDashOffset,r=this.getDistanceFromCenterForValue(e.ticks.reverse?this.min:this.max),l=this.getPointPosition(a,r),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=Si(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=ki(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Ne(t,s.label,0,-n,l,{color:r.color,strokeColor:r.textStrokeColor,strokeWidth:r.textStrokeWidth})})),t.restore()}drawTitle(){}}const Ro={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},Io=Object.keys(Ro);function zo(t,e){return t-e}function Fo(t,e){if(s(e))return null;const i=t._adapter,{parser:n,round:o,isoWeekday:r}=t._parseOpts;let l=e;return"function"==typeof n&&(l=n(l)),a(l)||(l="string"==typeof n?i.parse(l,n):i.parse(l)),null===l?null:(o&&(l="week"!==o||!N(r)&&!0!==r?i.startOf(l,o):i.startOf(l,"isoWeek",r)),+l)}function Vo(t,e,i,s){const n=Io.length;for(let o=Io.indexOf(t);o=e?i[s]:i[n]]=!0}}else t[e]=!0}function Wo(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a=0&&(e[l].major=!0);return e}(t,s,n,i):s}class No extends Js{static id="time";static defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",callback:!1,major:{enabled:!1}}};constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e={}){const i=t.time||(t.time={}),s=this._adapter=new Rn._date(t.adapters.date);s.init(e),x(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:Fo(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:o,maxDefined:r}=this.getUserBounds();function l(t){o||isNaN(t.min)||(s=Math.min(s,t.min)),r||isNaN(t.max)||(n=Math.max(n,t.max))}o&&r||(l(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||l(this.getMinMax(!1))),s=a(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=a(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=nt(s,n,this.max);return this._unit=e.unit||(i.autoSkip?Vo(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=Io.length-1;o>=Io.indexOf(i);o--){const i=Io[o];if(Ro[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return Io[i?Io.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=Io.indexOf(t)+1,i=Io.length;e+t.value)))}initOffsets(t=[]){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=J(s,0,o),n=J(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||Vo(n.minUnit,e,i,this._getLabelCapacity(e)),a=l(s.ticks.stepSize,1),r="week"===o&&n.isoWeekday,h=N(r)||!0===r,c={};let d,u,f=e;if(h&&(f=+t.startOf(f,"isoWeek",r)),f=+t.startOf(f,h?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const g="data"===s.ticks.source&&this.getDataTimestamps();for(d=f,u=0;d+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){const i=this.options.time.displayFormats,s=this._unit,n=e||i[s];return this._adapter.format(t,n)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.ticks.callback;if(o)return d(o,[t,e,i],this);const a=n.time.displayFormats,r=this._unit,l=this._majorUnit,h=r&&a[r],c=l&&a[l],u=i[e],f=l&&c&&u&&u.major;return this._adapter.format(t,s||(f?c:h))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=it(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=it(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}var jo=Object.freeze({__proto__:null,CategoryScale:class extends Js{static id="category";static defaults={ticks:{callback:po}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(s(t))return null;const i=this.getLabels();return((t,e)=>null===t?null:J(Math.round(t),0,e))(e=isFinite(e)&&i[e]===t?e:go(i,t,l(e,t),this._addedLabels),i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){return po.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return"number"!=typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:xo,LogarithmicScale:ko,RadialLinearScale:Eo,TimeScale:No,TimeSeriesScale:class extends No{static id="timeseries";static defaults=No.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=Ho(e,this.min),this._tableRange=Ho(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;ot-e))}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t,t}getDecimalForValue(t){return(Ho(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return Ho(this._table,i*this._tableRange+this._minPos,!0)}}});const $o=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],Yo=$o.map((t=>t.replace("rgb(","rgba(").replace(")",", 0.5)")));function Uo(t){return $o[t%$o.length]}function Xo(t){return Yo[t%Yo.length]}function qo(t){let e=0;return(i,s)=>{const n=t.getDatasetMeta(s).controller;n instanceof jn?e=function(t,e){return t.backgroundColor=t.data.map((()=>Uo(e++))),e}(i,e):n instanceof $n?e=function(t,e){return t.backgroundColor=t.data.map((()=>Xo(e++))),e}(i,e):n&&(e=function(t,e){return t.borderColor=Uo(e),t.backgroundColor=Xo(e),++e}(i,e))}}function Ko(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}var Go={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(!i.enabled)return;const{data:{datasets:s},options:n}=t.config,{elements:o}=n;if(!i.forceOverride&&(Ko(s)||(a=n)&&(a.borderColor||a.backgroundColor)||o&&Ko(o)))return;var a;const r=qo(t);s.forEach(r)}};function Zo(t){if(t._decimated){const e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function Jo(t){t.data.datasets.forEach((t=>{Zo(t)}))}var Qo={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,i)=>{if(!i.enabled)return void Jo(t);const n=t.width;t.data.datasets.forEach(((e,o)=>{const{_data:a,indexAxis:r}=e,l=t.getDatasetMeta(o),h=a||e.data;if("y"===Pi([r,t.options.indexAxis]))return;if(!l.controller.supportsDecimation)return;const c=t.scales[l.xAxisID];if("linear"!==c.type&&"time"!==c.type)return;if(t.options.parsing)return;let{start:d,count:u}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=J(it(e,o.axis,a).lo,0,i-1)),s=h?J(it(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(l,h);if(u<=(i.threshold||4*n))return void Zo(e);let f;switch(s(a)&&(e._data=h,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),i.algorithm){case"lttb":f=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;cu&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(h,d,u,n,i);break;case"min-max":f=function(t,e,i,n){let o,a,r,l,h,c,d,u,f,g,p=0,m=0;const b=[],x=e+i-1,_=t[e].x,y=t[x].x-_;for(o=e;og&&(g=l,d=o),p=(m*p+a.x)/++m;else{const i=o-1;if(!s(c)&&!s(d)){const e=Math.min(c,d),s=Math.max(c,d);e!==u&&e!==i&&b.push({...t[e],x:p}),s!==u&&s!==i&&b.push({...t[s],x:p})}o>0&&i!==u&&b.push(t[i]),b.push(a),h=e,m=0,f=g=l,c=d=u=o}}return b}(h,d,u,n);break;default:throw new Error(`Unsupported decimation algorithm '${i.algorithm}'`)}e._decimated=f}))},destroy(t){Jo(t)}};function ta(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=G(n),o=G(o)),{property:t,start:n,end:o}}function ea(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function ia(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function sa(t,e){let i=[],s=!1;return n(t)?(s=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=ea(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new no({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function na(t){return t&&!1!==t.fill}function oa(t,e,i){let s=t[e].fill;const n=[e];let o;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!a(s))return s;if(o=t[s],!o)return!1;if(o.visible)return s;n.push(s),s=o.fill}return!1}function aa(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=l(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(o(s))return!isNaN(s.value)&&s;let n=parseFloat(s);return a(n)&&Math.floor(n)===n?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,n,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function ra(t,e,i){const s=[];for(let n=0;n=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&i.fill&&da(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;na(i)&&da(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;na(s)&&"beforeDatasetDraw"===i.drawTime&&da(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const ba=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class xa extends Hs{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=d(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=Si(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=ba(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,s,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const{itemWidth:p,itemHeight:m}=function(t,e,i,s,n){const o=function(t,e,i,s){let n=t.text;n&&"string"!=typeof n&&(n=n.reduce(((t,e)=>t.length>e.length?t:e)));return e+i.size/2+s.measureText(n).width}(s,t,e,i),a=function(t,e,i){let s=t;"string"!=typeof e.text&&(s=_a(e,i));return s}(n,s,e.lineHeight);return{itemWidth:o,itemHeight:a}}(i,e,n,t,s);o>0&&u+m+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:m},d=Math.max(d,p),u+=m+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:n}}=this,o=Oi(n,this.left,this.width);if(this.isHorizontal()){let n=0,a=ft(i,this.left+s,this.right-this.lineWidths[n]);for(const r of e)n!==r.row&&(n=r.row,a=ft(i,this.left+s,this.right-this.lineWidths[n])),r.top+=this.top+t+s,r.left=o.leftForLtr(o.x(a),r.width),a+=r.width+s}else{let n=0,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height);for(const r of e)r.col!==n&&(n=r.col,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height)),r.top=a,r.left+=this.left+s,r.left=o.leftForLtr(o.x(r.left),r.width),a+=r.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;Ie(t,this),this._draw(),ze(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:n,labels:o}=t,a=ue.color,r=Oi(t.rtl,this.left,this.width),h=Si(o.font),{padding:c}=o,d=h.size,u=d/2;let f;this.drawTitle(),s.textAlign=r.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=h.string;const{boxWidth:g,boxHeight:p,itemHeight:m}=ba(o,d),b=this.isHorizontal(),x=this._computeTitleHeight();f=b?{x:ft(n,this.left+c,this.right-i[0]),y:this.top+c+x,line:0}:{x:this.left+c,y:ft(n,this.top+x+c,this.bottom-e[0].height),line:0},Ai(this.ctx,t.textDirection);const _=m+c;this.legendItems.forEach(((y,v)=>{s.strokeStyle=y.fontColor,s.fillStyle=y.fontColor;const M=s.measureText(y.text).width,w=r.textAlign(y.textAlign||(y.textAlign=o.textAlign)),k=g+u+M;let S=f.x,P=f.y;r.setWidth(this.width),b?v>0&&S+k+c>this.right&&(P=f.y+=_,f.line++,S=f.x=ft(n,this.left+c,this.right-i[f.line])):v>0&&P+_>this.bottom&&(S=f.x=S+e[f.line].width+c,f.line++,P=f.y=ft(n,this.top+x+c,this.bottom-e[f.line].height));if(function(t,e,i){if(isNaN(g)||g<=0||isNaN(p)||p<0)return;s.save();const n=l(i.lineWidth,1);if(s.fillStyle=l(i.fillStyle,a),s.lineCap=l(i.lineCap,"butt"),s.lineDashOffset=l(i.lineDashOffset,0),s.lineJoin=l(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=l(i.strokeStyle,a),s.setLineDash(l(i.lineDash,[])),o.usePointStyle){const a={radius:p*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},l=r.xPlus(t,g/2);Ee(s,a,l,e+u,o.pointStyleWidth&&g)}else{const o=e+Math.max((d-p)/2,0),a=r.leftForLtr(t,g),l=wi(i.borderRadius);s.beginPath(),Object.values(l).some((t=>0!==t))?He(s,{x:a,y:o,w:g,h:p,radius:l}):s.rect(a,o,g,p),s.fill(),0!==n&&s.stroke()}s.restore()}(r.x(S),P,y),S=gt(w,S+g+u,b?S+k:this.right,t.rtl),function(t,e,i){Ne(s,i.text,t,e+m/2,h,{strikethrough:i.hidden,textAlign:r.textAlign(i.textAlign)})}(r.x(S),P,y),b)f.x+=k+c;else if("string"!=typeof y.text){const t=h.lineHeight;f.y+=_a(y,t)+c}else f.y+=_})),Ti(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=Si(e.font),s=ki(e.padding);if(!e.display)return;const n=Oi(t.rtl,this.left,this.width),o=this.ctx,a=e.position,r=i.size/2,l=s.top+r;let h,c=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),h=this.top+l,c=ft(t.align,c,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);h=l+ft(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const u=ft(a,c,c+d);o.textAlign=n.textAlign(ut(a)),o.textBaseline="middle",o.strokeStyle=e.color,o.fillStyle=e.color,o.font=i.string,Ne(o,e.text,u,h,i)}_computeTitleHeight(){const t=this.options.title,e=Si(t.font),i=ki(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(tt(t,this.left,this.right)&&tt(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o,useBorderRadius:a,borderRadius:r}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const l=t.controller.getStyle(i?0:void 0),h=ki(l.borderWidth);return{text:e[t.index].label,fillStyle:l.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:l.borderCapStyle,lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:(h.width+h.height)/4,strokeStyle:l.borderColor,pointStyle:s||l.pointStyle,rotation:l.rotation,textAlign:n||l.textAlign,borderRadius:a&&(r||l.borderRadius),datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class va extends Hs{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const s=n(i.text)?i.text.length:1;this._padding=ki(i.padding);const o=s*Si(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:n,options:o}=this,a=o.align;let r,l,h,c=0;return this.isHorizontal()?(l=ft(a,i,n),h=e+t,r=n-i):("left"===o.position?(l=i+t,h=ft(a,s,e),c=-.5*C):(l=n-t,h=ft(a,e,s),c=.5*C),r=s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=Si(e.font),s=i.lineHeight/2+this._padding.top,{titleX:n,titleY:o,maxWidth:a,rotation:r}=this._drawArgs(s);Ne(t,e.text,0,0,i,{color:e.color,maxWidth:a,rotation:r,textAlign:ut(e.align),textBaseline:"middle",translation:[n,o]})}}var Ma={id:"title",_element:va,start(t,e,i){!function(t,e){const i=new va({ctx:t.ctx,options:e,chart:t});as.configure(t,i,e),as.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;as.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const wa=new WeakMap;var ka={id:"subtitle",start(t,e,i){const s=new va({ctx:t.ctx,options:i,chart:t});as.configure(t,s,i),as.addBox(t,s),wa.set(t,s)},stop(t){as.removeBox(t,wa.get(t)),wa.delete(t)},beforeUpdate(t,e,i){const s=wa.get(t);as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Sa={average(t){if(!t.length)return!1;let e,i,s=0,n=0,o=0;for(e=0,i=t.length;e-1?t.split("\n"):t}function Ca(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Oa(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=Si(e.bodyFont),h=Si(e.titleFont),c=Si(e.footerFont),d=o.length,f=n.length,g=s.length,p=ki(e.padding);let m=p.height,b=0,x=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(x+=t.beforeBody.length+t.afterBody.length,d&&(m+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),x){m+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(x-g)*l.lineHeight+(x-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*c.lineHeight+(f-1)*e.footerSpacing);let _=0;const y=function(t){b=Math.max(b,i.measureText(t).width+_)};return i.save(),i.font=h.string,u(t.title,y),i.font=l.string,u(t.beforeBody.concat(t.afterBody),y),_=e.displayColors?a+2+e.boxPadding:0,u(s,(t=>{u(t.before,y),u(t.lines,y),u(t.after,y)})),_=0,i.font=c.string,u(t.footer,y),i.restore(),b+=p.width,{width:b,height:m}}function Aa(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function Ta(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return it.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||Aa(t,e,i,s),yAlign:s}}function La(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=wi(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:J(g,0,s.width-e.width),y:J(p,0,s.height-e.height)}}function Ea(t,e,i){const s=ki(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function Ra(t){return Pa([],Da(t))}function Ia(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}const za={beforeTitle:e,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex{const e={before:[],lines:[],after:[]},n=Ia(i,t);Pa(e.before,Da(Fa(n,"beforeLabel",this,t))),Pa(e.lines,Fa(n,"label",this,t)),Pa(e.after,Da(Fa(n,"afterLabel",this,t))),s.push(e)})),s}getAfterBody(t,e){return Ra(Fa(e.callbacks,"afterBody",this,t))}getFooter(t,e){const{callbacks:i}=e,s=Fa(i,"beforeFooter",this,t),n=Fa(i,"footer",this,t),o=Fa(i,"afterFooter",this,t);let a=[];return a=Pa(a,Da(s)),a=Pa(a,Da(n)),a=Pa(a,Da(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;at.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),u(l,(e=>{const i=Ia(t.callbacks,e);s.push(Fa(i,"labelColor",this,e)),n.push(Fa(i,"labelPointStyle",this,e)),o.push(Fa(i,"labelTextColor",this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Sa[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Oa(this,i),a=Object.assign({},t,e),r=Ta(this.chart,i,a),l=La(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=wi(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,b,x,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,x=_+o,y=_-o):(p=d+f,m=p+o,x=_-o,y=_+o),b=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(x=u,_=x-o,p=m-o,b=m+o):(x=u+g,_=x+o,p=m+o,b=m-o),y=x),{x1:p,x2:m,x3:b,y1:x,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=Oi(i.rtl,this.x,this.width);for(t.x=Ea(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=Si(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r0!==t))?(t.beginPath(),t.fillStyle=n.multiKeyBackground,He(t,{x:e,y:g,w:h,h:l,radius:r}),t.fill(),t.stroke(),t.fillStyle=a.backgroundColor,t.beginPath(),He(t,{x:i,y:g+1,w:h-2,h:l-2,radius:r}),t.fill()):(t.fillStyle=n.multiKeyBackground,t.fillRect(e,g,h,l),t.strokeRect(e,g,h,l),t.fillStyle=a.backgroundColor,t.fillRect(i,g+1,h-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=Si(i.bodyFont);let d=c.lineHeight,f=0;const g=Oi(i.rtl,this.x,this.width),p=function(i){e.fillText(i,g.x(t.x+f),t.y+d/2),t.y+=d+n},m=g.textAlign(o);let b,x,_,y,v,M,w;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Ea(this,m,i),e.fillStyle=i.bodyColor,u(this.beforeBody,p),f=a&&"right"!==m?"center"===o?l/2+h:l+2+h:0,y=0,M=s.length;y0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Sa[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Oa(this,t),a=Object.assign({},i,this._size),r=Ta(e,t,a),l=La(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=ki(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),Ai(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),Ti(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!f(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!f(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e;const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Sa[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}var Ba={id:"tooltip",_element:Va,positioners:Sa,afterInit(t,e,i){i&&(t.tooltip=new Va({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0}))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:za},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return An.register(Yn,jo,fo,t),An.helpers={...Wi},An._adapters=Rn,An.Animation=Cs,An.Animations=Os,An.animator=xt,An.controllers=en.controllers.items,An.DatasetController=Ns,An.Element=Hs,An.elements=fo,An.Interaction=Xi,An.layouts=as,An.platforms=Ss,An.Scale=Js,An.Ticks=ae,Object.assign(An,Yn,jo,fo,t,Ss),An.Chart=An,"undefined"!=typeof window&&(window.Chart=An),An})); +//# sourceMappingURL=chart.umd.js.map diff --git a/web/js/charts_modal.js b/web/js/charts_modal.js index e5df464..c3d0112 100644 --- a/web/js/charts_modal.js +++ b/web/js/charts_modal.js @@ -12,14 +12,14 @@ function updateModalChart(history) { } const labels = history.map(h => `E${h.epoch}`); - const scoreData = history.map(h => h.score !== undefined ? h.score : h.dice); - const lossData = history.map(h => h.loss !== undefined ? h.loss : h.bce); + const scoreData = history.map(h => h.score); + const lossData = history.map(h => h.loss); const hasScore = scoreData.some(d => d !== null && d !== undefined); const hasLoss = lossData.some(d => d !== null && d !== undefined); - const lossLabel = window.HPOState.data.hpoConfig?.metric_loss_label || "BCE"; - const scoreLabel = window.HPOState.data.hpoConfig?.metric_score_label || "Dice"; + const lossLabel = window.HPOState.data.hpoConfig?.metric_loss_label || "Loss"; + const scoreLabel = window.HPOState.data.hpoConfig?.metric_score_label || "Score"; const lossText = lossLabel.toLowerCase().includes("loss") ? lossLabel : `${lossLabel} Loss`; const scoreText = scoreLabel.toLowerCase().includes("score") || scoreLabel.toLowerCase().includes("accuracy") ? scoreLabel : `${scoreLabel} Score`; diff --git a/web/js/charts_pareto.js b/web/js/charts_pareto.js index f012734..85a24c0 100644 --- a/web/js/charts_pareto.js +++ b/web/js/charts_pareto.js @@ -16,8 +16,8 @@ function updateChart(trials, paretoSet, directions) { const isSingleObj = !directions || directions.length === 1; const isMinimize = directions && directions[0] === "MINIMIZE"; - const lossLabel = window.HPOState.data.hpoConfig?.metric_loss_label || "BCE"; - const scoreLabel = window.HPOState.data.hpoConfig?.metric_score_label || "Dice"; + const lossLabel = window.HPOState.data.hpoConfig?.metric_loss_label || "Loss"; + const scoreLabel = window.HPOState.data.hpoConfig?.metric_score_label || "Score"; const metricLabel = isMinimize ? lossLabel : scoreLabel; const accentColor = window.HPOState.ui.accentColorHex; diff --git a/web/js/charts_pathways_math.js b/web/js/charts_pathways_math.js index 205d060..d0c10ad 100644 --- a/web/js/charts_pathways_math.js +++ b/web/js/charts_pathways_math.js @@ -13,8 +13,8 @@ function computeAxesBounds(trials) { paramKeys.forEach(k => { axes.push({ key: k, label: k.replace(/_/g, " "), type: "param" }); }); - axes.push({ key: "dice", label: window.HPOState.data.hpoConfig?.metric_score_label || "Dice", type: "metric" }); - axes.push({ key: "bce", label: window.HPOState.data.hpoConfig?.metric_loss_label || "BCE", type: "metric" }); + axes.push({ key: "score", label: window.HPOState.data.hpoConfig?.metric_score_label || "Score", type: "metric" }); + axes.push({ key: "loss", label: window.HPOState.data.hpoConfig?.metric_loss_label || "Loss", type: "metric" }); axes.forEach(axis => { if (axis.type === "param") { diff --git a/web/js/charts_pathways_render.js b/web/js/charts_pathways_render.js index 9fca7b9..920e891 100644 --- a/web/js/charts_pathways_render.js +++ b/web/js/charts_pathways_render.js @@ -155,7 +155,7 @@ function updateParallelCoordinates(trials, paretoSet) { ctx.strokeStyle = "rgba(100, 116, 139, 0.15)"; ctx.lineWidth = 1; } else { - const score = t.dice !== null ? t.dice : 0; + const score = t.score !== null ? t.score : 0; const ratio = Math.max(0, Math.min(1, score)); const grad = ctx.createLinearGradient(xPad, 0, W - xPad, 0); grad.addColorStop(0, `hsla(260, 80%, 60%, ${0.15 + ratio * 0.3})`); @@ -177,7 +177,7 @@ function updateParallelCoordinates(trials, paretoSet) { const lossLabel = window.HPOState.data.hpoConfig?.metric_loss_label || "Loss"; const scoreLabel = window.HPOState.data.hpoConfig?.metric_score_label || "Score"; - const tooltipText = `Trial #${t.number} [${t.state}] β€” ${scoreLabel}: ${t.dice !== null ? t.dice.toFixed(4) : "N/A"}, ${lossLabel}: ${t.bce !== null ? t.bce.toFixed(4) : "N/A"}`; + const tooltipText = `Trial #${t.number} [${t.state}] β€” ${scoreLabel}: ${t.score !== null ? t.score.toFixed(4) : "N/A"}, ${lossLabel}: ${t.loss !== null ? t.loss.toFixed(4) : "N/A"}`; ctx.font = "600 0.75rem Inter, system-ui, sans-serif"; const textWidth = ctx.measureText(tooltipText).width; const boxW = textWidth + 20, boxH = 28; diff --git a/web/js/charts_timeline.js b/web/js/charts_timeline.js index 52d3464..43e66e3 100644 --- a/web/js/charts_timeline.js +++ b/web/js/charts_timeline.js @@ -11,8 +11,8 @@ function updateAshaTimeline(trials) { return; } - const scoreLabel = window.HPOState.data.hpoConfig?.metric_score_label || "Dice"; - const lossLabel = window.HPOState.data.hpoConfig?.metric_loss_label || "BCE"; + const scoreLabel = window.HPOState.data.hpoConfig?.metric_score_label || "Score"; + const lossLabel = window.HPOState.data.hpoConfig?.metric_loss_label || "Loss"; let html = ""; validTrials.forEach(t => { @@ -77,8 +77,8 @@ function updateAshaTimeline(trials) { ${paramsStr}
- ${t.dice !== null ? `${scoreLabel}: ${t.dice.toFixed(4)}` : ""} - ${t.bce !== null ? ` (${lossLabel}: ${t.bce.toFixed(4)})` : ""} + ${t.score !== null ? `${scoreLabel}: ${t.score.toFixed(4)}` : ""} + ${t.loss !== null ? ` (${lossLabel}: ${t.loss.toFixed(4)})` : ""}
diff --git a/web/js/charts_utils.js b/web/js/charts_utils.js index 3d451fc..2cc733a 100644 --- a/web/js/charts_utils.js +++ b/web/js/charts_utils.js @@ -13,14 +13,14 @@ function prepareParetoDatasets(trials, paretoSet, directions, isSingleObj, isMin if (isSingleObj) { const sortedCompleted = trials - .filter(t => t.state === "COMPLETE" && (isMinimize ? t.bce !== null : t.dice !== null)) + .filter(t => t.state === "COMPLETE" && (isMinimize ? t.loss !== null : t.score !== null)) .sort((a, b) => a.number - b.number); let currentBest = null; const bestLinePoints = []; sortedCompleted.forEach(t => { - const val = isMinimize ? t.bce : t.dice; + const val = isMinimize ? t.loss : t.score; if (currentBest === null) { currentBest = val; } else { @@ -31,13 +31,13 @@ function prepareParetoDatasets(trials, paretoSet, directions, isSingleObj, isMin const completedPoints = sortedCompleted.map(t => ({ x: t.number, - y: isMinimize ? t.bce : t.dice, + y: isMinimize ? t.loss : t.score, label: `Trial ${t.number}` })); const runningTrials = trials.filter(t => t.state === "RUNNING"); runningTrials.forEach(t => { - const val = isMinimize ? t.bce : t.dice; + const val = isMinimize ? t.loss : t.score; if (val !== null && val !== undefined) { runningPoints.push({ x: t.number, y: val, label: `Trial ${t.number} (running)` }); } @@ -84,8 +84,8 @@ function prepareParetoDatasets(trials, paretoSet, directions, isSingleObj, isMin const scatterPoints = []; const paretoPoints = []; trials.forEach(t => { - if (t.bce === null || t.dice === null) return; - const point = { x: t.bce, y: t.dice, label: `Trial ${t.number}` }; + if (t.loss === null || t.score === null) return; + const point = { x: t.loss, y: t.score, label: `Trial ${t.number}` }; if (t.state === "RUNNING") { runningPoints.push(point); } else if (paretoSet.has(t.number)) { @@ -137,7 +137,7 @@ function prepareParetoDatasets(trials, paretoSet, directions, isSingleObj, isMin } } - const totalPoints = trials.filter(t => (isSingleObj ? (isMinimize ? t.bce !== null : t.dice !== null) : (t.bce !== null && t.dice !== null))).length; + const totalPoints = trials.filter(t => (isSingleObj ? (isMinimize ? t.loss !== null : t.score !== null) : (t.loss !== null && t.score !== null))).length; return { datasets, totalPoints }; } diff --git a/web/js/export.js b/web/js/export.js index 735d334..aa10734 100644 --- a/web/js/export.js +++ b/web/js/export.js @@ -23,11 +23,11 @@ function exportTrialsToCsv() { const row = [ `#${t.number}`, t.state, - t.bce !== null && t.bce !== undefined ? t.bce.toFixed(6) : "", - t.dice !== null && t.dice !== undefined ? t.dice.toFixed(6) : "" + t.loss !== null && t.loss !== undefined ? t.loss.toFixed(6) : "", + t.score !== null && t.score !== undefined ? t.score.toFixed(6) : "" ]; if (ev.enabled) { - row.push(t.dice_eval_fixed !== null && t.dice_eval_fixed !== undefined ? t.dice_eval_fixed.toFixed(6) : ""); + row.push(t.score_eval_fixed !== null && t.score_eval_fixed !== undefined ? t.score_eval_fixed.toFixed(6) : ""); } paramKeys.forEach(k => { const val = t.params[k]; @@ -69,8 +69,8 @@ async function exportParetoFront() { data.pareto_front.forEach(t => { const row = [ `#${t.number}`, - t.bce !== null && t.bce !== undefined ? t.bce.toFixed(6) : "", - t.dice !== null && t.dice !== undefined ? t.dice.toFixed(6) : "" + t.loss !== null && t.loss !== undefined ? t.loss.toFixed(6) : "", + t.score !== null && t.score !== undefined ? t.score.toFixed(6) : "" ]; paramKeys.forEach(k => { const val = t.params[k]; diff --git a/web/js/health.js b/web/js/health.js index 3b20f38..b14ed29 100644 --- a/web/js/health.js +++ b/web/js/health.js @@ -156,7 +156,7 @@ function copyDiagnosticPrompt() { anomaly: window.HPOState.data.studyHealthReason || "unknown anomaly", active_search_space: window.HPOState.data.activeSearchSpace || {}, recent_trials: window.HPOState.data.trials.slice(0, 3).map(t => ({ - trial_id: t.number, state: t.state, params: t.params, score: t.dice, loss: t.bce + trial_id: t.number, state: t.state, params: t.params, score: t.score, loss: t.loss })) }; const promptText = `I need help debugging my Pathfinder study because it is failing health checks: diff --git a/web/js/main.js b/web/js/main.js index 6db3d43..8735d1c 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -12,25 +12,24 @@ document.addEventListener("DOMContentLoaded", () => { window.fetchSearchSpace(); window.fetchHpoConfig(); + + window.populateStudyList(); - const reviewPill = document.getElementById("dashboard-review-pill"); - if (reviewPill) { - reviewPill.addEventListener("click", window.copyReviewPrompt); - reviewPill.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - window.copyReviewPrompt(); + async function checkWelcomeState() { + const overlay = document.getElementById("welcome-overlay"); + if (!overlay) return; + try { + const res = await fetch("/api/studies"); + const data = await res.json(); + if (data.success && (!data.studies || data.studies.length === 0)) { + overlay.style.display = "flex"; } - }); - } - const reviewCloseBtn = document.getElementById("dashboard-review-close"); - if (reviewCloseBtn) { - reviewCloseBtn.addEventListener("click", (e) => { - e.stopPropagation(); - window.dismissReviewPill(); - }); + } catch (e) { + overlay.style.display = "flex"; + } } - window.populateStudyList(); + checkWelcomeState(); + const studySelect = document.getElementById("study-select"); if (studySelect) { studySelect.addEventListener("change", (e) => { @@ -88,7 +87,6 @@ async function pollData() { try { await Promise.all([ fetchStudyDetails(true), - fetchPendingChanges(true), checkStudyHealth(true) ]); @@ -122,4 +120,25 @@ async function pollData() { // Window exports window.pollData = pollData; +async function launchDemo() { + const status = document.getElementById("welcome-status"); + const btn = document.getElementById("welcome-launch-btn"); + if (status) status.textContent = "Initializing demo study..."; + if (btn) btn.disabled = true; + try { + const res = await fetch("/api/quickstart_demo", { method: "POST" }); + const data = await res.json(); + if (data.success) { + window.location.href = `/?study=${data.study_name}`; + } else { + if (status) status.textContent = "Failed to launch demo. Try again."; + if (btn) btn.disabled = false; + } + } catch (e) { + if (status) status.textContent = "Connection error. Is the broker running?"; + if (btn) btn.disabled = false; + } +} +window.launchDemo = launchDemo; + window.addEventListener("hashchange", () => window.handleRouting()); diff --git a/web/js/modal.js b/web/js/modal.js deleted file mode 100644 index 5d7ba87..0000000 --- a/web/js/modal.js +++ /dev/null @@ -1,185 +0,0 @@ -function openTrialDetails(trialNum) { - const trial = window.HPOState.data.trials.find(t => t.number === trialNum); - if (!trial) return; - - const titleEl = document.getElementById("modal-trial-title"); - if (titleEl) titleEl.textContent = `Trial ${trial.number} Inspector [ID: ${trial.trial_id}]`; - - const pGrid = document.getElementById("modal-params"); - if (pGrid) { - pGrid.replaceChildren(); - const displayParams = trial.params_display || trial.params; - Object.entries(displayParams).forEach(([k, v]) => { - const displayVal = typeof v === "number" ? (v % 1 === 0 ? String(v) : v.toExponential(2)) : String(v); - const item = document.createElement("div"); - item.className = "param-item"; - const label = document.createElement("div"); - label.className = "param-label"; - label.textContent = k.replace(/_/g, " "); - const value = document.createElement("div"); - value.className = "param-value"; - value.textContent = displayVal; - item.appendChild(label); - item.appendChild(value); - pGrid.appendChild(item); - }); - if (trial.dice_eval_fixed != null) { - const item = document.createElement("div"); - item.className = "param-item"; - const label = document.createElement("div"); - label.className = "param-label"; - label.textContent = window.HPOState.data.hpoConfig?.eval_protocol?.dice_fixed_label || "Dice (eval)"; - const value = document.createElement("div"); - value.className = "param-value"; - value.textContent = trial.dice_eval_fixed.toFixed(4); - item.appendChild(label); - item.appendChild(value); - pGrid.appendChild(item); - } - let ledClass = "complete"; - let stateLabel = trial.state; - - if (trial.state === "COMPLETE") { - ledClass = "complete"; - stateLabel = "DONE"; - } else if (trial.oom_triggered || trial.failure_tag === "OOM" || trial.state === "FAIL") { - ledClass = "fail"; - stateLabel = (trial.oom_triggered || trial.failure_tag === "OOM") ? "FAILED (OOM)" : "FAILED"; - } else if (trial.state === "PRUNED") { - ledClass = "pruned"; - stateLabel = "PRUNED"; - } else if (trial.state === "RUNNING") { - ledClass = "running"; - stateLabel = "RUNNING"; - } - - const statusItem = document.createElement("div"); - statusItem.className = `param-item status-param-item status-cell-${ledClass}`; - const statusLabel = document.createElement("div"); - statusLabel.className = "param-label"; - statusLabel.textContent = "State"; - const statusValue = document.createElement("div"); - statusValue.className = "param-value status-cell-wrapper"; - statusValue.style.marginTop = "4px"; - const led = document.createElement("span"); - led.className = `led-light ${ledClass}`; - const statusText = document.createElement("span"); - statusText.className = "status-text"; - statusText.textContent = stateLabel; - statusValue.appendChild(led); - statusValue.appendChild(statusText); - statusItem.appendChild(statusLabel); - statusItem.appendChild(statusValue); - pGrid.appendChild(statusItem); - } - - const oomSection = document.getElementById("modal-oom-section"); - const oomDetail = document.getElementById("modal-oom-detail"); - const isOom = trial.oom_triggered || trial.failure_tag === 'OOM'; - if (oomSection && oomDetail) { - if (isOom) { - oomSection.style.display = "block"; - let oomText = "This trial crashed with an out-of-memory error before completing."; - if (trial.gpu_model) oomText += ` GPU: ${trial.gpu_model}.`; - if (trial.max_vram_gb != null) oomText += ` Peak VRAM: ${trial.max_vram_gb.toFixed(1)} GB.`; - oomText += " TPE will automatically steer future suggestions away from this configuration."; - oomDetail.innerText = oomText; - } else { - oomSection.style.display = "none"; - } - } - - // Metric validation health warning - const warnSection = document.getElementById("modal-health-warning-section"); - const warnDetail = document.getElementById("modal-health-warning-detail"); - if (warnSection && warnDetail) { - if (trial.health_reason) { - warnSection.style.display = "block"; - warnDetail.textContent = trial.health_reason; - } else { - warnSection.style.display = "none"; - } - } - - // Environment info section - const envSection = document.getElementById("modal-env-section"); - const envFields = ["python_version", "platform", "hostname", "git_commit", "cuda_version", "dataset_version", "pip_freeze"]; - const hasEnvData = envFields.some(f => trial[f] !== undefined && trial[f] !== null && trial[f] !== ""); - if (envSection) { - if (hasEnvData) { - envSection.style.display = "block"; - document.getElementById("modal-env-hostname").textContent = trial.hostname || "β€”"; - document.getElementById("modal-env-platform").textContent = trial.platform || "β€”"; - document.getElementById("modal-env-python").textContent = trial.python_version || "β€”"; - document.getElementById("modal-env-cuda").textContent = trial.cuda_version || "β€”"; - document.getElementById("modal-env-git").textContent = trial.git_commit || "β€”"; - document.getElementById("modal-env-dataset").textContent = trial.dataset_version || "β€”"; - - const pipTextarea = document.getElementById("modal-env-pip"); - if (pipTextarea) { - pipTextarea.value = trial.pip_freeze || "No pip freeze recorded."; - pipTextarea.parentElement.style.display = trial.pip_freeze ? "flex" : "none"; - } - - // Start collapsed - const content = document.getElementById("modal-env-content"); - const chevron = document.getElementById("modal-env-chevron"); - if (content) content.style.display = "none"; - if (chevron) chevron.style.transform = "rotate(180deg)"; - } else { - envSection.style.display = "none"; - } - } - - const reasoningEl = document.getElementById("modal-reasoning"); - const rationaleTitle = document.getElementById("modal-rationale-title"); - const logic = window.HPOState.data.thoughtLogs.find(l => l.trial_id === trial.trial_id); - if (reasoningEl && rationaleTitle) { - if (logic) { - rationaleTitle.innerText = "Coordinator Rationale"; - reasoningEl.innerText = `"${logic.predicted_outcome_rationale}"`; - } else { - rationaleTitle.innerText = "TPE Suggestion"; - reasoningEl.innerText = `"Optuna TPE sampled this configuration based on the observed score landscape. No coordinator override was active for this trial."`; - } - } - - window.HPOState.ui.isModalOpen = true; - document.getElementById("detail-modal")?.classList.add("active"); - requestAnimationFrame(() => window.updateModalChart(trial.history)); -} - -function closeModal(event) { - if (event.target.id === "detail-modal") { - closeModalDirect(); - } -} - -function closeModalDirect() { - document.getElementById("detail-modal")?.classList.remove("active"); - window.HPOState.ui.isModalOpen = false; - if (window.HPOState.render.pendingRender) { - window.HPOState.render.pendingRender = false; - window.renderStudyDetails(window.HPOState.data.latestStudyData); - } -} - -function toggleModalEnvCard() { - const content = document.getElementById("modal-env-content"); - const chevron = document.getElementById("modal-env-chevron"); - if (!content || !chevron) return; - - const isHidden = content.style.display === "none"; - if (isHidden) { - content.style.display = "flex"; - chevron.style.transform = "rotate(0deg)"; - } else { - content.style.display = "none"; - chevron.style.transform = "rotate(180deg)"; - } -} - -window.openTrialDetails = openTrialDetails; -window.closeModal = closeModal; -window.closeModalDirect = closeModalDirect; -window.toggleModalEnvCard = toggleModalEnvCard; diff --git a/web/js/onboarding.js b/web/js/onboarding.js deleted file mode 100644 index 3e17885..0000000 --- a/web/js/onboarding.js +++ /dev/null @@ -1,313 +0,0 @@ -// web/js/onboarding.js β€” Handles manifest-based onboarding modal lifecycle and validation. - -(function () { - const DEFAULT_TEMPLATE = `study_name: segment_hpo -metrics: - primary_score: dice - objectives: - - name: dice - direction: maximize - label: Dice Score - - name: loss - direction: minimize - label: BCE Loss -params: - - name: lr - type: float_log - min: 0.0001 - max: 0.1 - - name: batch_size - type: categorical - options: [16, 32, 64] - - name: optimizer - type: categorical - options: ["adam", "sgd"] - - name: num_epochs - type: fixed - value: 10 -eval_protocol: - enabled: true - fixed_resolution: 512 - train_resolution_param: resolution -worker: - entrypoint: python train.py --lr {lr} --batch_size {batch_size} - env: - CUDA_VISIBLE_DEVICES: "0"`; - - let validationTimeout = null; - - function showNewStudyModal() { - const modal = document.getElementById("new-study-modal"); - const editor = document.getElementById("manifest-yaml-editor"); - const consoleEl = document.getElementById("manifest-validation-console"); - const forceCheck = document.getElementById("manifest-force-check"); - const submitBtn = document.getElementById("btn-submit-manifest"); - const fileInput = document.getElementById("manifest-file-input"); - - if (fileInput) fileInput.value = ""; - if (forceCheck) forceCheck.checked = false; - if (submitBtn) submitBtn.disabled = true; - - if (editor && (!editor.value || editor.value.trim() === "")) { - editor.value = DEFAULT_TEMPLATE; - } - - if (modal) { - modal.classList.add("active"); - validateManifestOnClient(); - } - } - - function closeNewStudyModal(event) { - if (event && event.target.id === "new-study-modal") { - closeNewStudyModalDirect(); - } - } - - function closeNewStudyModalDirect() { - const modal = document.getElementById("new-study-modal"); - if (modal) { - modal.classList.remove("active"); - } - } - - async function validateManifestOnClient() { - const editor = document.getElementById("manifest-yaml-editor"); - const consoleEl = document.getElementById("manifest-validation-console"); - const submitBtn = document.getElementById("btn-submit-manifest"); - - if (!editor || !consoleEl) return; - - const yamlContent = editor.value; - if (!yamlContent || yamlContent.trim() === "") { - consoleEl.innerHTML = `
No manifest loaded yet. Drag-and-drop or select a file to begin.
`; - if (submitBtn) submitBtn.disabled = true; - return; - } - - consoleEl.innerHTML = `
Running validation check...
`; - - try { - const res = await fetch("/api/validate_manifest", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ yaml: yamlContent }) - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.detail || "Validation request failed"); - } - - const result = await res.json(); - - consoleEl.innerHTML = ""; - - if (!result.success && result.errors && result.errors.length > 0) { - if (submitBtn) submitBtn.disabled = true; - - const errTitle = document.createElement("div"); - errTitle.style.color = "var(--status-failed, #ef4444)"; - errTitle.style.fontWeight = "bold"; - errTitle.style.marginBottom = "6px"; - errTitle.textContent = `βœ— Validation Failed (${result.errors.length} error${result.errors.length > 1 ? 's' : ''}):`; - consoleEl.appendChild(errTitle); - - result.errors.forEach(err => { - const line = document.createElement("div"); - line.style.color = "#f87171"; - line.style.paddingLeft = "12px"; - line.style.textIndent = "-12px"; - line.style.marginBottom = "4px"; - line.textContent = `β€’ ${err}`; - consoleEl.appendChild(line); - }); - } else { - if (submitBtn) submitBtn.disabled = false; - - const okLine = document.createElement("div"); - okLine.style.color = "var(--status-complete, #10b981)"; - okLine.style.fontWeight = "bold"; - okLine.style.marginBottom = "6px"; - okLine.textContent = `βœ“ Manifest validation passed! Ready to initialize.`; - consoleEl.appendChild(okLine); - } - - if (result.warnings && result.warnings.length > 0) { - const warnTitle = document.createElement("div"); - warnTitle.style.color = "var(--status-pruned, #f59e0b)"; - warnTitle.style.fontWeight = "bold"; - warnTitle.style.marginTop = "10px"; - warnTitle.style.marginBottom = "6px"; - warnTitle.textContent = `⚠ Warnings (${result.warnings.length}):`; - consoleEl.appendChild(warnTitle); - - result.warnings.forEach(warn => { - const line = document.createElement("div"); - line.style.color = "#fbbf24"; - line.style.paddingLeft = "12px"; - line.style.textIndent = "-12px"; - line.style.marginBottom = "4px"; - line.textContent = `β€’ ${warn}`; - consoleEl.appendChild(line); - }); - } - } catch (err) { - if (submitBtn) submitBtn.disabled = true; - consoleEl.innerHTML = `
βœ— Validation Error:
-
${err.message}
`; - } - } - - function debounceValidation() { - if (validationTimeout) { - clearTimeout(validationTimeout); - } - validationTimeout = setTimeout(validateManifestOnClient, 300); - } - - async function submitManifest() { - const editor = document.getElementById("manifest-yaml-editor"); - const consoleEl = document.getElementById("manifest-validation-console"); - const forceCheck = document.getElementById("manifest-force-check"); - const submitBtn = document.getElementById("btn-submit-manifest"); - - if (!editor) return; - - const yamlContent = editor.value; - const force = forceCheck ? forceCheck.checked : false; - - if (submitBtn) { - submitBtn.disabled = true; - submitBtn.textContent = "Initializing..."; - } - - consoleEl.innerHTML = `
Registering study...
`; - - try { - const res = await fetch(`/api/init_from_manifest?force=${force}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ yaml: yamlContent }) - }); - - const result = await res.json(); - - if (!res.ok) { - throw new Error(result.detail || "Server failed to register the study."); - } - - if (!result.success) { - if (result.errors) { - throw new Error("Validation errors returned: " + result.errors.join("; ")); - } else { - throw new Error(result.message || "Initialization failed"); - } - } - - consoleEl.innerHTML = `
βœ“ Study '${result.study_name}' registered successfully!
`; - - // Refresh study list and switch to the new study - if (window.populateStudyList) { - await window.populateStudyList(); - } - - // Select and redirect to the new study - const select = document.getElementById("study-select"); - if (select) { - select.value = result.study_name; - const url = new URL(window.location); - url.searchParams.set('study', result.study_name); - window.history.pushState({}, '', url); - - window.HPOState.session.studyName = result.study_name; - window.HPOState.tables.dashboard = { sort: { col: null, dir: null }, filters: {} }; - window.HPOState.tables.analysis = { sort: { col: null, dir: null }, filters: {} }; - window.HPOState.render.lastDashboardHeaderSnapshot = ""; - window.HPOState.render.lastAnalysisHeaderSnapshot = ""; - - if (window.fetchStudyDetails) window.fetchStudyDetails(); - if (window.fetchFanova) window.fetchFanova(); - if (window.fetchSearchSpace) window.fetchSearchSpace(); - if (window.fetchHpoConfig) window.fetchHpoConfig(); - } - - setTimeout(() => { - closeNewStudyModalDirect(); - if (typeof window.showToast === 'function') { - window.showToast("Study registered successfully! Go to the 'Worker Setup' tab to run your model."); - } - }, 1000); - - } catch (err) { - consoleEl.innerHTML = `
βœ— Initialization Failed:
-
${err.message}
`; - } finally { - if (submitBtn) { - submitBtn.disabled = false; - submitBtn.textContent = "Initialize Study"; - } - } - } - - // Set up drag and drop + file input listener once DOM loads - document.addEventListener("DOMContentLoaded", () => { - const uploadZone = document.getElementById("manifest-upload-zone"); - const fileInput = document.getElementById("manifest-file-input"); - const editor = document.getElementById("manifest-yaml-editor"); - - if (uploadZone && fileInput) { - uploadZone.addEventListener("click", () => { - fileInput.click(); - }); - - uploadZone.addEventListener("dragover", (e) => { - e.preventDefault(); - uploadZone.classList.add("dragover"); - }); - - uploadZone.addEventListener("dragleave", (e) => { - e.preventDefault(); - uploadZone.classList.remove("dragover"); - }); - - uploadZone.addEventListener("drop", (e) => { - e.preventDefault(); - uploadZone.classList.remove("dragover"); - if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { - const file = e.dataTransfer.files[0]; - readManifestFile(file); - } - }); - - fileInput.addEventListener("change", (e) => { - if (e.target.files && e.target.files.length > 0) { - const file = e.target.files[0]; - readManifestFile(file); - } - }); - } - - if (editor) { - editor.addEventListener("input", debounceValidation); - } - - function readManifestFile(file) { - const reader = new FileReader(); - reader.onload = (event) => { - if (editor) { - editor.value = event.target.result; - validateManifestOnClient(); - } - }; - reader.readAsText(file); - } - }); - - // Expose functions globally - window.showNewStudyModal = showNewStudyModal; - window.closeNewStudyModal = closeNewStudyModal; - window.closeNewStudyModalDirect = closeNewStudyModalDirect; - window.submitManifest = submitManifest; - window.validateManifestOnClient = validateManifestOnClient; -})(); diff --git a/web/js/settings.js b/web/js/settings.js index 3d98a13..1e6a519 100644 --- a/web/js/settings.js +++ b/web/js/settings.js @@ -1,48 +1,3 @@ -async function fetchPendingChanges(throwOnError = false) { - try { - const res = await fetch(`/api/pending_changes?study_name=${window.HPOState.session.studyName}`); - if (!res.ok) { if (throwOnError) throw new Error("HTTP " + res.status); return; } - const data = await res.json(); - const banner = document.getElementById("pending-changes-banner"); - const diffContainer = document.getElementById("pending-changes-diff"); - if (!banner || !diffContainer) return; - if (data && data.proposed_changes) { - window.HPOState.data.pendingChanges = data.proposed_changes; - const current = window.HPOState.data.activeSearchSpace || {}; - diffContainer.replaceChildren(); - for (const [key, val] of Object.entries(data.proposed_changes)) { - const curVal = current[key]; - if (!curVal) continue; - const infoLine = document.createElement("span"); - infoLine.className = "diff-line info"; - infoLine.textContent = `# Parameter: ${paramLabel(key)} (${key})`; - diffContainer.appendChild(infoLine); - if (curVal.type === "categorical") { - const curActive = curVal.active || [], newActive = val.active || []; - diffContainer.appendChild(Object.assign(document.createElement("span"), { className: "diff-line removed", textContent: `- active: [${curActive.join(", ")}]` })); - diffContainer.appendChild(Object.assign(document.createElement("span"), { className: "diff-line added", textContent: `+ active: [${newActive.join(", ")}]` })); - } else { - const curMin = curVal.min, curMax = curVal.max; - const newMin = val.min !== undefined ? val.min : curMin; - const newMax = val.max !== undefined ? val.max : curMax; - if (curMin !== newMin || curMax !== newMax) { - diffContainer.appendChild(Object.assign(document.createElement("span"), { className: "diff-line removed", textContent: `- range: [${curMin}, ${curMax}]` })); - diffContainer.appendChild(Object.assign(document.createElement("span"), { className: "diff-line added", textContent: `+ range: [${newMin}, ${newMax}]` })); - } - } - } - banner.classList.toggle("hidden", diffContainer.childNodes.length === 0); - } else { - window.HPOState.data.pendingChanges = null; - banner.classList.add("hidden"); - } - renderHealthLatestAction(); - } catch (err) { - console.error("Error fetching pending changes:", err); - if (throwOnError) throw err; - } -} - function populateEvalProtocolForm(config) { const ev = config?.eval_protocol || {}; const vr = config?.validation_rules || {}; @@ -50,13 +5,12 @@ function populateEvalProtocolForm(config) { const setCheck = (id, val) => { const el = document.getElementById(id); if (el) el.checked = !!val; }; set("eval-metric-loss-label", config?.metric_loss_label || "Loss"); set("eval-metric-score-label", config?.metric_score_label || "Score"); - setCheck("desktop-notifs-enabled", !!config?.desktop_notifications_enabled); setCheck("eval-enabled", ev.enabled); set("eval-fixed-resolution", ev.fixed_resolution ?? ""); set("eval-train-res-param", ev.train_resolution_param || "resolution"); set("eval-low-warn", ev.low_train_res_warning ?? ""); - set("eval-score-train-label", ev.dice_train_label || "Score (train)"); - set("eval-score-fixed-label", ev.dice_fixed_label || "Score (eval)"); + set("eval-score-train-label", ev.score_train_label || "Score (train)"); + set("eval-score-fixed-label", ev.score_fixed_label || "Score (eval)"); setCheck("eval-prune-on-fixed", ev.use_fixed_metric_for_pruning); // Validation rules setCheck("validation-rules-enabled", !!vr.enabled); @@ -85,8 +39,8 @@ async function saveEvalProtocol() { fixed_resolution: fixedRaw === "" || fixedRaw == null ? null : Number(fixedRaw), train_resolution_param: document.getElementById("eval-train-res-param")?.value || "resolution", low_train_res_warning: lowRaw === "" || lowRaw == null ? null : Number(lowRaw), - dice_train_label: document.getElementById("eval-score-train-label")?.value || "Score (train)", - dice_fixed_label: document.getElementById("eval-score-fixed-label")?.value || "Score (eval)", + score_train_label: document.getElementById("eval-score-train-label")?.value || "Score (train)", + score_fixed_label: document.getElementById("eval-score-fixed-label")?.value || "Score (eval)", use_fixed_metric_for_pruning: document.getElementById("eval-prune-on-fixed")?.checked || false, }, validation_rules: { @@ -115,49 +69,6 @@ async function saveEvalProtocol() { } catch (err) { alert("Error saving eval protocol: " + err); } } -async function saveIdeSettings() { - const payload = { - ...window.HPOState.data.hpoConfig, - desktop_notifications_enabled: !!document.getElementById("desktop-notifs-enabled")?.checked, - }; - try { - const studyName = window.HPOState.session.studyName || ''; - const res = await fetch(`/api/hpo_config?study_name=${encodeURIComponent(studyName)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!res.ok) { alert("Failed to save IDE settings"); return; } - const data = await res.json(); - window.HPOState.data.hpoConfig = data.config || data; - populateEvalProtocolForm(window.HPOState.data.hpoConfig); - alert("IDE integration settings saved successfully!"); - } catch (err) { alert("Error saving IDE settings: " + err); } -} - -async function applyPendingChanges() { - try { - const res = await fetch(`/api/apply_pending_changes?study_name=${window.HPOState.session.studyName}`, { method: "POST" }); - if (!res.ok) { const errData = await res.json(); alert("Error: " + (errData.detail || "Failed to apply changes")); return; } - const data = await res.json(); - if (data.success) { - window.HPOState.data.activeSearchSpace = data.space; - renderSearchSpace(); - renderDashboardSearchSpaceSummary(); - fetchPendingChanges(); - } - } catch (err) { console.error("Error applying pending changes:", err); } -} - -async function discardPendingChanges() { - try { - const res = await fetch(`/api/discard_pending_changes?study_name=${window.HPOState.session.studyName}`, { method: "POST" }); - if (!res.ok) { alert("Failed to discard changes"); return; } - const data = await res.json(); - if (data.success) { fetchPendingChanges(); } - } catch (err) { console.error("Error discarding pending changes:", err); } -} - function togglePill(param, option, btn) { const list = window.HPOState.data.activeSearchSpace[param].active; const val = isNaN(option) ? option : Number(option); @@ -306,12 +217,8 @@ function switchSettingsTab(tabId) { if (panel) panel.classList.add("active"); } -window.fetchPendingChanges = fetchPendingChanges; window.populateEvalProtocolForm = populateEvalProtocolForm; window.saveEvalProtocol = saveEvalProtocol; -window.saveIdeSettings = saveIdeSettings; -window.applyPendingChanges = applyPendingChanges; -window.discardPendingChanges = discardPendingChanges; window.togglePill = togglePill; window.applySingleParamConstraints = applySingleParamConstraints; window.renderSearchSpace = renderSearchSpace; diff --git a/web/js/table_filters.js b/web/js/table_filters.js index 75e0254..c1a9aba 100644 --- a/web/js/table_filters.js +++ b/web/js/table_filters.js @@ -1,5 +1,5 @@ function getPrimaryScoreKey() { - return window.HPOState.data.hpoConfig?.primary_score_key || "dice"; + return window.HPOState.data.hpoConfig?.primary_score_key || "score"; } function filterTrialsForDisplay(trials, paretoSet) { @@ -74,9 +74,9 @@ function applyTablePipeline(trials, tableId) { function cycleTableSort(tableId, colKey) { const sort = window.HPOState.tables[tableId].sort; - // Loss (bce) defaults to ascending (lower is better). - // Others (number/Trial, score/dice/eval, parameters) default to descending (higher/latest is better). - const defaultDir = (colKey === "bce") ? "asc" : "desc"; + // Loss defaults to ascending (lower is better). + // Others (number/Trial, score/eval, parameters) default to descending (higher/latest is better). + const defaultDir = (colKey === "loss") ? "asc" : "desc"; if (sort.col !== colKey) { sort.col = colKey; diff --git a/web/js/table_infra.js b/web/js/table_infra.js index 6c929cb..1992394 100644 --- a/web/js/table_infra.js +++ b/web/js/table_infra.js @@ -110,9 +110,9 @@ function makeTableResizable(table, force = false) { function getTrialCellValue(trial, colKey) { if (colKey === "number") return trial.number; if (colKey === "state") return trial.state; - if (colKey === "bce") return trial.bce; - if (colKey === "dice") return trial[getPrimaryScoreKey()] ?? trial.dice; - if (colKey === "dice_eval_fixed") return trial.dice_eval_fixed; + if (colKey === "loss") return trial.loss; + if (colKey === "score") return trial[getPrimaryScoreKey()] ?? trial.score; + if (colKey === "score_eval_fixed") return trial.score_eval_fixed; if (colKey.startsWith("param:")) return trial.params?.[colKey.slice(6)]; return null; } diff --git a/web/js/table_render.js b/web/js/table_render.js index d7cad78..0725fb6 100644 --- a/web/js/table_render.js +++ b/web/js/table_render.js @@ -31,7 +31,7 @@ function buildTrialsSnapshot(trials, paretoSet) { const base = trials .map( (t) => - `${t.number}|${t.state}|${t.bce ?? ""}|${t.dice ?? ""}|${paretoSet.has(t.number) ? 1 : 0}` + `${t.number}|${t.state}|${t.loss ?? ""}|${t.score ?? ""}|${paretoSet.has(t.number) ? 1 : 0}` ) .sort((a, b) => Number(a.split("|")[0]) - Number(b.split("|")[0])) .join(";"); @@ -41,7 +41,7 @@ function buildTrialsSnapshot(trials, paretoSet) { function buildFanovaSnapshot(trials) { return trials .filter((t) => t.state === "COMPLETE") - .map((t) => `${t.number}|${t.bce}|${t.dice}`) + .map((t) => `${t.number}|${t.loss}|${t.score}`) .join(";"); } @@ -65,8 +65,8 @@ function renderDashboardTableBody(displayTrials, paretoSet, data) { let rowsHtml = ""; piped.forEach((t) => { const isPareto = paretoSet.has(t.number); - const bceStr = formatMetric(t.bce); - const diceStr = formatMetric(t.dice); + const lossStr = formatMetric(t.loss); + const scoreStr = formatMetric(t.score); let cellsHtml = ""; paramKeys.forEach((k) => { const val = t.params[k]; @@ -82,12 +82,12 @@ function renderDashboardTableBody(displayTrials, paretoSet, data) { }); let trClass = t.state === "RUNNING" ? "running" : (isPareto ? "pareto" : (t.state === "PRUNED" ? "pruned" : (t.state === "FAIL" ? "failed" : ""))); const statusTdHtml = buildStatusTd(t.state, t.latest_epoch); - const fixedStr = formatMetric(t.dice_eval_fixed); + const fixedStr = formatMetric(t.score_eval_fixed); let rowHtml = ` #${t.number} ${statusTdHtml} - ${bceStr} - ${diceStr}`; + ${lossStr} + ${scoreStr}`; if (ev.enabled) rowHtml += `${fixedStr}`; rowHtml += `${cellsHtml} ${isPareto ? "β˜…" : ""} @@ -131,9 +131,9 @@ function renderAnalysisTableBody(displayTrials) { } let html = ""; piped.forEach((t) => { - const bceStr = formatMetric(t.bce, "β€”"); - const diceStr = formatMetric(t.dice, "β€”"); - const fixedStr = formatMetric(t.dice_eval_fixed, "β€”"); + const lossStr = formatMetric(t.loss, "β€”"); + const scoreStr = formatMetric(t.score, "β€”"); + const fixedStr = formatMetric(t.score_eval_fixed, "β€”"); let cellsHtml = ""; paramKeys.forEach((k) => { const val = t.params[k]; @@ -151,8 +151,8 @@ function renderAnalysisTableBody(displayTrials) { html += ` #${t.number} ${statusTdHtml} - ${bceStr} - ${diceStr} + ${lossStr} + ${scoreStr} ${ev.enabled ? `${fixedStr}` : ""} ${cellsHtml} @@ -176,7 +176,7 @@ function applyAnalysisTableHeaders() { const lossLabel = window.HPOState.data.hpoConfig?.metric_loss_label || "Loss"; const scoreLabel = window.HPOState.data.hpoConfig?.metric_score_label || "Score"; - const evalLabel = ev.dice_fixed_label || "Score (eval)"; + const evalLabel = ev.score_fixed_label || "Score (eval)"; const headerSnapshot = `${lossLabel}|${scoreLabel}|${evalLabel}|${ev.enabled}|${paramKeys.join(",")}`; if (headerSnapshot === window.HPOState.render.lastAnalysisHeaderSnapshot) { @@ -188,10 +188,10 @@ function applyAnalysisTableHeaders() { if (tableHeader) { let thHtml = buildSortableTh("Trial", "number", "analysis"); thHtml += buildSortableTh("State", "state", "analysis"); - thHtml += buildSortableTh(lossLabel, "bce", "analysis"); - thHtml += buildSortableTh(scoreLabel, "dice", "analysis"); + thHtml += buildSortableTh(lossLabel, "loss", "analysis"); + thHtml += buildSortableTh(scoreLabel, "score", "analysis"); if (ev.enabled) { - thHtml += buildSortableTh(evalLabel, "dice_eval_fixed", "analysis"); + thHtml += buildSortableTh(evalLabel, "score_eval_fixed", "analysis"); } paramKeys.forEach(k => { const label = paramLabels[k] || k.replace(/_/g, " "); @@ -238,7 +238,7 @@ function renderStudyDetails(data) { if (tableHeader) { const lossLabel = data.hpo_config?.metric_loss_label || "Loss"; const scoreLabel = data.hpo_config?.metric_score_label || "Score"; - const evalLabel = ev.dice_fixed_label || "Score (eval)"; + const evalLabel = ev.score_fixed_label || "Score (eval)"; const headerSnapshot = `${lossLabel}|${scoreLabel}|${evalLabel}|${ev.enabled}|${paramKeys.join(",")}`; if (headerSnapshot !== window.HPOState.render.lastDashboardHeaderSnapshot) { @@ -246,10 +246,10 @@ function renderStudyDetails(data) { let thHtml = buildSortableTh("Trial", "number", "dashboard"); thHtml += buildSortableTh("State", "state", "dashboard"); - thHtml += buildSortableTh(lossLabel, "bce", "dashboard"); - thHtml += buildSortableTh(scoreLabel, "dice", "dashboard"); + thHtml += buildSortableTh(lossLabel, "loss", "dashboard"); + thHtml += buildSortableTh(scoreLabel, "score", "dashboard"); if (ev.enabled) { - thHtml += buildSortableTh(evalLabel, "dice_eval_fixed", "dashboard"); + thHtml += buildSortableTh(evalLabel, "score_eval_fixed", "dashboard"); } paramKeys.forEach(k => { const label = paramLabels[k] || k.replace(/_/g, " "); @@ -369,10 +369,10 @@ function applyEvalInsightsUi() { grid.innerHTML = keys .map((res) => { const s = summary[res]; - const trainBestVal = s.best_score_train !== undefined ? s.best_score_train : s.best_dice_train; + const trainBestVal = s.best_score_train; const trainBest = trainBestVal != null ? trainBestVal.toFixed(4) : "β€”"; - const fixedBestVal = s.best_score_fixed !== undefined ? s.best_score_fixed : s.best_dice_fixed; + const fixedBestVal = s.best_score_fixed; const fixedBest = fixedBestVal != null ? fixedBestVal.toFixed(4) : "β€”"; // Capitalize parameter label for individual chips @@ -386,9 +386,7 @@ function applyEvalInsightsUi() { const scoreLabel = window.HPOState.data.hpoConfig?.metric_score_label || "Score"; if (deployLabel && window.HPOState.data.evalInsights?.best_deploy_trial_number != null) { - const d = window.HPOState.data.evalInsights.best_deploy_score_fixed !== undefined - ? window.HPOState.data.evalInsights.best_deploy_score_fixed - : window.HPOState.data.evalInsights.best_deploy_dice_fixed; + const d = window.HPOState.data.evalInsights.best_deploy_score_fixed; deployLabel.textContent = `Best fixed-eval: Trial #${window.HPOState.data.evalInsights.best_deploy_trial_number}${d != null ? ` Β· ${scoreLabel} ${d.toFixed(4)}` : ""}`; } else if (deployLabel) { deployLabel.textContent = ""; diff --git a/web/js/theme.js b/web/js/theme.js index 5e25dfc..ee4ba79 100644 --- a/web/js/theme.js +++ b/web/js/theme.js @@ -80,7 +80,7 @@ function changeAccent(accentName, colorHex, glowStyle) { if (window.HPOState.charts.modalHistory.instance && window.HPOState.charts.modalHistory.instance.data && window.HPOState.charts.modalHistory.instance.data.datasets) { window.HPOState.charts.modalHistory.instance.data.datasets.forEach(ds => { - if (ds.yAxisID === "y-dice") { + if (ds.yAxisID === "y-score") { ds.borderColor = colorHex; } }); diff --git a/web/js/utils.js b/web/js/utils.js index a35bcf4..1f896f9 100644 --- a/web/js/utils.js +++ b/web/js/utils.js @@ -244,6 +244,15 @@ function hideEmptyState(containerId, recreateCanvas = false) { } } +function initHpoMarks() { + const svg = window.HPOState?.constants?.HPO_MARK_SVG; + if (!svg) return; + document.querySelectorAll(".brand-mark").forEach((el) => { + el.innerHTML = svg; + }); +} + window.showEmptyState = showEmptyState; window.hideEmptyState = hideEmptyState; +window.initHpoMarks = initHpoMarks; diff --git a/web/js/worker_setup.js b/web/js/worker_setup.js deleted file mode 100644 index cf13658..0000000 --- a/web/js/worker_setup.js +++ /dev/null @@ -1,231 +0,0 @@ -/* === Worker Setup & Colab Integration === */ - -async function updateColabSnippet() { - // Determine current host base URL - const protocol = window.location.protocol; - const host = window.location.host; - let baseUrl = `${protocol}//${host}`; - - // Handle custom inputs - const inputEl = document.getElementById("ngrok-url-input"); - const colabInputEl = document.getElementById("ngrok-url-input-colab"); - const customInput = inputEl ? inputEl.value.trim() : ""; - - if (customInput) { - baseUrl = customInput; - if (baseUrl.endsWith('/')) { - baseUrl = baseUrl.slice(0, -1); - } - } else { - // Pre-fill the input with the current browser origin if empty - if (inputEl) { - inputEl.value = baseUrl; - } - } - - // Keep the Colab input field in sync - if (colabInputEl && colabInputEl.value !== baseUrl) { - colabInputEl.value = baseUrl; - } - - // Toggle Colab warning if the active broker URL is localhost / 127.0.0.1 / [::1] - const isLocalhost = baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1") || baseUrl.includes("[::1]"); - const warningBanner = document.getElementById("colab-warning-banner"); - const warningUrlSpan = document.getElementById("colab-warning-url"); - - if (warningBanner) { - if (isLocalhost) { - warningBanner.style.display = "block"; - if (warningUrlSpan) { - warningUrlSpan.textContent = baseUrl; - } - } else { - warningBanner.style.display = "none"; - } - } - - const tokenLine = isLocalhost - ? "" - : `os.environ["HPO_SECRET_TOKEN"] = "paste-same-token-as-broker"\n`; - - const studyName = window.HPOState.session.studyName || 'bridge_crack_study'; - - let isReference = true; - let workerEntrypoint = null; - let workerEnv = null; - let colabSnippet = null; - - try { - const response = await fetch(`/api/study_setup?study_name=${encodeURIComponent(studyName)}`); - if (response.ok) { - const data = await response.json(); - if (data.success) { - isReference = data.is_reference; - workerEntrypoint = data.worker_entrypoint; - workerEnv = data.worker_env; - colabSnippet = data.colab_snippet; - } - } - } catch (err) { - console.error("Error fetching study setup:", err); - } - - // Render the Colab snippet text - let snippet = ""; - if (colabSnippet) { - snippet = colabSnippet - .replace(/\$\{baseUrl\}/g, baseUrl) - .replace(/\{baseUrl\}/g, baseUrl) - .replace(/\$\{studyName\}/g, studyName) - .replace(/\{studyName\}/g, studyName); - } else if (isReference) { - snippet = `# 1. Install required packages -!pip install -q albumentations opencv-python optuna requests sqlalchemy - -# 2. Download worker + client (token header required when broker uses --tunnel) -import requests -import os - -broker_url = "${baseUrl}" -os.environ["HPO_BROKER_URL"] = broker_url -${tokenLine} -def _broker_headers(): - h = {} - if "ngrok-free.app" in broker_url or "ngrok.io" in broker_url: - h["ngrok-skip-browser-warning"] = "1" - if os.environ.get("HPO_SECRET_TOKEN"): - h["X-HPO-Token"] = os.environ["HPO_SECRET_TOKEN"] - return h - -for _name in ("hpo_client.py", "colab_worker.py"): - _r = requests.get(f"{broker_url}/{_name}", headers=_broker_headers(), timeout=60) - _r.raise_for_status() - with open(_name, "w") as _f: - _f.write(_r.text) - -# 3. Import and run the reference worker loop -from colab_worker import train_colab_trial_loop -train_colab_trial_loop("${studyName}", n_trials=12, epochs=15)`; - } else { - const envExports = []; - if (workerEnv && typeof workerEnv === 'object') { - for (const [k, v] of Object.entries(workerEnv)) { - envExports.push(`os.environ["${k}"] = "${v}"`); - } - } - const envLines = envExports.length ? envExports.join("\n") + "\n" : ""; - - if (workerEntrypoint) { - snippet = `# 1. Install required packages -!pip install -q optuna requests sqlalchemy - -# 2. Download HPO client (token header required when broker uses --tunnel) -import requests -import os - -broker_url = "${baseUrl}" -os.environ["HPO_BROKER_URL"] = broker_url -os.environ["HPO_STUDY_NAME"] = "${studyName}" -${tokenLine}${envLines} -def _broker_headers(): - h = {} - if "ngrok-free.app" in broker_url or "ngrok.io" in broker_url: - h["ngrok-skip-browser-warning"] = "1" - if os.environ.get("HPO_SECRET_TOKEN"): - h["X-HPO-Token"] = os.environ["HPO_SECRET_TOKEN"] - return h - -for _name in ("hpo_client.py",): - _r = requests.get(f"{broker_url}/{_name}", headers=_broker_headers(), timeout=60) - _r.raise_for_status() - with open(_name, "w") as _f: - _f.write(_r.text) - -# 3. Run your training entrypoint (make sure your training script is uploaded to Colab) -!${workerEntrypoint}`; - } else { - snippet = `# 1. Install required packages -!pip install -q optuna requests sqlalchemy - -# 2. Download worker template + client (token header required when broker uses --tunnel) -import requests -import os - -broker_url = "${baseUrl}" -os.environ["HPO_BROKER_URL"] = broker_url -os.environ["HPO_STUDY_NAME"] = "${studyName}" -${tokenLine}${envLines} -def _broker_headers(): - h = {} - if "ngrok-free.app" in broker_url or "ngrok.io" in broker_url: - h["ngrok-skip-browser-warning"] = "1" - if os.environ.get("HPO_SECRET_TOKEN"): - h["X-HPO-Token"] = os.environ["HPO_SECRET_TOKEN"] - return h - -for _name in ("hpo_client.py", "worker_minimal.py"): - _r = requests.get(f"{broker_url}/{_name}", headers=_broker_headers(), timeout=60) - _r.raise_for_status() - with open(_name, "w") as _f: - _f.write(_r.text) - -# 3. Fill in train_one_epoch inside worker_minimal.py and run -# !python worker_minimal.py`; - } - } - - const snippetTextEl = document.getElementById("colab-snippet-text"); - if (snippetTextEl) { - snippetTextEl.textContent = snippet; - } - - let customSnippet = `export HPO_BROKER_URL="${baseUrl}" -export HPO_STUDY_NAME="${studyName}"\n`; - - if (workerEnv && typeof workerEnv === 'object') { - for (const [k, v] of Object.entries(workerEnv)) { - customSnippet += `export ${k}="${v}"\n`; - } - } - if (workerEntrypoint) { - customSnippet += `${workerEntrypoint}`; - } else { - customSnippet += `python worker_minimal.py`; - } - - const customSnippetEl = document.getElementById("custom-worker-snippet"); - if (customSnippetEl) { - customSnippetEl.textContent = customSnippet; - } -} - -function syncColabUrl(val) { - const mainInputEl = document.getElementById("ngrok-url-input"); - if (mainInputEl) { - mainInputEl.value = val; - } - updateColabSnippet(); -} - -function copyColabSnippet(btn) { - const snippetTextEl = document.getElementById("colab-snippet-text"); - if (!snippetTextEl) return; - const snippetText = snippetTextEl.textContent; - navigator.clipboard.writeText(snippetText).then(() => { - const targetBtn = btn || document.querySelector("button[onclick^='copyColabSnippet']"); - if (targetBtn) { - const originalText = targetBtn.textContent; - targetBtn.textContent = "Copied!"; - setTimeout(() => { - targetBtn.textContent = originalText; - }, 2000); - } - }).catch(err => { - console.error("Could not copy text: ", err); - }); -} - -// Window exports -window.updateColabSnippet = updateColabSnippet; -window.syncColabUrl = syncColabUrl; -window.copyColabSnippet = copyColabSnippet; diff --git a/web/styles.css b/web/styles.css index 748d2fd..325edd8 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1278,7 +1278,7 @@ .trial-table .col-trial { width: 60px; min-width: 60px; } .trial-table .col-state { width: 100px; } .trial-table .col-metric { width: 72px; } - .trial-table .col-dice-fixed { width: 72px; } + .trial-table .col-score-fixed { width: 72px; } .trial-table .col-lr { width: 64px; } .trial-table .col-bs { width: 44px; } .trial-table .col-res { width: 44px; } From 790bf91fae14147a3d5c2478298efd3982144650 Mon Sep 17 00:00:00 2001 From: Ishaan Date: Tue, 30 Jun 2026 10:38:59 -0400 Subject: [PATCH 6/8] archive, delete unused documentation --- AGENTS.md | 40 +-- CLAUDE.md | 80 ----- README.md | 319 ++++++++++-------- colab_worker.py => archive/colab_worker.py | 0 docs/INTEGRATION.md | 168 ++++----- simulators/training_worker.py | 45 +-- ...unet_crack_segmentation_test_model_card.md | 28 +- templates/worker_minimal.py | 13 +- 8 files changed, 321 insertions(+), 372 deletions(-) delete mode 100644 CLAUDE.md rename colab_worker.py => archive/colab_worker.py (100%) diff --git a/AGENTS.md b/AGENTS.md index ecc8c49..e088e90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ coordinator without an explicit user request.** ## MCP setup (one time, shared across IDEs) All three IDEs use the same server `pathfinder` (`python hpo_mcp_server.py`) and the -same database. See the README "Exposing Pathfinder to AI Agents" section for the exact config block. +same database. See the README "IDE Setup (Agent-Driven Onboarding & Inspection)" section for the exact config block. Capability does not differ between Cursor, Antigravity, and Claude Code. Use one IDE at a time for writes; reviews are idempotent per trial window, so a second client will not double-write. @@ -51,7 +51,7 @@ Trigger this when the user says "integrate HPO", "onboard my training script", " 7. **Document the GPU side.** Tell the user to set `HPO_BROKER_URL` and `HPO_STUDY_NAME` on the training machine. The study name must match the manifest. Enable `HPO_SPARKLINES=1` if they want a Unicode performance curve printed on trial completion. -The worker contract is exactly three calls; full reference in `docs/INTEGRATION.md`. Load the grill checklist from `hpo://prompts/grill` (not a separate integration-guide tool). +The worker contract is exactly three calls; full reference in `docs/INTEGRATION.md`. ### Statistical confidence (caveat, not a gate) @@ -59,34 +59,19 @@ The worker contract is exactly three calls; full reference in `docs/INTEGRATION. | Tier | Completed trials | Agent behavior | |------|------------------|----------------| -| `low` | < 10 | Treat fANOVA/Spearman as noisy; use `estimated_score_improvement=-1.0` when uncertain | +| `low` | < 10 | Treat fANOVA/Spearman as noisy; be cautious with interpretation | | `medium` | 10–19 | Signals stabilizing; stay cautious on large bound shifts | | `high` | β‰₯ 20 | Standard interpretation | Reviews are never hard-blocked at low confidence β€” the dashboard shows a banner only. -## Coordinator procedure (episodic review) +## Inspection flow -When the dashboard shows a health warning (Watch or Intervene) and the brand-mark double pings (white for Watch, red for Intervene), run the 7-step review. The review prompt can be loaded from the MCP resource `hpo://prompts/review`. +When the user asks about study progress, trial results, or health: -1. Call `get_study_data(study_name)` to retrieve the compacted statistical and telemetry packet (`statistical_confidence`, `coordinator_accuracy`, `past_reviews`). -2. Read dynamic metrics from `project_context` and evaluate fANOVA importances and Spearman correlations; heed `statistical_confidence` when low/medium. -3. Perform a safety review of VRAM predictions (`bounds_oom_risk`) and review the last 3 `past_reviews` β€” **ignore reviews where `quality_flagged` is true**. -4. **Coordinator accuracy self-regulation:** `coordinator_accuracy` tracks review forecasts vs. measured best-score deltas (not trial-suggest logs). If `insufficient_data` is true (`n_scored_reviews` < 3), do not self-regulate yet. If `mean_absolute_error` > 0.05 with `n_scored_reviews` β‰₯ 3, propose smaller bound shifts. -5. Propose active search space adjustments via `update_search_space(study_name, space_config, apply=False)`. -6. Submit with `submit_agent_review` β€” **required:** `estimated_score_improvement` (float) and `cited_best_trial` (int). Use `-1.0` when < 5 completed trials. Human approves via dashboard **Apply Proposal** or `update_search_space(apply=True)`. -7. Call `generate_model_card(study_name)` to write the model card and index it in the database. - -### Coordinator accuracy semantics (honest correlational accountability) - -At review time the system snapshots `baseline_best_score`. After the human applies a bounds patch and **β‰₯ 5 new COMPLETE trials** finish, `backfill_review_outcomes` sets `actual_score_improvement = new_best - baseline`. MAE uses only `outcome_status=measured` reviews (excludes sentinel `-1.0`, `inconclusive` prune-storm windows, and `quality_flagged` rows). This does **not** prove causation β€” it measures whether the coordinator's numeric forecast matched the best-score delta N trials later. Flag bad reviews: `python hpo_cli.py flag-review --id N` or the dashboard **Flag** button. - -## Guardrails (always) - -- Never block the GPU worker on an LLM; the suggest path stays TPE. -- Never auto-invoke the coordinator from the broker, dashboard, or a hook - only on user request. -- Do not write/modify local JSON files for search space or configuration (e.g. `active_search_space.json`, `hpo_config.json`); state must reside in SQLite. -- Do not modify the root `colab_worker.py` unless the user owns the bridge-crack project. Cloners use `templates/`. +1. Call `get_study_data(study_name)` to retrieve trial telemetry, health tier, fANOVA importances, best trials, and OOM patterns. +2. Summarize: current best score, health status, stagnation indicators, and any OOM warnings. +3. If health is `watch` or `intervene`, explain the reason and offer to explore the data further. ## IDE triggers & status polling (.hpo_status.json) @@ -94,6 +79,13 @@ When a completed trial is reported or the background daemon polls health, the sy **Agent Action (Cursor, Antigravity, and Claude Code)**: * Eagerly check for the existence of `.hpo_status.json` in the project root. -* If `health_tier` is `"watch"` or `"intervene"`, immediately notify the user in your first response, explain the `health_reason`, and proactively offer to run the **Coordinator procedure (episodic review)** described above. +* If `health_tier` is `"watch"` or `"intervene"`, immediately notify the user in your first response, explain the `health_reason`, and proactively offer to inspect the study data. * Offer to use the `/goal` slash command to help coordinate and automate resolution of search space stagnation or failures. +## Guardrails (always) + +- Never block the GPU worker on an LLM; the suggest path stays TPE. +- Never auto-invoke the coordinator from the broker, dashboard, or a hook - only on user request. +- Do not write/modify local JSON files for search space or configuration (e.g. `active_search_space.json`, `hpo_config.json`); state must reside in SQLite. +- Do not modify the root `colab_worker.py` unless the user owns the bridge-crack project. Cloners use `templates/`. +- Never modify `archive/` files β€” they are historical reference only. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index bb0a244..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,80 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Development & Test Commands - -- **Environment Setup**: - ```bash - python3 -m venv .venv - source .venv/bin/activate - pip install -r requirements.txt - ``` -- **Run FastAPI Broker Server (serves Custom Dashboard on http://127.0.0.1:8000)**: - ```bash - python3 broker.py --daemon # local only - export HPO_SECRET_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')" - python3 broker.py --daemon --tunnel # Colab / remote GPU (auth required) - ``` -- **Run Simulated Training Worker / GPU Runner**: - ```bash - # Standard worker simulation: - python3 simulators/training_worker.py - - # Or run a specific study: - HPO_BROKER_URL=http://localhost:8000 HPO_STUDY_NAME=your_study_name python3 simulators/training_worker.py - ``` -- **Launch Fallback Optuna Dashboard**: - ```bash - optuna-dashboard sqlite:///hpo_studies.db - ``` -- **Run Integration Tests**: - ```bash - .venv/bin/python3 tests/test_integration.py - ``` - ---- - -## High-Level Architecture - -This project implements a decentralized **Worker-Broker-Registry** pattern for hyperparameter tuning, specifically optimized for a **U-Net crack segmentation project** (and adaptable via templates). It avoids blocking training workers on LLM calls. - -``` -+---------------------------+ MCP +---------------------------+ -| AI Assistant (IDE) | <-----------------> | FastMCP Server (Local) | -+---------------------------+ +---------------------------+ - | | - Reads Stats Read / Write - v v -+-----------------------------------------------------------------------------+ -| Database (Local SQLite / Hosted Cloud PostgreSQL) | -+-----------------------------------------------------------------------------+ - ^ - Read / Write - | - +---------------------------+ - | Training Worker (Any Box)| ---> [ U-Net Pipeline ] - +---------------------------+ -``` - -### Architecture Components - -1. **SQLite Database (`hpo_studies.db`)**: The **single source of truth** for all persistence. Holds both Optuna's trial states and custom metadata tables (`trial_results`, `system_configuration`, `study_reviews`, `study_status`, `agent_reasoning_logs`, `invalid_proposals`). -2. **FastAPI Broker (`broker.py`)**: Thin HTTP broker API exposing endpoints for workers (`/api/suggest_trial`, `/api/report_epoch`, `/api/complete_trial`) and the dashboard web interface (`/api/hpo_config`, `/api/review_packet`). -3. **MCP Server (`hpo_mcp_server.py`)**: FastMCP server exposing tools for human-in-the-loop coordination, onboarding, and reviews to IDE agents. -4. **Decoupled Worker (`src/hpo_client.py`, `colab_worker.py`, `simulators/training_worker.py`)**: Interacts exclusively via HTTP using the 3-step life cycle (`suggest` -> `report_epoch` -> `complete`). Colab reference: `train_colab_trial` (one trial) and `train_colab_trial_loop` (repeated session). -5. **Decoupled Evaluator (Interactive Dashboard)**: Custom dashboard calling the FastAPI routes to view trials, Pareto fonts, fANOVA parameter importances, and toggle coordinator reviews. - ---- - -## Code Style & Development Guidelines - -1. **State & DB Isolation**: State MUST live in SQLite. Do **not** write or persist temporary configurations to files on disk like `active_search_space.json` or `hpo_config.json`. Always load/save via the `SystemConfiguration` ORM table. -2. **Database Resilience**: Column additions or schema model changes should be registered in `src/db_manager.py:__ADDITIVE_COLUMNS` to handle additive, idempotent migrations on runtime initialization instead of dropping tables. -3. **Coordinator Reviews (Episodic LLM)**: IDE agents act as **episodic coordinators**. Optuna (TPE) is the hot sampling path; workers NEVER block on language models. -4. **Review Procedure**: - - Retrieve compacted statistical packet with `get_study_data()`. - - Perform a safety review of VRAM and evaluate health alerts/triggers. - - Adjust active bounds or propose changes with `update_search_space()`. - - Submit review idempotently via `submit_agent_review()`. -5. **Worker Integration Contract**: Ensure newly integrated training scripts use `TrialSession` client rather than direct SQL queries or custom database drivers. diff --git a/README.md b/README.md index c260339..853a647 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,9 @@ # Pathfinder -[![Python 3.10+](https://img.shields.io/badge/python-3.10+-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/downloads/) -[![FastAPI](https://img.shields.io/badge/FastAPI-009688?style=flat-square&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com/) -[![Optuna](https://img.shields.io/badge/Optuna-Tuning-1E90FF?style=flat-square)](https://optuna.org/) -[![SQLite](https://img.shields.io/badge/SQLite-003B57?style=flat-square&logo=sqlite&logoColor=white)](https://www.sqlite.org/) -[![MCP](https://img.shields.io/badge/MCP-Model_Context_Protocol-orange?style=flat-square)](https://modelcontextprotocol.io/) [![Build Status](https://github.com/Ishaan1402/pathfinder/actions/workflows/integration.yml/badge.svg)](https://github.com/Ishaan1402/pathfinder/actions) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](LICENSE) -A layered hyperparameter optimization (HPO) framework that keeps quick Optuna suggestions seperate from episodic AI reviews. Workers run training script loops autonomously updating with optimized HPs without being blocked by LLM evaluation. This leaves you (or agents in your IDE) to review results periodically and decide when to adjust the search space or overarching strategy. +Pathfinder is an MCP-integrated hyperparameter optimization dashboard that lets AI coding agents onboard your training script and inspect running experiments. It wraps Optuna's TPE sampler in a FastAPI broker with SQLite persistence and a vanilla JS dashboard, while exposing structured study data through Model Context Protocol tools so your IDE agent can meaningfully participate in the tuning loop. @@ -17,32 +12,30 @@ A layered hyperparameter optimization (HPO) framework that keeps quick Optuna su
Hyperparameter Pathways Plot - ASHA Pruning Timeline + Pruning Timeline
- **Designed for:** ML researchers and students tuning deep learning models on their own infrastructure (local GPU, Colab, cloud VMs). Connect your existing training loop in just 4 lines of code β€” see [For Your Own Project](#onboarding-your-own-project) below. +**Designed for:** ML researchers tuning deep learning models on their own hardware (local GPU, Colab, cloud VMs). Connect your training loop in ~60 lines of code. ## Why Pathfinder? -**Problem:** Traditional HPO frameworks execute fast, but they typically operate within fixed boundaries. If your initial search space is poorly posed or if specific hyperparameter combinations trigger hardware failures (like CUDA OOMs or gradient explosions), a traditional optimizer will blindly burn through your GPU budget until it hits its limit. Fixing this requires the researcher to manually monitor charts, context-switch out of the IDE, and rewrite configuration files by hand. +ML practitioners waste GPU hours on poorly-bounded search spaces and have to manually inspect trial data by grepping logs or refreshing notebooks. Pathfinder gives you a live monitoring dashboard plus an MCP server so your IDE agent can read study state and help onboard new studies. It does not compete with W&B Sweeps or Ray Tune β€” it's a demonstration of agent-assisted HPO workflows. -**Solution:** Three independent layers: +Three independent layers: -- **Broker (Optuna TPE)**: Quick, deterministic suggestion engine. Hyperparameter suggestions and pruning happen in <10ms. Workers access this endpoint and continue training. -- **Worker**: Train autonomously in loops. Report metrics incrementally. Handles pruning (early stoppage), OOM, checkpointing. -- **Coordinator (You + Optional LLM)**: Run episodic reviews when *you* decide. Inspect trial history, check search health, propose bounds changes. AI agents (Claude, Cursor) can run reviews via MCP tools. +- **Broker (Optuna TPE)**: Fast, deterministic suggestion engine. Suggestions and pruning happen in <10ms. Workers hit the broker and continue training immediately. +- **Worker**: Trains autonomously in a loop. Reports metrics per epoch, handles pruning, OOM detection, and checkpointing. +- **Coordinator (you + optional LLM)**: Run episodic reviews when you decide. Inspect trial history, check search health, propose bounds changes. AI agents (Cursor, Claude Code) can assist via MCP tools. -All state lives in **SQLite** making it easy to resume reviews, audit decisions, and sync across machines. +All state lives in **SQLite** β€” resumable, auditable, portable. ## Quick Start ### Step 1: Start the Broker -The broker manages the study state and serves the dashboard. You can run it via Docker or Python. - -**Option A: Docker (Zero-Install)** +**Option A: Docker (zero-install)** ```bash docker-compose up -d @@ -61,50 +54,56 @@ python broker.py --daemon ### Step 2: Connect Your Workers -Workers run your training loops. They can be on the same machine or remote. - -**Local Workers on the same machine** +**Local worker (same machine)** ```bash -# Replace 'train.py' with your own training script HPO_BROKER_URL=http://localhost:8000 HPO_STUDY_NAME=my_study python train.py ``` -**Remote Workers (Colab / Cloud GPU)** -To connect remote workers to your local broker, use a tunnel: +**Remote worker (Colab / cloud GPU)** + +To expose your local broker to remote workers, use a tunnel: ```bash -# Local Terminal: Start broker with tunnel + auth +# Generate a token export HPO_SECRET_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')" -# ngrok (auto-generates URL) +# Start broker with tunnel (ngrok auto-generates a URL) python broker.py --daemon --tunnel -# OR Cloudflare (bring your own domain) +# Or with Cloudflare (bring your own domain): python broker.py --daemon --tunnel-provider cloudflare --tunnel-url https://your-domain.com +``` -# Prints: πŸ”₯ Remote broker URL established: https://... +Set the printed URL and token on your remote machine: -# Remote Server Terminal: Set environment and run your worker +```bash export HPO_BROKER_URL="https://..." export HPO_SECRET_TOKEN="" python train.py ``` -### Step 3: Agent Integration (Optional) +See [docs/INTEGRATION.md](docs/INTEGRATION.md) for more tunneling and auth options. -Point Claude Code, Cursor, Antigravity, etc to the MCP server for agent-driven onboarding and reviews. See [IDE Setup](#ide-setup-agent-driven-onboarding--reviews) below. +### Step 3: Agent Integration (optional) -### Environment Variables Reference +Point your IDE at the MCP server for agent-driven onboarding and inspection. See [IDE Setup](#ide-setup-agent-driven-onboarding--inspection). -Pathfinder supports the following optional environment variables for users: +### Environment Variables Reference -- `HPO_DATABASE_URL`: SQLite connection string (default: `sqlite:///hpo_studies.db`). -- `HPO_BROKER_URL`: The URL where the broker is running (e.g. `http://localhost:8000`). Required by workers. -- `HPO_STUDY_NAME`: The active study name. Overrides what is passed in code. -- `HPO_SECRET_TOKEN`: Bearer token for securing broker endpoints in remote deployments. -- `HPO_DEBUG`: Set to `1` to enable verbose debug logging in the broker. -- `HPO_SPARKLINES`: Set to `1` in the worker to print a unicode performance curve on trial completion. +| Variable | Default | Description | +|---|---|---| +| `HPO_DATABASE_URL` | `sqlite:///hpo_studies.db` | SQLite connection string. | +| `HPO_BROKER_URL` | `http://localhost:8000` | URL where the broker is running. Required by workers. | +| `HPO_STUDY_NAME` | *(none)* | Default study name when not passed explicitly. | +| `HPO_SECRET_TOKEN` | *(none)* | Bearer token for securing broker endpoints in remote deployments. | +| `HPO_DEBUG` | `0` | Set to `1` to enable verbose debug logging in the broker. | +| `HPO_SPARKLINES` | `0` | Set to `1` to print a Unicode performance curve on trial completion. | +| `HPO_BACKUP_ON_START` | `0` | Set to `1` to run a database backup when the broker starts. | +| `HPO_CAPTURE_FULL_ENV` | `0` | Set to `1` to capture full `pip freeze` output (default: ML whitelist only). | +| `HPO_TUNNEL_PROVIDER` | *(none)* | Tunnel provider for remote access: `ngrok` or `cloudflare`. | +| `HPO_TUNNEL_URL` | *(none)* | Static tunnel URL when using `cloudflare` provider. | +| `HPO_ALLOWED_ORIGINS` | *(none)* | Additional CORS origins (comma-separated) for the dashboard. | --- @@ -112,109 +111,140 @@ Pathfinder supports the following optional environment variables for users: ### Optuna Engine -- **Tree-structured Parzen Estimator Sampler**: Probability-based hyperparameter suggestions (beats grid search) -- **ASHA Pruning**: Cuts underperforming trials early to save GPU time -- **Single or Dual-Objective**: Optimize a single target, or map a Pareto front between one 'maximize' and one 'minimize' metric (e.g., accuracy vs. latency) - - *(Example: An [image segmentation model](https://github.com/Ishaan1402/crack-seg#crack-seg) could map a Pareto front to maximize Dice Score while minimizing BCE Loss).* -- **fANOVA Importances**: Identifies which hyperparams actually matter to further guide your strategy +- **TPE Sampler**: Tree-structured Parzen Estimator β€” probability-based hyperparameter suggestions that beat grid and random search +- **Median Pruning**: Cuts underperforming trials early to save GPU time +- **Single or Dual-Objective**: Optimize one target, or map a Pareto front between a maximize and a minimize metric (e.g., accuracy vs. loss) +- **fANOVA Importances**: Identifies which hyperparameters actually matter -### Tuning Coordinator +### Study Health Monitoring -- Dashboard shows health warnings (nudges to review, never auto-reviews) -- 7-step review procedure: retrieve telemetry β†’ evaluate fANOVA β†’ safety/OOM check β†’ accuracy self-regulation β†’ propose bound adjustments β†’ submit audit trail β†’ generate model card -- Search space proposals are staged, requiring your explicit approval before taking effect -- Coordinator accuracy tracks your reviews' forecasted score improvements vs. measured deltas -- Optional LLM integration (Claude, Gemini, OpenAI) for automatic reviews +The dashboard and `.hpo_status.json` show a health tier: -### Persistent Study State using SQLite +| Tier | Meaning | +|------|---------| +| `healthy` | Trials are completing, metrics are improving | +| `watch` | Stagnation or early warning signs | +| `intervene` | High OOM rate, prolonged stagnation, or 100% prune rate | -All configuration, trials, reviews, and metadata live in `hpo_studies.db`: +Health checks detect stagnation (best score flat-lining) and hardware failure patterns (CUDA OOM on specific batch sizes). -- Active search space -- Trial results + VRAM telemetry +### Persistent SQLite State + +All configuration, trials, reviews, and metadata live in `hpo_studies.db`: +- Active search space and HPO config +- Trial results with VRAM telemetry - Coordinator review history -- Study health tier - Generated model cards +### MCP Server + +An MCP server (`hpo_mcp_server.py`) exposes structured study data through Model Context Protocol tools so your IDE agent can read study state, validate manifests, and register new studies. + +--- + +## Agent Integration + +Pathfinder exposes MCP tools that let your IDE agent (Cursor, Claude Code, Antigravity) participate in two workflows: + +### Onboarding Flow + +1. Agent reads your training script, identifies tunable hyperparameters and metrics +2. Agent drafts a `train.hpo.yaml` manifest +3. Agent calls `validate_manifest` to check for errors +4. Agent calls `init_from_manifest` to register the study in Optuna and SQLite +5. Agent writes a minimal worker script from `templates/worker_minimal.py` + +### Inspection Flow + +1. Agent calls `get_study_data` to retrieve trial telemetry, health tier, fANOVA importances, and best trials +2. Agent summarizes: current best score, health status, OOM rate, stagnation warnings +3. Search space adjustments happen through the dashboard Settings UI or `hpo_cli.py` + +Key MCP tools: `validate_manifest`, `init_from_manifest`, `get_study_data`, `get_study_cards`, `export_manifest`. + +Trigger phrases: say **"integrate HPO"** or **"wire hyperparameter tuning"** and your agent will walk through the onboarding flow. For inspection, say **"show study health"** or **"check HPO progress."** + +See [AGENTS.md](AGENTS.md) for the full agent procedure. + --- ## Onboarding Your Own Project -If you cloned this to tune your own model, the easiest way to start is by having an agent (via Cursor, Claude Code, Antigravity, etc) write the manifest for you. - -After setting up the Pathfinder MCP server, simply open your training script and tell your agent something like **"help me wire this training script up to Pathfinder."**. The agent will read your script, identify tunable hyperparameters, and automatically draft the manifest. - -Otherwise, you can onboard manually: - -1. **Write a manifest** (`train.hpo.yaml`): - ```yaml - study_name: my_study - metrics: - objectives: - - name: loss - direction: minimize - - name: accuracy - direction: maximize - params: - - name: learning_rate - type: float_log - min: 1e-5 - max: 1e-2 - - name: batch_size - type: categorical - options: [4, 8, 16, 32] - worker: - entrypoint: python train.py - ``` -2. **Register the study**: - ```bash - python hpo_cli.py validate train.hpo.yaml - python hpo_cli.py init train.hpo.yaml - ``` -3. **Update your training script** (`train.py`): - Instead of hardcoding your hyperparameters, ask the Pathfinder broker for them at the start of your script, and report your loss at the end of each epoch. FastAPI endpoints will facilitate communication between Optuna and your training loop to auto-update inputs based on each trial's iterative output. - ```python - from src.hpo_client import TrialSession - - # 1. Connect to broker and get parameters - session = TrialSession(broker_url="http://localhost:8000", study_name="my_study") - trial = session.suggest() - learning_rate = trial["params"]["learning_rate"] - - for epoch in range(epochs): - loss = train_one_epoch(lr=learning_rate) - - # 2. Report metrics (Pathfinder handles pruning automatically) - if session.report_epoch(epoch, loss=loss): - break # Trial was pruned - - # 3. Mark completion - session.complete(epoch, loss=loss, state="COMPLETE") - ``` -4. **Run on your GPU** (set env vars first): - ```bash - export HPO_BROKER_URL=http://localhost:8000 - export HPO_STUDY_NAME=my_study - python train.py - ``` - -Full integration walkthrough: [docs/INTEGRATION.md](docs/INTEGRATION.md) +### 1. Write a manifest (`train.hpo.yaml`) + +```yaml +study_name: my_study +metrics: + primary_score: accuracy + objectives: + - name: accuracy + direction: maximize + label: "Accuracy" + - name: loss + direction: minimize + label: "Loss" +params: + - name: learning_rate + type: float_log + min: 1e-5 + max: 1e-2 + - name: batch_size + type: categorical + options: [4, 8, 16, 32] +worker: + entrypoint: python train.py +``` + +The `primary_score` field tells the dashboard which objective to highlight. + +### 2. Register the study + +```bash +python hpo_cli.py validate train.hpo.yaml +python hpo_cli.py init train.hpo.yaml +``` + +### 3. Update your training script + +```python +from src.hpo_client import TrialSession + +session = TrialSession() # reads HPO_BROKER_URL / HPO_STUDY_NAME +trial = session.suggest() +params = trial["params"] + +for epoch in range(epochs): + accuracy, loss = train_one_epoch(params, epoch) + + if session.report_epoch(epoch, score=accuracy, loss=loss): + # Trial was pruned by the broker + session.complete(epoch, score=accuracy, loss=loss, state="PRUNED") + break + +session.complete(epoch, score=accuracy, loss=loss, state="COMPLETE") +``` + +The worker contract is three calls: `suggest()`, `report_epoch()`, `complete()`. Map your higher-is-better metric to `score` and your lower-is-better metric to `loss`. Full details: [docs/INTEGRATION.md](docs/INTEGRATION.md). + +### 4. Run on your GPU + +```bash +export HPO_BROKER_URL=http://localhost:8000 +export HPO_STUDY_NAME=my_study +python train.py +``` --- -## IDE Setup (Agent-Driven Onboarding & Reviews) +## IDE Setup (Agent-Driven Onboarding & Inspection) ### Cursor -1. **Settings β†’ Features β†’ MCP** -2. **+ Add New MCP Server** -3. Name: `pathfinder` - Type: `command` - Command: `source .venv/bin/activate && python3 hpo_mcp_server.py` +**Settings β†’ Features β†’ MCP β†’ + Add New MCP Server** -### Claude Code / Antigravity +Name: `pathfinder`, Type: `command`, Command: `source .venv/bin/activate && python3 hpo_mcp_server.py` -Add to your MCP config (`~/.config/claudecode/mcp_config.json` or similar): +### Claude Code / Antigravity ```json { @@ -230,16 +260,7 @@ Add to your MCP config (`~/.config/claudecode/mcp_config.json` or similar): } ``` -### Other MCP Clients (OpenCode, etc.) - -Pathfinder is compliant with the Model Context Protocol standard. You can integrate it with any other MCP-compatible IDE or agent using its standard configuration method, pointing it to `python3 hpo_mcp_server.py`. - -Then tell your agent: - -- **"integrate HPO"** β†’ agent drafts manifest, validates, registers study -- **"run a coordinator review"** β†’ agent fetches study data, rates health, proposes bounds changes - -See [AGENTS.md](AGENTS.md) for the full procedure. +Pathfinder is compliant with the Model Context Protocol standard β€” it works with any MCP-compatible IDE. --- @@ -249,21 +270,25 @@ See [AGENTS.md](AGENTS.md) for the full procedure. # Start broker + dashboard python broker.py --daemon -# Validate & initialize a study from manifest +# Validate and initialize a study from manifest python hpo_cli.py validate train.hpo.yaml python hpo_cli.py init train.hpo.yaml # Check study health python hpo_cli.py status -# Run a manual coordinator review (or prints prompt for copy-paste) -python hpo_cli.py review - # Export study config back to YAML python hpo_cli.py manifest my_study -# Commit pending search space changes -python hpo_cli.py apply +# Export/import study data +python hpo_cli.py export my_study --output my_study.json +python hpo_cli.py import my_study.json + +# Generate a model card +python hpo_cli.py modelcard my_study + +# Delete a study +python hpo_cli.py delete my_study # Run tests pytest tests/ -q @@ -271,23 +296,29 @@ pytest tests/ -q --- -## Reference: crack-seg +## Limitations + +This is not a production HPO framework. It runs on a single machine with SQLite. It does not support distributed studies, Postgres backends, or advanced samplers like MOTPE or CMA-ES. Use Optuna's native dashboard or W&B Sweeps for production workloads. Pathfinder is a demonstration of MCP/agent integration for ML experiment workflows. + +## What I Learned + +Building Pathfinder taught me the MCP architecture: how to expose structured tool surfaces so an IDE agent can participate in a tuning loop without blocking the hot path. I learned the lease/reap concurrency pattern for worker lifecycle management β€” detecting dead workers and reclaiming their trials without false positives. I also gained respect for SQLite as an application database; with WAL mode and careful connection pooling, it handled concurrent broker + dashboard + MCP reads without ever becoming the bottleneck. -This Pathfinder instance was initially tuned for [crack-seg](https://github.com/Ishaan1402/crack-seg#crack-seg), a **U-Net pixel-level segmentation model** trained on high-res UAV bridge imagery. See [colab_worker.py](colab_worker.py) for the full reference implementation (dataset download, model setup, training loop). +--- + +## Reference: crack-seg -**Don't modify `colab_worker.py`** unless you're maintaining the bridge-crack project. Cloners should use `templates/worker_minimal.py` instead. +Pathfinder was initially built to tune [crack-seg](https://github.com/Ishaan1402/crack-seg#crack-seg), a U-Net pixel-level segmentation model trained on UAV bridge imagery. The reference implementation (`colab_worker.py`) is preserved in [archive/](archive/). **Do not fork it** for new studies β€” use `templates/worker_minimal.py` instead. --- ## Docs -- **[AGENTS.md](AGENTS.md)** β€” Guide for AI agents (Claude, Cursor, Antigravity) -- **[CLAUDE.md](CLAUDE.md)** β€” Development commands and architecture for Claude Code -- **[examples/onboarding/](examples/onboarding/)** β€” Step-by-step walkthrough for a new project +- **[AGENTS.md](AGENTS.md)** β€” Guide for AI agents (Cursor, Claude Code, Antigravity) - **[docs/INTEGRATION.md](docs/INTEGRATION.md)** β€” Worker integration contract details --- ## License -MIT License - see the [LICENSE](LICENSE) file for details. \ No newline at end of file +MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/colab_worker.py b/archive/colab_worker.py similarity index 100% rename from colab_worker.py rename to archive/colab_worker.py diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 76e9405..9bc2171 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -3,9 +3,9 @@ A human quickstart for wiring your own model to the broker. For the agent-driven version of this (have your IDE assistant do it), see [`AGENTS.md`](../AGENTS.md). -The root `colab_worker.py` is the full bridge-crack U-Net reference implementation. You do -**not** need to fork it. Cloners start from `templates/worker_minimal.py` and the 3-call -client in `hpo_client.py`. +The reference crack-seg implementation (`colab_worker.py`) lives in [`archive/`](../archive/). You +do **not** need to fork it. Cloners start from `templates/worker_minimal.py` and the 3-call +client in `src/hpo_client.py`. ## 1. Install @@ -17,7 +17,7 @@ pip install -r requirements.txt ## 2. Run the broker -**Local only** (simulator on the same machine β€” Colab cannot reach this): +**Local only** (worker on the same machine β€” Colab cannot reach this): ```bash source .venv/bin/activate @@ -37,24 +37,19 @@ Save that token and use the **same** value in three places: | Where | How | |-------|-----| | Colab / worker | `os.environ["HPO_SECRET_TOKEN"] = "…"` β€” `TrialSession` sends `X-HPO-Token` | -| Dashboard | First visit to the ngrok URL prompts once; stored in a session cookie | +| Dashboard | First visit to the tunnel URL prompts once; stored in a session cookie | | CLI / MCP | `export HPO_SECRET_TOKEN=…` when tools hit the tunneled broker | -Worker downloads (`/colab_worker.py`, `/hpo_client.py`) also require the token header when -auth is on. The dashboard **Worker Setup** tab explains this and generates copy-paste snippets. +Worker downloads also require the token header when auth is on. The dashboard **Worker Setup** tab +generates copy-paste snippets. -## 3. Configure MCP (optional, for agent assistance) +## 3. Define your search space via a Manifest -Point your IDE's MCP config at `pathfinder` (`python hpo_mcp_server.py`). The same -config works in Cursor, Antigravity, and Claude Code - see the README -"Exposing Pathfinder to AI Agents" section. Set `HPO_BROKER_URL` in the MCP env if you want -`validate_integration` to check the live broker. - -## 4. Define your search space via a Manifest - -Pathfinder uses a manifest-based onboarding system. You define your study config, search space parameters, objectives, and training command in a single YAML file (e.g. `train.hpo.yaml`). +Pathfinder uses a manifest-based onboarding system. You define your study config, search space +parameters, objectives, and training command in a single YAML file (e.g. `train.hpo.yaml`). To set up a study: + 1. **Create the manifest file**: Write a YAML file based on `templates/manifest.template.yaml`. 2. **Validate the manifest**: - **CLI**: `python hpo_cli.py validate train.hpo.yaml` @@ -65,9 +60,10 @@ To set up a study: - **MCP**: Use the `init_from_manifest` tool. - **Dashboard**: Click **Initialize Study** after validating. -This stores configuration in the database and creates the Optuna study. The database configuration keeps all studies isolated. +This stores configuration in the database and creates the Optuna study. The database +configuration keeps all studies isolated. -## 5. The worker contract (three calls) +## 4. The worker contract (three calls) Set environment variables on the machine that trains: @@ -77,10 +73,10 @@ export HPO_STUDY_NAME="my_study" export HPO_SPARKLINES=1 # Optional: prints a Unicode curve on completion ``` -Then use `hpo_client.TrialSession`: +Then use `src.hpo_client.TrialSession`: ```python -from hpo_client import TrialSession +from src.hpo_client import TrialSession import sys # 1. Detect GPU telemetry @@ -100,24 +96,25 @@ trial = session.suggest() # -> {trial_id, trial_number, params} pruned = False oom_triggered = False last_epoch = 0 -dice, bce = 0.0, 0.0 +score, loss = 0.0, 0.0 try: for epoch in range(num_epochs): last_epoch = epoch - dice, bce = train_one_epoch(trial["params"]) # your training step - if session.report_epoch(epoch, dice, bce): # True => broker says prune + score, loss = train_one_epoch(trial["params"]) # your training step + if session.report_epoch(epoch, score=score, loss=loss): # True => broker says prune session.complete( - epoch, dice, bce, state="PRUNED", + epoch, score=score, loss=loss, state="PRUNED", gpu_model=gpu_model, max_vram_gb=max_vram_gb, oom_triggered=False ) pruned = True break except Exception as exc: # 2. Catch and report Out Of Memory (OOM) failures - if "out of memory" in str(exc).lower(): + exc_str = str(exc).lower() + if "out of memory" in exc_str or "oom" in exc_str: session.complete( - last_epoch, dice, bce, state="FAIL", + last_epoch, score=score, loss=loss, state="FAIL", gpu_model=gpu_model, max_vram_gb=max_vram_gb, oom_triggered=True ) print("Trial failed due to GPU OOM.") @@ -127,7 +124,7 @@ except Exception as exc: if not pruned: session.complete( - last_epoch, dice, bce, weights_path="model.pt", state="COMPLETE", + last_epoch, score=score, loss=loss, weights_path="model.pt", state="COMPLETE", gpu_model=gpu_model, max_vram_gb=max_vram_gb, oom_triggered=False ) ``` @@ -137,54 +134,31 @@ That is the entire contract: | Call | Endpoint | Purpose | |------|----------|---------| | `session.suggest()` | `POST /api/suggest_trial` | Get the next trial's hyperparameters | -| `session.report_epoch(epoch, dice, bce, ...)` | `POST /api/report_epoch` | Log an epoch; returns `should_prune` | -| `session.complete(epoch, dice, bce, ..., gpu_model, max_vram_gb, oom_triggered)` | `POST /api/complete_trial` | Finalize (COMPLETE / PRUNED / FAIL) with hardware telemetry | - -`templates/worker_minimal.py` is a ~65-line starting point - fill in `train_one_epoch`. - -### Google Colab (bridge-crack reference) - -The root `colab_worker.py` is **only** for the bridge-crack U-Net study. Cloners use -`templates/worker_minimal.py` above β€” do not fork `colab_worker.py`. - -| Entrypoint | When to use | -|------------|-------------| -| `train_colab_trial(study_name, epochs=15)` | One trial (smoke test or debugging) | -| `train_colab_trial_loop(study_name, n_trials=12, epochs=15)` | Normal Colab session (default) | - -`train_colab_trial_loop` repeatedly calls `train_colab_trial`. Each iteration: - -- Reports guardrail skips and caught OOM/crashes as `FAIL` to the broker (loop continues). -- Clears the CUDA cache between trials. -- Retries transient `suggest_trial` errors (via `TrialSession` backoff). - -If the Colab kernel hard-crashes without running Python cleanup, the broker marks the trial -`FAIL` after the worker lease expires (~45s without a heartbeat) or on the next dashboard poll. - -```python -import os -os.environ["HPO_BROKER_URL"] = "https://" -os.environ["HPO_SECRET_TOKEN"] = "" # required when --tunnel is on - -from colab_worker import train_colab_trial_loop -train_colab_trial_loop("bridge_crack_study", n_trials=12, epochs=15) -``` +| `session.report_epoch(epoch, score=score, loss=loss, ...)` | `POST /api/report_epoch` | Log an epoch; returns `should_prune` | +| `session.complete(epoch, score=score, loss=loss, ..., gpu_model, max_vram_gb, oom_triggered)` | `POST /api/complete_trial` | Finalize (COMPLETE / PRUNED / FAIL) with hardware telemetry | -The dashboard **Worker Setup β†’ Google Colab Integration** tab generates the full -download-and-run snippet (including authenticated fetches of `colab_worker.py`). +`templates/worker_minimal.py` is a ~60-line starting point β€” fill in `train_one_epoch`. > [!NOTE] > **Abstracting Metrics (Non-CV Tasks)** -> The parameter names `dice` (higher-is-better) and `bce` (lower-is-better) are abstract placeholders in the API. If you are doing NLP (e.g. Perplexity and BLEU), RL (e.g. Reward and Episode Length), or Tabular tasks: -> - Pass your higher-is-better metric (e.g. Accuracy, BLEU, F1, Reward) as `dice`. -> - Pass your lower-is-better metric (e.g. Loss, Perplexity, MAE) as `bce`. -> - You can customize their display names on the UI dashboard under **Settings > Eval protocol** by setting "Loss metric display name" and "Score metric display name" (they default to BCE and Dice). +> The parameter names `score` (higher-is-better) and `loss` (lower-is-better) are generic slots in the API. If you are doing NLP (e.g. Perplexity and BLEU), RL (e.g. Reward and Episode Length), or Tabular tasks: +> - Pass your higher-is-better metric (e.g. Accuracy, BLEU, F1, Reward) as `score`. +> - Pass your lower-is-better metric (e.g. Cross-Entropy, Perplexity, MAE) as `loss`. +> - You can customize their display names on the UI dashboard under **Settings > Eval protocol** by setting "Loss metric display name" and "Score metric display name". + +### Google Colab (reference only) -## 6. Create the study and validate +The historical bridge-crack U-Net reference implementation is preserved in +[`archive/colab_worker.py`](../archive/colab_worker.py). **Do not use it for new studies.** +Cloners should use `templates/worker_minimal.py`. -Call the MCP `init_from_manifest` tool (or CLI `init`) to create the Optuna study and seed configuration options from your manifest file. Use the `/health` broker endpoint to verify connectivity. +## 5. Create the study and validate -## 7. CLI operations +Call the MCP `init_from_manifest` tool (or CLI `init`) to create the Optuna study and seed +configuration options from your manifest file. Use the `/health` broker endpoint to verify +connectivity. + +## 6. CLI operations Pathfinder ships a command-line interface (`hpo_cli.py`) for database operations. @@ -194,7 +168,8 @@ Pathfinder ships a command-line interface (`hpo_cli.py`) for database operations python hpo_cli.py validate train.hpo.yaml ``` -Parses and validates the YAML manifest against the Pathfinder schema. Reports errors and warnings without touching the database. +Parses and validates the YAML manifest against the Pathfinder schema. Reports errors and +warnings without touching the database. ### Initialize a study from manifest @@ -210,7 +185,8 @@ python hpo_cli.py export my_study --output my_study.json python hpo_cli.py export my_study --format csv --output my_study.csv ``` -Exports the full study β€” Optuna trials, trial results, reviews, agent logs β€” into a portable JSON or CSV file. Useful for archiving, sharing, or migrating between machines. +Exports the full study β€” Optuna trials, trial results, reviews, agent logs β€” into a portable +JSON or CSV file. Useful for archiving, sharing, or migrating between machines. ### Import a study @@ -220,7 +196,26 @@ python hpo_cli.py import my_study.json --rename new_study_name python hpo_cli.py import my_study.json --rename new_study_name --force # overwrite if exists ``` -Imports a study from a previously exported JSON file. Any trials that were `RUNNING` at export time are automatically converted to `FAIL` so they do not appear as zombie trials. If the import fails partway through (e.g. corrupted data), the entire import is rolled back atomically β€” no orphan rows are left. +Imports a study from a previously exported JSON file. Any trials that were `RUNNING` at export +time are automatically converted to `FAIL` so they do not appear as zombie trials. If the import +fails partway through, the entire import is rolled back atomically. + +### Generate a model card + +```bash +python hpo_cli.py modelcard my_study +``` + +Writes `MODEL_CARD.md` to disk with a synthesis of the study's results, best hyperparameters, +and importance rankings. + +### Delete a study + +```bash +python hpo_cli.py delete my_study +``` + +Permanently removes a study and all its data from the database. Requires confirmation. ### Backup the database @@ -228,22 +223,29 @@ Imports a study from a previously exported JSON file. Any trials that were `RUNN python hpo_cli.py backup --output backup.db ``` -Creates a point-in-time snapshot of the full SQLite database (`hpo_studies.db`) using SQLite's online backup API. Safe to run while the broker is running. +Creates a point-in-time snapshot of the full SQLite database using SQLite's online backup API. +Safe to run while the broker is running. -## 8. Environment variables +## 7. Environment variables | Variable | Default | Description | -|---|---|---| +|---|---|---|---| +| `HPO_DATABASE_URL` | `sqlite:///hpo_studies.db` | SQLite connection string. | | `HPO_BROKER_URL` | `http://localhost:8000` | URL the worker uses to reach the broker. | | `HPO_STUDY_NAME` | *(none)* | Default study name when not passed explicitly. | | `HPO_SECRET_TOKEN` | *(none)* | Bearer token required when `--tunnel` auth is enabled. | +| `HPO_DEBUG` | `0` | Set to `1` to enable verbose debug logging in the broker. | | `HPO_SPARKLINES` | `0` | Set to `1` to print a Unicode training curve on trial completion. | -| `HPO_BACKUP_ON_START` | `0` | Set to `1` to run an automatic database backup when the broker starts. Equivalent to `--backup-on-start` CLI flag. | -| `HPO_CAPTURE_FULL_ENV` | `0` | Set to `1` to capture the full `pip freeze` output rather than the default ML-library whitelist. Useful for exact reproducibility audits. | +| `HPO_BACKUP_ON_START` | `0` | Set to `1` to run an automatic database backup when the broker starts. | +| `HPO_CAPTURE_FULL_ENV` | `0` | Set to `1` to capture the full `pip freeze` output rather than the default ML-library whitelist. | +| `HPO_TUNNEL_PROVIDER` | *(none)* | Tunnel provider for remote access: `ngrok` or `cloudflare`. | +| `HPO_TUNNEL_URL` | *(none)* | Static tunnel URL when using `cloudflare` provider. | +| `HPO_ALLOWED_ORIGINS` | *(none)* | Additional CORS origins (comma-separated) for the dashboard. | -## 9. Validation guardrails schema +## 8. Validation guardrails schema -`validation_rules` can be set in the manifest YAML or via **Settings β†’ Eval protocol β†’ Metric guardrails** in the dashboard. +`validation_rules` can be set in the manifest YAML or via **Settings > Eval protocol > +Metric guardrails** in the dashboard. ```yaml validation_rules: @@ -253,11 +255,15 @@ validation_rules: max_epoch_jump: 0.5 # warn when score changes by more than this fraction between consecutive epochs ``` -When `enabled: false` (the default for new studies), no metric warnings are ever generated. Set `enabled: true` only when you have domain knowledge about valid metric ranges for your task. +When `enabled: false` (the default for new studies), no metric warnings are ever generated. +Set `enabled: true` only when you have domain knowledge about valid metric ranges for your task. -When a trial triggers a guardrail it is flagged as **Watch** in the study health, but the trial is still recorded β€” guardrails are advisory, not blocking (the only hard rejection is when *both* metrics are exactly `0.0`, history is empty, and `epoch ≀ 0` on a multi-objective study, which strongly indicates training never ran). +When a trial triggers a guardrail it is flagged as **Watch** in the study health, but the +trial is still recorded β€” guardrails are advisory, not blocking (the only hard rejection is +when *both* metrics are exactly `0.0`, history is empty, and `epoch ≀ 0` on a multi-objective +study, which strongly indicates training never ran). ## Next steps - Open the dashboard (`index.html` served via the broker root) to watch trials, the Pareto front, and fANOVA importance. -- Use the episodic coordinator review (see [`AGENTS.md`](../AGENTS.md)) to interpret results and adjust the search space with evidence. +- Use the inspection flow (see [`AGENTS.md`](../AGENTS.md)) to interpret results with your IDE agent. diff --git a/simulators/training_worker.py b/simulators/training_worker.py index 8b013b4..4b72681 100644 --- a/simulators/training_worker.py +++ b/simulators/training_worker.py @@ -8,18 +8,19 @@ from src.hpo_client import TrialSession -def simulate_unet_training_epoch( +def simulate_training_epoch( epoch: int, params: Dict[str, Any] ) -> tuple[float, float]: """ - Simulates a U-Net training epoch on crack segmentation. + Simulates a training epoch on a segmentation model. Defines a continuous non-linear optimization landscape: - Optimal learning rate: log10(lr) = -3 (1e-3) - Optimal BCE weight ratio: 0.3 - - Resolution 1024 captures fine details better (higher Dice), but takes longer + - Resolution 1024 captures fine details better (higher Score), but takes longer - model_capacity 'wide' performs best """ + # This simulates a BCE/Dice optimization landscape with a known optimum at lrβ‰ˆ1e-3, resolution=1024 # 1. LR performance (quadratic curve in log space) log_lr = np.log10(params["learning_rate"]) lr_perf = -1.5 * (log_lr - (-3.0))**2 # Peak at -3.0 (1e-3) @@ -44,21 +45,21 @@ def simulate_unet_training_epoch( loss_perf = -0.4 * (params["loss_weight_ratio"] - 0.3)**2 # Assemble base Dice score ceiling (maximum is ~0.92) - base_dice = 0.70 + lr_perf + res_perf + enc_perf + loss_perf - base_dice = max(0.15, min(0.92, base_dice)) + base_score = 0.70 + lr_perf + res_perf + enc_perf + loss_perf + base_score = max(0.15, min(0.92, base_score)) # Learning curve: approaches the ceiling asymptotically over epochs (max 10) progress = 1.0 - np.exp(-0.35 * epoch) - current_dice = base_dice * progress + current_score = base_score * progress # Add stochastic noise to simulate realistic batch training variances noise = np.random.normal(0, 0.008) - current_dice = float(max(0.01, min(0.95, current_dice + noise))) + current_score = float(max(0.01, min(0.95, current_score + noise))) # BCE Loss correlates inversely with Dice Score - current_bce = float(max(0.02, 2.5 * (1.0 - current_dice) + np.random.normal(0, 0.015))) + current_loss = float(max(0.02, 2.5 * (1.0 - current_score) + np.random.normal(0, 0.015))) - return current_dice, current_bce + return current_score, current_loss def run_training_worker( @@ -125,28 +126,28 @@ def run_training_worker( # Initialize tracking metrics val_history = [] - final_dice = 0.0 - final_bce = 999.0 + final_score = 0.0 + final_loss = 999.0 pruned = False # 2. Run Epoch training loop for epoch in range(1, epochs_per_trial + 1): # Simulate training/val forward pass - dice, bce = simulate_unet_training_epoch(epoch, params) - val_history.append({"epoch": epoch, "dice": dice, "bce": bce}) + score, loss = simulate_training_epoch(epoch, params) + val_history.append({"epoch": epoch, "score": score, "loss": loss}) - print(f" Epoch {epoch:02d}/{epochs_per_trial:02d} | Dice: {dice:.4f} | BCE: {bce:.4f}") + print(f" Epoch {epoch:02d}/{epochs_per_trial:02d} | Score: {score:.4f} | Loss: {loss:.4f}") # Record final metrics - final_dice = dice - final_bce = bce + final_score = score + final_loss = loss # 3. Intermediate epoch reporting & pruning evaluation try: should_prune = session.report_epoch( epoch=epoch, - score=dice, - loss=bce + score=score, + loss=loss ) except Exception as rep_err: print(f"Error reporting epoch: {rep_err}") @@ -159,8 +160,8 @@ def run_training_worker( try: session.complete( epoch=epoch, - score=dice, - loss=bce, + score=score, + loss=loss, state="PRUNED" ) except Exception as prune_err: @@ -176,8 +177,8 @@ def run_training_worker( try: comp_result = session.complete( epoch=epochs_per_trial, - score=final_dice, - loss=final_bce, + score=final_score, + loss=final_loss, weights_path=weights_path, history=val_history, state="COMPLETE" diff --git a/studies/unet_crack_segmentation_test_model_card.md b/studies/unet_crack_segmentation_test_model_card.md index 0010ba9..f8d837c 100644 --- a/studies/unet_crack_segmentation_test_model_card.md +++ b/studies/unet_crack_segmentation_test_model_card.md @@ -5,29 +5,29 @@ This model card synthesizes results for study `unet_crack_segmentation_test`. - **Best Achieved Score (Dice):** 0.5000 - **Optimal Hyperparameters:** - - `learning_rate`: 1.9171154265835047e-05 - - `batch_size`: 16 - - `resolution`: 512 - - `model_capacity`: wide - - `loss_weight_ratio`: 0.2773533339795067 + - `learning_rate`: 1.66144714685308e-05 + - `batch_size`: 32 + - `resolution`: 1024 + - `model_capacity`: narrow + - `loss_weight_ratio`: 0.5534297134135798 ## Search Space Performance - **Total Trials Evaluated:** 8 -- **Successful Runs:** 8 +- **Successful Runs:** 7 - **Pruned Runs:** 0 -- **Failed/OOM Runs:** 0 +- **Failed/OOM Runs:** 1 ### Key Parameter Importances (fANOVA) -- `batch_size`: 0.3152 -- `loss_weight_ratio`: 0.2959 -- `model_capacity`: 0.2055 -- `learning_rate`: 0.1316 -- `resolution`: 0.0519 +- `learning_rate`: 0.4944 +- `batch_size`: 0.2712 +- `resolution`: 0.1175 +- `loss_weight_ratio`: 0.0701 +- `model_capacity`: 0.0468 ## Telemetry Profile - **GPU Device:** Unknown - **Peak VRAM Recorded:** 0.00 GB -- **OOM Failures:** 0 +- **OOM Failures:** 1 --- -*Generated by Pathfinder on 2026-06-11T02:55:19.556028* +*Generated by Pathfinder on 2026-06-19T13:48:12.043832* diff --git a/templates/worker_minimal.py b/templates/worker_minimal.py index 789169b..82f2a98 100644 --- a/templates/worker_minimal.py +++ b/templates/worker_minimal.py @@ -1,15 +1,13 @@ """Minimal HPO worker template. -Copy this next to your training code, fill in `train_one_epoch`, and run it on your GPU box -(Colab, a server, anywhere). It talks to the broker only through `hpo_client` -- no need to -fork the 600-line bridge-crack `colab_worker.py`. +Copy this next to your training code, fill in ``train_one_epoch``, and run it on your GPU box +(Colab, a server, anywhere). It talks to the broker only through ``hpo_client``. Setup: - export HPO_BROKER_URL="https://hpo.mycustomdomain.com" # Or your Cloudflare/ngrok/Tailscale URL + export HPO_BROKER_URL="https://hpo.mycustomdomain.com" export HPO_STUDY_NAME="my_study" python worker_minimal.py """ -import sys from src.hpo_client import TrialSession NUM_EPOCHS = 15 @@ -19,7 +17,7 @@ def train_one_epoch(params: dict, epoch: int) -> tuple[float, float]: """Run one epoch with the given hyperparameters and return (score, loss). Replace this body with your real training/validation step. `params` contains the - hyperparameters the broker suggested (keys match your active_search_space.json). + hyperparameters the broker suggested (keys match the search space defined in your manifest). NOTE: The return value should be a tuple of (higher_is_better_score, lower_is_better_loss). The parameter names 'score' and 'loss' inside report_epoch are generalized: @@ -74,7 +72,8 @@ def main(): last_epoch, score=score, loss=loss, state="FAIL", gpu_model=gpu_model, max_vram_gb=max_vram_gb, oom_triggered=True ) - sys.exit(1) + print("Trial failed due to GPU OOM. Continuing to next trial.") + return else: # Re-raise standard training exceptions raise exc From b06a4500942277a354cac3b633b512fbea42794c Mon Sep 17 00:00:00 2001 From: Ishaan Date: Tue, 30 Jun 2026 10:39:29 -0400 Subject: [PATCH 7/8] update tests + ci --- .github/workflows/ci.yml | 3 + .github/workflows/integration.yml | 41 +- .gitignore | 6 + Dockerfile | 4 + archive/README.md | 11 + archive/bridge_crack_500px_plots.png | Bin 0 -> 259742 bytes archive/bridge_crack_study_500px.csv | 8 + archive/bridge_crack_study_trials.json | 2858 ++++++++++++++++++++++++ archive/extract_bridge_crack_study.py | 117 + archive/extract_to_csv.py | 99 + archive/plot_results.py | 93 + docker-compose.yml | 7 +- pyproject.toml | 10 +- pytest.ini | 7 - requirements-dev.txt | 1 + requirements.txt | 2 - src/reporting.py | 67 +- src/tunneling.py | 3 +- tests/conftest.py | 2 +- tests/test_health_tier.py | 2 +- tests/test_http_api.py | 8 +- tests/test_http_auth.py | 2 +- tests/test_integration.py | 411 +--- tests/test_manifest.py | 86 +- tests/test_metrics.py | 10 +- tests/test_pruning.py | 22 +- tests/test_robustness_features.py | 24 +- tests/test_vram_telemetry.py | 50 + 28 files changed, 3447 insertions(+), 507 deletions(-) create mode 100644 archive/README.md create mode 100644 archive/bridge_crack_500px_plots.png create mode 100644 archive/bridge_crack_study_500px.csv create mode 100644 archive/bridge_crack_study_trials.json create mode 100644 archive/extract_bridge_crack_study.py create mode 100644 archive/extract_to_csv.py create mode 100644 archive/plot_results.py delete mode 100644 pytest.ini create mode 100644 tests/test_vram_telemetry.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f12bad..dc4eea8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,5 +26,8 @@ jobs: python -m pip install --upgrade pip pip install -r requirements-dev.txt + - name: Lint + run: ruff check src/ hpo_cli.py hpo_mcp_server.py broker.py --select F + - name: Run tests (SQLite) run: pytest tests/ -q diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 216df8b..bcdc2a6 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -1,32 +1,23 @@ name: Integration Test -on: [push, pull_request] +on: + push: + branches: [main, master] + pull_request: jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.11 - uses: actions/setup-python@v4 - with: - python-version: "3.11" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Run Unit Tests - run: | - pip install pytest httpx - pytest tests/ -v - - name: Generate Quickstart Study - run: | - printf '\n\n\n\n\n' | python hpo_cli.py quickstart - - name: Start Broker - run: | - python broker.py --daemon & - - name: Wait for Broker - run: sleep 2 - - name: Run Dummy Worker - run: | - python quickstart_worker.py + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + - name: Run tests + run: pytest tests/ -q diff --git a/.gitignore b/.gitignore index 3e55830..e0371f5 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,9 @@ pending_changes.json # Build artifacts *.egg-info/ +# Generated model cards +studies/ + +# Default database location +.data/ + diff --git a/Dockerfile b/Dockerfile index 727b1c0..6acbed4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,5 +12,9 @@ COPY . . # Expose broker port EXPOSE 8000 +# Health check +HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 + # Run uvicorn on 0.0.0.0 CMD ["python", "broker.py", "--daemon", "--host", "0.0.0.0", "--port", "8000"] diff --git a/archive/README.md b/archive/README.md new file mode 100644 index 0000000..1540e3f --- /dev/null +++ b/archive/README.md @@ -0,0 +1,11 @@ +# Archive + +This directory contains code that was part of Pathfinder's development history but is not part of the current API surface. + +## colab_worker.py + +The original bridge-crack U-Net reference implementation. This was the study Pathfinder was initially built to tune β€” a pixel-level crack segmentation model trained on high-res UAV bridge imagery. + +It still works as a standalone Colab worker, but uses domain-specific naming (dice/bce) that predates Pathfinder's generic score/loss metric abstraction. It is kept here for historical context and as evidence of the project's origins. + +**Do not use this as a template for new studies.** Use `templates/worker_minimal.py` instead. diff --git a/archive/bridge_crack_500px_plots.png b/archive/bridge_crack_500px_plots.png new file mode 100644 index 0000000000000000000000000000000000000000..3c3e6672ae4735314742f1fd58a04247d1ecb255 GIT binary patch literal 259742 zcmeFYcQl-B_ckm&NFpI3h?3|;jb2mq-bQC4TJ%noAt51%&WIkJ(V};P=)IT0=zR>M z494(XbKlSVuJ2vzeZGIbe}C6nStG`p>zec2=ibLYj$?CM(;LhERw`%Hq72$t{(*^GKUoR5EwE`O8#{cyqBK-Yd-y zkIfOrHvykwUg&Eo{G?A)igfM%&RgHt7DUL9I$Ptib@_fk3Paaj9=~HgQ9s~vbgJNW zhR)gYkvv-^Q%L2jTOCYP*&fel)PH5Qx)KD(@mZEP{&s>iqsWe1)Fh+TGbE#jGu|oH zIxR82{kl)eKSNyp@of8jsgS*%+;T@a z&F=BYN?uQ_)lkZ8-ZrBBDRcHBgS`JeT_{>}S|sOk30c6X}r5@rQbmZNFj=f7!$-LwVmW-=PY z!F$g%E7koXY00E7MIk!S5~GbHxr}AR_|FU~It||EydR{eIv;>`4SOH=FupzAzlL+B z6L&FkvFzJLw!gmXxw%QG-)L852QSm*0ykN1NwE8Ij9F1;Yag^}X1R!j&`Ko1BguI9 ztXfIyT#)B?N^|6)(!fgZ>LR~jX|L8pu?+5xk44>BIeUK93xz)B&GhikUM>K>9XsK2 zgNNT|Zgd_t3vwr-JJ(*!J%OS>!I0=lH^XH_^N4>DyjJA_jl^ZEE$q8D{7HDDhcf;< z2VenQ0Gh{F{XaF?eL8w3P2Ov0B<$w(TZbD^oA^HKEBESIozNd=ZV3h1{8kZmt~S`; zLxFclERMcz@wqO%P*N!UVcv68;EQ!v3b?kUlXwV?>xxXTwSb3^{!JjVP*Q9%05Qs- zvmIvc5ZKm&_qpG^d83)5`ik?c6RVvwdB1PNTH&q~v`}5-47(uZAW`y@H&dxuTqN5K zSe6VsHqJ$wOAf{(;xK42^qN#e+`_!5{Dh(fXA=vfFV`RJq~mPvt%CX(hA)|FX_E1j z6;FB_rJy|%wnA=GT4cqcBj=r$?~=+v)HZL{QCNP6fqwOSDh$Ox zn=iaHx>l%JX{XZe{$%i3Df!xUBhy~T!|baluvcpsh-+rqc=beah2ISIxtg2mOsL+QW(f8uIXGFO|2o@*`gITImGVr|yR873 zf0GkNATt$VUv=R3XUqdBLVuhL;iOZeem0y}$e*t@P&#`9BXK!EhLMBu7)HN6%W-a^ zx<>z>PdP0M0vY1Ok z%%AfSgRE1J$2GM&=bdWh6hXt>ffVlW?Wy4axwc^9E`)4au*KH80pjWPyNFpdHrKOfTf z1&l@#&2%sQ5e<&@dpp1A^=q69PqfO6*f@nHy({xoX-aD_bfl1(Ur;xs#D3tE_S+)o zSpV1)`y_80|S^^ zLTNdZv`dW!k8qWDPLqifNhkAc5CVl3PdL<7e@+D;Z7qUsa=@;RK zZch5>Ld-x4HkjXL{vd zC%DQ4Hf5UuVL98md-x0U##At0TkN(YA+O|j>r;E5ZfUlA@E(_v-*K~;=hfu0HU+045_oEFKgw77B_<7z zc8^5`I1-s}WlH=(tXSj?nXtMiRmS^VO-nsMa@0zpC4iVMwGNzZHSxJvEjZf7Wa&oS z0dD=;>7T;&`nwPcV=?+$#>WdNJm0TgUW|mq=4C_j7G{6tH0(;bX*chU8!j|@tyDt@ z_B>Xn(+zkgV)N9_eqVdt^~bcOw@|k>ul~@ zoR*TUF@>02Q=*mhWvp>%kPN8+9r}S`Al|Fm?TV&1HSDI93`1ItO;=GbeQTXGYb@j6 zm$rE049>5FNB=!qz|;N_fzcZ@bGV@&1NYA&q^?QJgP0TJ<|$_D zdic2t+cPf6--H1r&{uY*?GKF1@Rbw;@a?{LG^s*jn zjCONCD9%=YEW(MsJLiXDFnvFJ(2K3_SR$$ROUMg_crt)hId%T4mz<6-HH;Ra=91XN z0(yP&&(}WnZMkk#g?8K(hkGJp+bm#84H1Z**7mMOi14)y;INy(F`l@fUL^`<0&t^m z`0iX2!tsP8`fmuSe8zPzJHN+)LE$kqjtDgGf0}8(kBBer19xt4b4*x5sW+B~ z0O@tQxMcPcLaLD^JCauHKl8iV?IT=S)NkS~zF;wh`f>4^N!L_{`|n9bfjd=~VuRt& z063vEzAS$r1O(1V%B?U9#21ET65v>=9EE?$@d#HG1WUQ@1@vPfiCvNSWA!Jv&G!gT zGW_9=d#NMuV%~RwXqouB13X`MvXJB2Sr`TS;*6M6Z*jYd8a=h;ir${!P=Xs*+EvA| zo5=SCcR`UO zG^hR>t?hCEG@wJf@8mw9d-Xo@!#862y_6ZrPe^345c&k+U*)HA?73av=zSdYOvG>0 z1uSIQ=Ei2gD#dbR|KT2mF9M2eSnDp6vgazaB_;Uy4VIZGl&t*#oOSbmCYS(I+xo4$A9rJ-o=MK?B>9B$CB%uPxV`z{X|T#?!c3 zq-8Z2CbTWtSvfzrW$NR-ra#>+fhcQ?2AMS8sosmhP8IpT04KJOqNhKr1W8 zZoaI`@_k+sy>5n`f+QEPl)yy?6_e9x-`Ex@w?JfbQc;S zg%Vl*XWQ}OzNhjhyJF@drsJ!Wt~o}Owq=g5a=86 za(M0@Rbs6HFtYGZsNS28E?q^O0chaJcFlxC_3bw@>02Zc2 zdHEleJcYMxH_JB&d2^JSw8T&hfxF{vg({sl@`ln99emox6JuDWf}LwWgMcVuQh`05 z^$ZQIxtN{~d6lhG6-p}jl2-%xQlD#_gz!hLC7az^paeWNS|3R(6)mtM*f*|!xCTUi z?hb~M$0+m(J3Rmsc#Lc8!(^cm*GfG1E@U`)zow~YyzN=#ZLhrf)>1Q1nJZS7Bp!9= zg7MY*g+uLL(y^iZ=Ub6I=`4Abb_NJw%k9^FIqTx}%O49kc>;CDo}T10*12U73)C@~ zVQZCgL8huBn5o5ORrJ&OQ6hk$ineee<#rR^r0-i%BjEGutS&XhU@nOHzFJnc={%KY ziw=2+X<3p$fEZ*_jPQ5vkjckdagU;(e9lMeJ!dAP2Nc{U@-5F9sV|tF&}yTa_cG43O^xDZEh}xp->gDE4#Ri}6_{~H< zv$Q2|xW{fhi&}b;NyBGOVE^)Tjza~>-W#`gDFGb_hX5=N)9Z}SG!F?og7J_1nu62j zG4joE`Po>uOsseA4g!ZwyRdxTL(sT098DI~*9YaYWILO{Ht)>7(ackHs2xoAPmKmr z|4fzfA1#P+aP@$BZ*4kbk;3oLa@(P6k?r){PQU5f{d@ z3|*@&v=S9c@}F}qp~%^?;&)O& zXE8ayhrnD$bHY6-Y2m-WG)c-!=16~k_~!P})(6qVJq#1a{RAJMtGkkCzmyf=A(Coo zq6~)BsC5QBoB}T2?~3B(Zms+ZiQ)xY^>-qtX|HbXXSB)Z|LRrLZhb z_!m5c6yr8&i~@36Ut$Ht>SbWC!8Zob8V7)&V_sT+=!xSE{2NAXD6T-$o?G0o+SbuW z3iOT&DXbZ*1M%}2L-46mQ%^Q+`qE145j{T|tGOW>5kF0;vqi7NoSnm^tS*<$eP zt#WbiBR#C@cN&gd-=yco=6xw6g@{LAMs@HAs_ROeNyuRVDyNp#z0##tIh7?H!5GaT zH_1MltCp;|+!aP0h4o|te|+Vw{rYo#r}m3)0B(f-`Xxjvq-qyJ#~rcSlc6hE1td|>wHYcbJ#17!vbI6InLYtWCka;-5MC|vbfJ%=X4M%?uVAf`e|e4XUct< z*~w#rYJ;oHtSjy-;j-<@bsM`$GSyTO zmIe&%xFiMWlcGdkZz4mOQdo9=*+bAKa(=pk$~A;SDB{GI%?CPkv0Vof@}oFbzWNK! zTU#H1qc1v$LmsomFsdc#m0JxkhLCn9L`ip$L*{b}eBrn|?Z+vUVn-bw+og~+DXkS9 z3NGX57S+^vED2pNuz71U6;`tXzlcOSzSF8;cSmzoGsw&Lb*CH`JiP9GC|LehRVzh4 zmL*rktE=8&qILn$EJh6_9U@KM$BLQKAulzuq@%MHNELE_Y%u{;&9Z8Z10p&$Z?HGM zaH`5VE`b}Y@b~+pDqhuN5%Y8YrY8wo3MCf~m;_1b)L{4I>RF@Q!FH?kT;kJ{MaPS^ zS@a#4({9QwrBx)nrhaFx-gT+~a&s0JUFsX`ySpHTS^JY==CXwbai+<2OcXw)ortu$ zd>LqHwtYM-blU`x|KTqYML!3%AnIa z>b{BA^ywDI+KXYVZ$M>7vlX9{_r=H53ls{+-CaDIIV}HlMm4{NsIUFWYb!R7kvHQB0Yi|v>CN5>B;%2|Vt}0lth$)Lj`PH-G77t{QTB?wvQMxytTZqja ziU#gr(`6n;v|0!%&aAYVTnzmDjhD>oW2;g1>R>cT@i8as^QPjtMt8aOt1@M1iC*D- zO~Z_sg*Xl^BbY8%-YuO|_~pEN%i@;c;r4_rC9ikp9=dl;$+ndl#S->Y)b$GhHVZr) zEtG!pWK^MQ&#MZ1DRM$Xgsyu{7QyXY_4}WW;5m|I;s}F4Xy`DSbREeX2{069;o~mX(p4vEY~^X$ya; zL72jx#2JZ{&l*96JUv3l#GnI=$=j&s$B)+@-rRO2820;{Dyos!rY;wODTRju0a z_SXJziKoke#?UB+z=(YQ)U1ac>2O{G%v$Q zXRod{E^lnj+I}8TC>_a?iLI|AVG2`)i66u}{F0HHC{KU*5@Cv_luWE>)Z7MDCHLU`J6O`o{8xD&Gpi z9tN_r|B_>ZIgVs6_-{<~p86zP-iMTIew8Tt`>;UTqIpu4;-$fJAfsG5?#_HybhEUb z%LmFWG^X}b#n7v)G;+06ks&7cW4l1|ZtF|C;8y<*6=M*3L9Jhrlo@>m&> z|FcWXW=>f5eIm-L8rts}C_d|qO? z0=*w1#0kCS@wQcMQkLVV#>eby*|PEI2gbVIU8O{JKr_(8Qx=^W)1E&aMg{6q+FJfb z6Dr`e;n~n~{M=i+VS+>2tm-tGyXKA@%18T4ngo_4L=5tbd(C!ga`RwpwWq=gx=f0T zKQGdDZaJhlDt$$+Wl{RNK?)s@dT1!ghtB-;7G`0j#it*A!hGS z!nYenCUF6LR~U1x|Bs#$! zAOGlGvyc&sOs6lv&3_|i0l3$Som$ltaV7+gM>~R6iY-F@MlDdbpymo>V%n*e-TNba zwwE8~zjAwP%Y*|3&E}0o8lP$JOG=^m?zQNJb=g z3fvO_k}U21slPym6$wPr0M^L$9jmUB{_zp=0t>53&bIGLtM7~4(=P>So_QTHQsR+M zb=$Z59C99|W3b@cZ30E+gNCC#Gx9@&V5Pp?^DkDip$UaKO!++LsZ%c#k;jMY13P0n zYroPU!B3fgmXSXbSj2J}Jp2+_yiNCjurKNdiXQ=Upu_Ypz*^EVH>V7R> zR*WyG&`8JI9r=ri1)YFvIQMHcw3oe==nrkE-9v-@x4GkcFq(SvTF}oE`Rr|_psxzH{>U^a@(F!BZl$SOAgV% zO_O{XvN5-s(YO`mmR~W@_#$;V&pOzrTHhtNzAnwIH%i6cnQ8I<7bkCir4T)|AMHKC zB!axVXTIJN=B%O1nv0=xsz*>L#B*6cKXNZU{lS$j%O zzz_d(_kg{oxt_per0U0yG8v;2tFSjO=Dc2Y+O#goCRJ}`Gu@kdW%r(&lQNEb?`n92 zOtaX*bsocI$fHtjey)uz(ZtNJFb`Ee`SORZW(Q1d_qmkhARh`=z>@lb9 zv#xTd^-n#iuadi@_(1fk5<6mt8fDjCvGe)|&Q4T1Kd3k`k)RfIOhDav;0YW4>3zF2 zbiT@F_BAzw(|TP^Qlo^gI-R{yo@Jm;u8b(2TE_W!o}my~ooew*Ewfxtvq;0~rY~E{ zQh>fjG5uP*#fT=ZjK*-~!F-E-VK|2&?^=!5sDC$ieV?I_BP;OByoR|1zl6wwOqY3Y zl2J~o49u=kII>`w$#Mk+pW=?m4j~H>dudo@GV8f#UprXPZkVdbf3QDd){}(tUn%!a z;?HM-`1tK;AuBcd94kLi4W4>+V+>-Lz{tZQ^)b)!Lzk?-4o&sd3F|7`GfhZ`raQeC zQkSJs16_DBrm5cjcirwqM<3cAL-#Dg-Y4NEs(}lvv_@p{7re@8py7ZhquI!cp!s<@CBYO0^p$(`H(lLZKDcD4I4Gf&A^c2C`j&&uC$_Br3G z{!kcnDX!`VZhR|5nW_JRd_su4vf^o?SPL9iOfs^gE6J@PgJ&m%Z?6v_TSu_8@h?AK ztR^n2qPmnQ4<@TDo{SxFagC&kJNs+bd1$qxep|1|%9p*k_0o}hcrQ{MNAuxvY+n9Q z&WbK)B^JkpxIavkY?$+*Bj~~DU}bk*A%K`H5&1E#Ypi;cmdf8l;WHH$bwEGdzK6tn zI-DYQvNrHNrqr+7!=P{ES*?3`TydlQ2yZj zh$1K=@48J=&5m6`QW)npWMZc#PIA7*nVmOL0%#1(YAmSURC+W|g|h_RH44flgv9xJxo$tu zJX;aib?Rh}XwdKIbqC#QevIbCnzO5g>H>Gcq4< zO8{MH9I^q^b95l3G|G6wbT3KL_R9s&sTH!W`PHTFxY>_qSBG&IqDATL3StXhI8k|E zV?QL(zbg}3Se>t+7NEtqHx20ug+n{(Sdwfltj;oV<3U4y4|@CTXVX-;w}(lA4q2iE zsO6{0uUh9Z@kfCZJ+sZTs<^VD3dN2WlQ10(YL?Lo^A3XsviD8C$BH_MkA1bU6JbiW z^BE0eWzy^+GQlMXTf%$wzGJc%UqavTE1qU`Joe>4LxvoyZu;mHILqipaKbLN!@004GZm;`KWKS}_oQF3i)1Kjeu$Ir`oVsZO*AD~0M-Ip`PPz~@6%2lFu{_4 zq3`4(c9>fili~D(V_%;cIPT zjth+=u=yX!7s;RA4nEUYp3(^XFfi)@4HZc4p8KX$N%TE!eZaLa|MORa@L6GjgDVKT ze73>-lXA=-(434O#}-ADa`0>q=93M6<GsTn$mD1LJ&RGy1{oauEpsG8V5Htg;dpsu=P$QE-yPuVN4wAQS%NZ;Tx5j3I| zuzAe+XXuCi@GsayR2a7A#pVFDHS#sO&@)XO!aUHj{Q|(ZBgQP=M<4ls(mCc)`F@0G z#`Cqm7+!CTOcxiJlijhr&uHawXvOl4coi@!*pX0z>s?d^(<&TY00SOy?b861N&)~> zg!vPXRyGa59qGrj^|bLJ*5j8Sk$D|Z@JkU5VOUTaM@d$Q;vLB$#|>Z=be8o zf6sN&jAl}j;|l?0-6;QSV@8K7AsId;05Md)m^`4;r0&r!9b!Gn!Pc*2bxLLywfPdRsw+IQ-Ga(cO7wSVztx-RBmr*{b1+qzi+g zX?IBI5Fc*s%P*pU#V$$5U1k~qD+v4<9nGnJU8`gc_!WExzvI3r*GF#<5<1SQX z_VVEN)r$N9GZ5w5XINlW>sAeD&JXnnwb|K5#}VtvB?(fFssMt!oZJw3@#f-_=5#jw zSPZ!>a&~=r&GufxREY`iv@PtT!j#F4)fv|*iYuz~Ltzo;QB0R=3lHlqrWqiPdIMzo zaxB{?fp+0vUYRmgzl_ML;2^-U-jCaA(Aa6qqXsp{!O*2zR=Edo@!5u`FI?O%S)Jyy zzGGus$ol1mdBk~CBxQ%)YEQhx``JJ#G{&|M+=;+G;OC;%$+NUjf)iC@8NjCxMk*|E zPrObyqqX4bkmy3QBl`@;dvndaYO1(8X?E*p2wQw`Z|Kv-oMZiABw4BlQv74#i(wj9<7I`oF2vjP5ba~Z>5@rKqk*YJ5n(nW#_!kV_WMt!a7Y4h(>QOj7P@M`x%{y z`#Z43Nku+>8P$~&!qo_IAg}W0m~v{nc|{D zis{hsPnEQ_ZkiLRm~BkEz)^W5l*L;9EQtkZ>C#joaJ^*SkO6(e2`!IA#N9`2N+WO2 zs_q9^ooJIAOkT)Y214Fj>7C!I&0h1f4~(WMhi^aCQ9|u*SVO-Z;oX44j9&0tUyzt z0bbf4?i@>c&emDA?3EYk^M!H?yPgEx!&wyRNDTD@%u-?r+5Zb}`R{N`K>>!9a(hEIl%kZl~2t#PXl2rTRP zNg+UxDK%*tRk;qzHGgD1RbJ+C-_RMC>(^n(q;t=f&?QSQ(z5oVbHGrt&Yz=tR^BiE zxv@=8GDpMTAC&JL=9?LktM~Yd-xkK~z*c{>-#H#J&Xe0HHOKDZT$YwC=H0cI_W;VJ zcd9tq@1fhg|M!EhZ{kz-66pMY+qzgnmD9JGAIw#{2UpsUbfc^Br;*?@reO6j)%)a? zN_LvC#&!F9{0=G8v#-uL;U2a_sH6N(K>&%Y)MApV(p5-D&3IQ=lTj&C0;?jw$oTSz7CL3AfhL`h>FRQTlkLd#l!?7Nbc_!T=Z9W41Uo zrPn2$P|$P9<7d6QbI&CFG^tq>69nTw1?KrDP;i44yBr|*(^H%Mk&n;CO9i=dBD7S} zE^F_m>6=peXA{lm-;|SzcxsmPl3uR5pH#}k=syP@21wH7UU07YH6`7WSU@GUJ}|c& zubZ(^7k>WX?_Gf7VM|u19v2Y^E9;LD$zoJ|s0OHPrB%A|&m6IXkf5o|`UgQcJ`+o2 zH)EF@p53Voh35THs;X&xw6T}MJ#BylUPTAW=dxY@p~(ZBh@VoCtNm&EJ^&Q{0>Igm z#F7L_dz+pFv4+2)!(*O#cTyH*`up436_rh|>!r}&P!D~i!=8Y`mK5S&)OjF$mPRdRDlInd9Mn}8 z={3D+MvaUVmn{Io9$d(B@TkC})P6^Ayee-!8DL=N(15X%fI#>^jCENIqpG>J(`2f4 z{JV;fHWliLXh1%;{lbdU3bdTZ-YXaJI!`@o;f|dgpt1=hsJ?yKrIu(>>qs8Ae;Cyq z!)kg#Vxp^Xch+;O`2LgW%m72}fN$3z^`T-DMFu981K4){=)g2UsO+Uv(y^9ezsrE9 z;p)vF6=;`K0#Xv(aj3Z5Z%k0X${}{^a|YFEp}_$$6624Uaiz#nD!t|cxN(AzSyLqM zEc%D`3O*e*mkS1{U0l$oKtLS`|N!eb9@*?CtE;=&xKI2R?e?J5ADD0l-+!Yk!;k?Hu1RttRrgLofLA zYt;4-Am5yUX$E)LD%z|itu>}R-y^S?!DcY1k0xh71kz}2&x{**15=Gtc+K+QL# zmGCQSxIol6u1)OFb8htj_Mx>)+#Q#-?fZ4U69yoAP^*Z~q;k;C*VX1f>F^kC%^vy& z4?8TPMYnjK6_D&Eja%N%#27^FVpE!?9%Q@j?%;AB#lJpuQvp zC76U_u*Ck~sSOk*pYIRT7hicy-zr58f^B=Y5dNw#W;->&1*oK#sr&SuM1^*2^>Lb1 zeoqk~LQc$k*8VoNc#m+^LsE6w;&u)zIbe-MzCFa?rBlmI_J-f3_|degYcoylhSU|_ z*+DMZ>wcNs%M$ef{kA-E!o5g`oL@1jrAxdL55kj|QGODP`lEkatEH@#NGZkbDouHZ z6~@*-BHxhDqdWgeL#SuMZsWffv)KKf+(ou(uzM+1=4&AR5NWvJ%H^My0A*E3?o-i$ zDCGp#OKJ94kK>}*SMxp;(u(`$GnqAgI)g04I&)Wrq;N%ZUZV@9C!Xn!|*SojWPuNRx6!{We@*LX^V&btEiO$5>M7G zIm%mi3KVkoGHDk|1rt+>Nv~DfPW)Zx6A%_Z*?6PbGBZ_fg?OI4lfO;&%91-S&A9nN zweqd>r=VmFawY9sf`&Kz*;y?Xj>aJd5*ZoSI4$@hu_Oy^`k^G^ZS=J?0q zUYDo@T7jpTx7o*sqL#9%zMw3_cN76x4!1W4I}~-=EDgfyVt-1Rr8oe&>Ox=GHK6jM zzRq^aQ zlUZa`G2%AAztpa@G$fVMjV!?T%p2$lYGsy40KRHauhz)idJ%!O_>DJ8K6z=tmA0&A zZ-MfILw?4v%kXW@BdG%s+Lf+C7Q1Sig&NeHf2N>7SKs?fcb>_p_P1n>zwUmHpjmvq z!@4RR^-F?FUGX}WVIj`1>BXaU~J zdYoL{x4Pw8*-}`fiQ(Dy)RxM7;CO$MWTH@CCiHPXcbe+aIv4xHDUeCyDVU&0k*N5( z5`$bsc0-9`r5$6YDrwvM9mr!hn=m5b2fz{T!`1}bsKL0^ar0fkut64%lTOOdXlt&b zWywQ3hVQt)jde>K(53DL{scu;D0&On_Oj|%+cXuy^V3%?jA%s=2_~QJ0@Yw&gqAU%nCg&IlQo@DAx?eo5*Mj6zIq;R zs9EXcr$n}PoL`pA?VdK~nT(3g*WbDK|DyP87hbflMZb_8?<_k? z(XJns(pv2EF|O=epB)Bflv)I5w%ivZW~%+-j>!Tj-VC)55Pp=Ri{n|h&& z!^4>h5{dkfh%vb4)GA6z$41Jg@l6L!f#i7fX$7V06PuyU@aFb$VL!CFf*qHxjAU*E zH5b6Oj)#-0Z{%5Rlm?RbXinPCG2>Cacqcn~Z}<1-2)Od?tl6K6Z!<_M`0XZ5grcFL z@Li!*3cwPd^n`4!2j$_v_ijPNY4hh^Ie&rXM~Di2o4E;aetGE0DBh##BfE}YVL?b2RcEXQ< zmU4?T<=JXGyW0E5JS}RN2+fitzqRJI!YO>gf;@b-4_G`oUw5*K45|fsQ{O)90A@~Z zA0xTK4cva$88NZwRl3FVJ8G)Uu>n&bd|v%L*3*rF4sUPz6dabD9zH;_IPUH8{qodp zUb4x3F?{A0?I*oPIZ zw0HP9EK}!vSPhq)0*q3DWSWd>?3atibaBs9o!d&H7AQ{53H<;#mCJ1V)_Eu?$IRoS>!X7)X%ZqPIf}yqDC)nR3HXTn)jNwX0nAs}ISe?4@sc{BNq7$8$-uJp>M@)k2&G>ZLm5`YqPU4jG?; z4eG&2G{@sddkmilC^8lRm=xe8>ji_nQ0dq!PNg{dX8sc0_9IVqLfs!kv-5y-ZZo(;H1U&6?fp3l6#g|0wm71!8`}<*xWgfh`Hw&s_BV)c$j} z$pMRUN%F;ZDt@SJ`hdeNm#HnJ?|MPiqwo0nzU|P#5!WOU-LH~x3*?@|)c|sId z$zRDHT|ohVtG-F&Fx_ibLst#WxK zoDrE>((X7bKyu>!%edX!10|7K~?A)(dDGh57>9y*%W0Wj|H^yv^>q4$EZO^rE=%3l1nUqw9b5=qSQ7L*AP%z zNm&ROtSysXzv+nq3}IL$(DA5QWxu{RsA1ta7MPTK;$Is{Bbgnk{nD`4ZT|l}qg(k! ziLY!hoKq!;xPn(3eF0L?#M^D(9_J0!N?wCz?%DgC;4})$?faBszmDh>N*U^-j(6zO z;VmE;;R~Je$KQHs3WjP3$Q|7ej{*+d4SH zmc+J`g=o{;RDRT`V(hElgkb(PVj{9)qMlmM8@1e>!W!@wEaOf1TJtahi<9ut!qW%2#ujjkm-Y_kQMJCp515(Tjt{K?k+R3ZNwn+nlA5i zoyS~1hj^^x*qxT`(@u>68`Jw*djZ<jZxpw#B%8uMePYLaMPPB4cJtZDg7i*x&X`z$a+nMy&w5I z?^l2O0X+q)LYg)?m*T}6^mG4=lC7mc>_-JHX4T{@rei0I7o^8tu!AFotYAgJa;c1{ zFvCc;FBh{r{B}7Tg`R-x-foe^B;=z+$;qMt$EiY$a0b9Li1UsE8)6O^(}UnNz+Bhb zK`PMaMtQYx#{-naR>>=$%NKd1??ur6oI`f$Y|`UR1q)O0S-RQ4ynNs$HO;x-YQ_Zw zTsBDr!vfEjt0tzGA=KNYp&8XKspl}4L9p>WD!lPmHmZ@}Zrx!JFMo*eaGB_NzF5u3Vp#=T$qt1k$IXE6tQ#its;bo) zRM*Km63{eWU5}wGx_=k8t<~)702*FgxKSOL`HW_mODFrYPPE$n)ouL>)37b8tUSO8 zHhWefM{m-e_K#{upkXEAvf#UQ4)g|KgKtt<)dsLnraN64wV$nBh^)@c3_~coghPfG zm$EF@8>K3<836-cyfWn&T-h>K)WQYXWwNl)QsG-Lpa;rU_v_n%t?<~qlAW+j&4C?n zDZ~wX2J9T+Ijf_slkjYn*wNAR=S|%EUDaRzNOfI~f<={Z_s8D%c9?D*)7 zJ|@7YJz@-0OsV^1@7cB+im^4wY}Btu%d8yn>I00bKVJ)No^QM3HlE@4gy4@Sel%dv zxHh{%TqEGrU=+fE-WXuR4}+vO7Kh%{OH`*~axq8NwVanFeR{vKheL0UFw}ISD;?*@ zM=O|p3V6-pc{+*WSv$KcHikNq#g7}F@v<4^q?PKc{La)a)Dx6u`V$J+P%G_c^pb=M zIF{PKxqhVnS8#Qe2I`a>@SK2Sl~?(54w1knA&qF|c>V`$ooCTcUPk4g!=#UoI@eu5 ztJ!Wj-m|rVR2v$E>*GFlygm>1xV6D!S%*_V^7Wm4dJ+By@hXmKpIoGN>4^OX6|Y_n|( z)JgVO0nlMdzDllk7Q^em89bIF&O0^m*^dKvsl=-odM1@j0h5pI0is`=?9X1s-z)IQ z5}bvEv)jO$yWWoCqPcy8^)iUG*9W5~M(YWew^+0_(6!|ZrfOeXdRaKvpb8XE8BY8Z@j+VeYC6Cn0Z}+&mY>#|0c%ew41PfEs@Xi zccWug>ur$f_GypSGTXK%YO1ip&PW7|_m=Qx%DHZx1mB}$0KVJFmTLn9BZ+hsqRQ8} z#(DzNtIl3R&-;*sX0huRF+L_OG4-?sI+*bygH|TE%PL-(^4dKG2cb0H7@ROl5>pHQ zc-)<=G}l81S4o3Xgf+MN9 zhM;S&`movuYJl%+B<4@Ha&*7ua!m2<4ym}%HN}u7?PuAFIvE=ig}N@os~C?pPapcD z=ei|ELgB9nzn&}-!oXT9MV5eNIWHV>`@D+$uJ&lPET^b^Br3QY7`uGi|8IatOB&ns zDL7=w3aI`#UG7ji;0L06i`uM<|FWdf8<)FgkN4>~r5(Gl0x(lN&0@O*~OHO zb;g=#pJujvP~TNm)MA?k;M{kyCHTbw zSOis@t>MZsiQ0pc@4$=c_JpMS8`I!(CAs`nrNSwDC@}G#tZG#LVK9Ix7voT(Q)nOq z5b}wAH9qJ4UhQiz7jK}4REQ@6eGco?o}O-~8*|U@Ahk9RPWZL!^q&RITt$f2(Po)(7mwQ? zPbjHna)^Z!iQC~M4YI;>EgL@~47fnNEr(tfGwb`a_=&?6wBi6>RqUwoHPc&BukE5| z;=ansao(%b3U$69DX%cjLCLJ{M2RrLpuGnnq|=AHQ=fJZkcMF-19N}L019=lAO`$Y z!BOr-iRa2J?EL~T{meKl9r8>e=)qBbU-HjIJ8`0)%Ox_oX7-5 z`tUygsC-wPOu>iQp*4;v^PbQuppW*jHyZ8L`xbpj!PPem?gZL1(e0AMsjZ42xP0np zrJz3Vk$hKTe>nhPd-JH^)Pi$}s@rYmvr?nATijhLld11x=!T>9vzKKw#!pK&Q z<1z7i9U0Jz9EF$NBl^ZBovDrI+?OIt^KJ@nov6y;{s@Cs|899+Enr zuvj>==r`|!`<(8VX@PR*6mI=L?7d}Flx^Goje!b^0b(Jbq_mWvlz|{EEiElw(lLq( zf^>(3G($-@gVH5R4ULp^H$(jQQ?L8F>sjkv|98D>yab{>%JRx`n!`v$GiP60==GjC>|Naf5o~MbKJIA8d0h0GRS;-@}eUZ|TD!zwSrQP@&VI`!U+gb1booH4*R6+6KfL;@^MD$sKGwnF=CA-sB zb4U;-AGOPRic*WtRov(v0Qrs>G48M)F>%RneP5CQ7|Hrlx5~Yj`@^fwh-wrTDwmbt z8(E%9A|6Pe9f*}a^@kueZ?iNk z9R1Sd6Z6cEGV9NH7k&Z0!PF#^lErsd|7#YvWY=wi_mw*8Z7HH_zxO88^C~dM?(WQB z34-n^TBoayo=#WG@NOrKu~vfeUDBfggZ}oIa#YJNvvR$GZ>o(GIGu;7jD94= zwk6!19lTb@Rl;{d)^{zv)V9t5Akj5C_Mw0VE}QP;D=3-+}bhcbbZ z=v~$Wv^-m>A+cd<{XNk`{-;bYU;@HoF-+OFy4glXASd+TaE_8v$dRhpdvx(Lg-s3|P!kJF=QDIvUq~#yJxkJ`N^+739Q*@3QKV#?xNJny_EYsQ=YH~u zudVJpV|88&VXi_hdi+P^X!|TL=esh2kM3pPLD!99wo@>*afuZw-t2rNym;U$Q?%5d zN@Qw2lq*W1vaLQGdDy@)Gx#aA4Xr)QU+k@N{z2=No|8liL>?v1Td_mIhvRj=X7-AS z0Zv2%j}*HY0@%V8VB{y9Xq#DG;DdFVx#EaoeGW6q%0`=)+x5qF`#kUe|+>~x8r}jl`GuCTFFm> z*HVhasF%8n{+rZwf65V8f}@%Ijx9A>+?0>c)ehr#9Daw6&A(PPoX<5c z@YxGpVH(bJ4IjOtveB$h>mz1GSSS#BCdsD#V2s&z2rc|fm#!_~L}7*)uBdH(R&?)i zMTu2`70U(!`dIjw-dh7WgcsQ8?umEC>cXU9x!rzQG$&sgo=NEI&0rjxdLcw6P-YcG z(B0rTTP|>ZH(&~iSFHA1y&3fVZ_0@f1PkxW)|ZtO8dYJ}RXE>8n6!j1`nRUF%hhS7 zKeMEU=_8l~g1Bw!@i0QGi%`41KrL+9H*#%Q`uZOGuE^mmS-1Lm(r{8_S+oj!zhwTm z`!C{;SNLJ(98W8WHqCnrE%zfC6j73dcA{hN?B~VSCkT~-SREb>^5!P1Cg=70qaE_i zrYIr>oj;k5lw#K_4+l|*6gd=TPupFkim?Km6fdl}KHu_ww#OY<9l080SWZP{h}m z*GJ$i=yHtVbM-+eBNEF$3DM`WyVHCN%zK`expem5z8g`zwR8rVT?I?^Q!CtBI=&ID zIDYzak4=-Z!sGxb1jGS!Q)t{K^6g{!4Q{fcus(Sau?`w9=A}1P)rb8-sXMASBB=i= z9se5K`M_bcdmYZRtmGtNN_p2vaVTgxJ&u#PICYlsy|m&HMWIb&SeR%ZWe$BVON(_n z#8Ek|r+EyxtbA4dK5TP(t`8kB19Pj<#t}TmzOb^(T(c6C0&b>vkA!t3KQfUZ_}q~s zZG^RJi;$vG@Tx#2Qh2W{ob}|6HwNp@AjSm0A=GUa$%*&hKgC4zQnY-T5>z@;>1 zS1oRe(AHR?2h`KVEzHmzhUN6uhzXQ%*#|Y-j7eD*iotHwzjWtkLm=x_9){-k_V|svDJoTvbLX$Y zyGp7pk^tc8PIyHF-bSGfS>Jh`))FVlU%$0rBJ0&8=e8`m-gY zVeVYg)5otvlSGw3)spLJQ!`FMj?Vcm#}5G-jhdaciI5_eYxp=>F?)qNCeml0j{al} zWBEO$KQq(eK;!r-5x1N?*E#>J)xA`GBQ~Hz353GTsMbgtn0Jom>B&L^cs}Xs>Ug`Z z6n10tfP7NItlpUF9BLUXh~ac8R_{O(C!S$IB#AAz-YDxb*$p+S>)!ftd0R_uwIDwJ z#)|!)F}Mp6jTrS51&5#&WWeA!xHzF)YNHprLVdEOhZ`f2FJk*pZHSK{SJe>g-EwL!Wmc~L>cCsxm>(iU2{a&LrR zY;y2PZ=^#1xGfM|pE~VJa+kYwGJr)%3j!k-m%r;XNY}gulA(8H00i~8j>Ot33sd(~g zGSTTA7PZ2IHxpK5#fx1UJkW#tEziX%jdLT+1ptw}&q#Yn?yKsONUA`@#R{j=IN4uS`D z-3wndvNbU`W1P?4u8fsWenKa7cKYgAHVdjNRa8_gmdk5q8!h`I?mi$$HtkRO{NUL& z72*o(Z0rtt5p7@&`tmr2*3nHgLy74g5+%@UfKxx79x#$z#3;!0_( zvlOU{Y6j)EKFCe2p>=cCBEuoDUH^&D+L1Zv-|h~UDpHAZ#7^V3(+kDwz6@_oq<7+q zXys`3tFr=L65+^1zeVS0#6bbg3jHva1zm@kwEex?X-E`z1_q) zx%`79WzNz0@L<>=(^Aoz-TuS*n-!z0;cVomlkAnk&t|bGVk(^$JPMXN8myl(xV}Ct zq&39H@3UVTYq}w4zfz3UPbP7IAxhk@R0PLO`cRs$>0n-G_D?%fb&XONBczSW{E_=X z)h$KIjF$urX=2*O#36zH!L)R@BbhgrN2w)n2UA~;)l7Pk)EDoRK{e$aHR{+iM;R`@ zMLy-60UB_J=2y6ZIcjn{4{yb z8;-+OT?Q)4VLn$@E0d?D!K2j~#NAs%*`JPCcg#)_oU+#Uo?{iV8)GYVob%f}Z7BHq8)`>H&#xvX;lV6OQqRQ1E+UD+47O` z{wEz5k8`tTyLZu6LrmE00dmyz;+5J)I(G4I>$x(L^=9P0Vm1@VrTcv6_T`f#>^pMK zmM2N6SGtQU3j~|z*T``#H_FG&kyf)P`n*a^RQ(7l=Z*Wsv{&#ES7e?Va39R{dN3JRK8q9j=ylTk-~OMN07kMf+=6~>T{><>D&^DuC)4Nr=WTl9419pVv2%B z7pbxnZZm6rQAiRF{B%X{a;vqlitV(qM6?J(fbrat1=}shbgHcNem%>OEr`kHZKjmG zNa(|0!FRwO%beN3oE{NXOUb5ry}XzVW{`Cy_bWV(z5ab=mdP)2TG%Bj_aE;FH5p@l z-K7MY2kJMdX;xPZPN~RrfW?HzJQQ@EO+s7be22}`1f6=#y`yfqD3lrJYnlYHr|#5E zZ0a{9BW|y*`MV~Yc6+3Vl@x*WS>B*E6wGUp=HXR0CJK{b5G?OOZ4UBbE12FD*vyLo z4lx|zqJ@n7G9uWXPhR9v7;A!I^hGH1Xz8m>|KQO`MKcU;OF#lwX)|R}Gbt8CllS|; ztQ#t?A1JEJ+|3ccmRvQSMYnaZ==sTRK@0zvJ?LFhWVTg4>MPBJS4$&jJf2IO3dbjq zLWujBW{VjoBo$wf=Y-;+qaN|)3&4Cx*G&Ex!YV7AXRxafoq52Xczo|09oGJ31=(<9 zJZhsK9KLkP8UvLs-JAqmgAAzHj~xZtX2?=>)CrySav_=UL0gc6m9AQUkx+p=YGzG6 z3-tA?xS-Io79P`KI-KO~ED2tOden8DDu?Ts1OoWh3>8FxNZE=Kwc+`E=K^;BIEZ~bPrH)>^a^XJ)P z9)2M}tBK0Nre<6+yhIG9qEMvbXlp)LLH_-BFNc6;-S=jy1(*00qfHHDnOj{5yc{@i z&(Exen*5el&LMq_ffLcKC;SE@h3lB_KQJ0d2;640l~!2IV>hVe)kA~~u# z{>-Ucu)@(iG^8o`8mo})uaA!g1Z?}aT@ftf^630I8Uhn5D$5%hHLt4!5#fPhlmf$< z5u4q4d^k8K((~cq>{aTzixeC-6NAXm#>tG+R@mWVs>>4qaAERYfNmim>liM4_1ucy z$EigV&@f(jR~GklYADRm?xVA^{1#=*Kyk?H8j;9sKGTWg44MV;n-tXR_&0M{`qb*W znH@g}oidv0l~<_SmKNA;0;NZ&g_*2tXE>3Gm@gw~fgASfhOrV-0sH`jG$V>Db3WLd zHwD!zNIfWPz<#nPraXw=@J`d7%8Bs8p=a!g0~>tldhzJlB9&0-L`+?1LAsfY@EW-1 zN~{+=hPGbL?gRxsW6wN!c55D&_()6z5N26Y;%Ukm-n`?XU$#U+&`2KD_~$~--(o6x zLn6h;oSf2SW$YcZYEi!>IcdvEXSHTZQ<1N?054g~__PC$IvXrCgKj_IM~276d(bj3 zW;zlk-me>0QLP1%ba7`T)XJ)2r#l7oqC*KV2Ul({bW!f|+c$<%XIyHx2J}&M3l`)# zFvA2|!{)U4BU+4{S&NDo34KtQ)SQVBqpXMbhh7lG&kx%_VO6g%#TMN$ADXct_#7+f zN(br%tVVXd)whq8H)c9gtQ{!RS!i@}N#9&yRe$D1d^d>SakusM3HD^M%vYRdOvYWe zJalDKrQ<@u0?=1!mTxt1k#4*y)+b}tQ;3vF_-(ICaNxyeD$lC5=)Wn}W1E`wk z=7m$(FpQjQY9zf(?1}aLz+%j!qXe$Hjnia`LCT53IW!-TIzRu13;giU+b6)DVAn`B ze#bUgbm$Y8!l~{y>hAi0N;zjJ0F-aWuj$2t_m)I0bP~|_6WyQ`(Ee-H)1QJ*{ZorP zhilZZWk<%fq{Yt4#2GvcXLk|+b7yU)dYbjLLbs(9ugEDD&TA{JFoNuR?ETX_j5i_) z-kS7|Af3d8-M#tFb&sXHZewf1V3iX2XwTPmS5`n0x7)17>R_3=@w*!}6VTN)SOGJ{+6(C$@);V3N9O*{jK9kKCv2G!lrG0AL+3SJO z1#bpTkyJzQ!M%klcWb|9J#BvNFA0+!8EPjzw^pWecX&-f(P~;1yVALyw^Xe?v=xJK zQ1K>ZeZKQdfj?Q?BSg54l1@5}mhy~l0GGHghT@*Jy8X^*BC04 zC#wdIeL4r2p==4_=Q~c$mZ30}e6l6@hS`%Mlc$w{3rZmtE11^GuVcSu46NdJCraBu<1l(7jquta>U-514hkSDxdv}W?*Z(H+RyT3MbAi%Hfm}b zaXNDXLHJC@A$Ws=z@Vrmcvca={g%TQmDk^u6dphAF3#@z{1`+;CJ~LdO?z*vqWYmX zrg9I3_QBQhZ%@aeD7M!R_AA?tUkiOE=U>^Rsm}6p`a4IG);R)_dj6H617L!{^i2|b zddBrZd?9WAt1{1Xl+n`e4pYtDCSh1xF1+^AEH#oBQ@taA#*)rhE^%6sjN~*c2)U&i zAbj_ZQ0iyZ!&JI=y~g%{;>zEPTpD%;r8fG0B)7o!0QN!QyG)O|!jqA({{ z$}AkWz|$X)9@qUAH6!0?Hdy8=>UL!>e`f!6AdIeJsj>Spvz^`f=LHBzYV94$k>eWL z>2%X&&ip@HOHcm>oS7SSlrSnK235~l*=h26Rk=!RGS|1SE2y`A2xnt4lMtYcK5Vci zpxLpW3b@D3sG_PYV8|uzGa<3i_0py3YreF_mgAyarMM$P0yAfBc}0opcD0yQXreXT zWIjv!{;AkWqEipup7>MA|d$3mR6ih>^|XNc|2KTdj{j+WbMvFn&D zKk~)<-LLlG8cXLEr7RL>#1INlYnjH2EUF4kQ9GO~UBA1C2Jd7u>oMDKc)vpscLRO` zQscO>T0aI7QKHaOqcwaLIrmG7_PWu9WKvjIv{~IBLDZQ~;^QsOTy-6fDK9#G1`~Yy zW-c*o$yfA$xi-D86&S53US@ew^JI-~V^DQPmgo|_%rzH|S)9@U`;2vGkp@6qx2I7} z_zIzgs6$sWQ}>cU5I^=R_5op+HVfGhZnZOhSak&7RYj0f`t3`&%Bz&t z^WRWLiaVpL{H3PKgRH;^Hj-0LjPFCdTV7%7G!jwIKzfNTAA})Xr^(wk?BImMmVGr4 zaL!=aal>+Dn-nzu6jzliDWKB~exK{>Ip}R^E2^v8LzsN(7V!7irPeN`5vMe~m)Xk$kY@}l6w)D{h zDcT?G3H4v-q@vA?)bf9^S@SA2hHp&o6>1N-iPF+t1$aE>Md~N1u~k4>&aJt|mZwpH z_wI6!h8ymi#*ltthKfX(9jwwjN)1uzok>|?)l{yVU;9yWT;>^-2m7z$By&RTsUCaZ zS{S&q=D`1sDTGO*)v{Fl7B+wBe3qqOB~n!Yj2f3Zy<)Q-v4E;Vbc^+5qfY;cZr*pK zQ>^NQN)fXf9h{0UAJ=uEdC^;>3@<)*N!GD0@>vScv+EYEOv#oxXlFeMOFjOOQs*Kr zLA^9ZE1)&5{|(bRKq{qU1Px347*p;phJOiaY|^7gG$YiZ6+f6yh}1cP(Tg>H$=_v^ zk0t=n7j96ppkG-mK{l(y+227U=cxeL{jhBF+ARdo{o6=v{`L4u(t{XdbD@-GQgJd? zXtZVXI~uEp%9OfHM$L0wDUd&AM@jF9xTUm{BY;5O)Fl7w2!oxLu2ok=`jbMY`JTN+adt^(RVC2PFV1#u5S(^ zt@pZ;AmU5qXk)kq=S&w`>4#-GNKFEvCjOq3(el$_Kw!$3_r|N`^gB52>p`F>Q7Nsk zQmb@Z3sWj>zGW$@=!sAi+E0W#+Wf)7#UOkJbhBUQXI%^XTlr&DY2=W zl<&>x0+PgHKN86N?i5LxC=%nRoq>l2F%W{d&6{U-?Ltzx}w}QP-Npj zW2fLA-o*Lqo-4n$E#p(nc>3nWCsV!HWQkyLkM+5md_1gy78rAFRiZ10TlS{NT0_WB z@R%FNK2CQ&X+#erHrc{Riut_bw32{NG-5x~%&k(?VOM8vE5mQsai(S~oKdq83_egM ziQ?5OinAB-k4q77zAgSOoTu1|xVdlba!V0;y{jDnx(b@4k71S!oFr@9uCf8%>!Ne| z)J;$d^k~H1)VNk&{0DZ;FXM}^z^L4888ezlca82Q%5WvGTXnA%Z0Ta5_u6QbKUuFD zeM^u->uBucZ4~$C2l_ugWwAHETO0Rlg>1IBOIV3IN6Q&?{bY79cuJdsno3?(r>%7A z1CZTgF0E?J>sK&tZ+TuG(MU zhM_}E{m=D^=M}nQ1%X%*0O!-KX{pcdeEGjv@+y|z)9HqbG@S+VGb407f9&_=xfYj5 z_yUX!+xOYg^W}m0D-GR#ti@oENE_dkOB+vph-1>t!MO%98GsBWy{ba2 zj{)}|A_bXo+=#r$8W%ue%^lT{tMy0G-ze*CT5c~*i`(V^aGmh{B4pCtc&%_xBsxF~ zj6Y_0oz0I{D68g}M{7zkvk~HX$!MJ`imG#6iE-Lvq=k}F0o>>-@+k_kUc@^!vz%x` zPuZjNq_qhr@LSQUKyMMFa8)_Y~*2utx_K zKKoJFwgTumRFz0g?OIoc+$W#WSE)ShSzv0r>t!HEG#8yc(vnMpx(Y-U9G3-F1>x{q z;ZG)h>7aT;z2;^pn?~GZa|oO-#8(#$chDOh6&jbLNx2l=D*_0jgLfGvIhdT@o+a;> z`#k-Hy1_QwbE4|T(*e2FB(XE$z_F0$QxZVD*Tn*Ew<|=87iP{w+60 zcA@0S7njw6q`*A$zUu%n&NDiH%aopjBjG?PQxo&fbhxa=K&PncjY#A7xQ1)yk2c;U zd-q8nPo`tkYy0?PdA|~{y?k~Hr^E1a)s`M05g@5G)N=^I))rXg;p{&KDe)g~vZ3MMcSbqimGUGELSr+mul=Z@c>l=$! z(TrZi=i!miTdGgdvloRN;=DQKRz#fGt>H}U&X?yN)>+T&6n}WuV$*R^A?-c|qTnu&Eboxq1zP>S<_(XQe8aY+^7&~mOhK+xWt;%cH;3C$57?74 zCGYZH%rG)&RR%I^luDBscCWSXpX9KbHQh-NcP;&GyL2Fn0qf=<2ni|64K z{nfrMRNP`mQw`YQpFW+2?Gj4|_1L(M&*rwBccyZ%J?nj^iEw@4Y+1BAb1pg;gO(X&GC5?qc{z?I~XTU55fIa+M9t;A*Hcw9ou{gX}9y zfyHXLJpK$mF>~b1VxyF5Al; zMUQpk1dGuRe-vvKey0z0el&o;6Jm9t$n4Y`fi`q|>nE!u z_NzX>{82t%72oVDg60L&4Q|+V2U_((tWu0kP<*mGtB{bS;e0kiNkr+M?F;<~{uS6N z6Vc|bw&58EB@fm%gE*ZHnj&t-gw>jZ09n`L5fpL1FU9w_Y^R1hs@iCc z`U(zCHVt=8uy0gyJ8WMeAncHNmKCIf%~DaZFP%Njhr*7me>_;$NIkdPCn#{w_;sU5 z`)!4U&j8yF-n65cZW!gnhPA?+7^zo-id#TnvP+ z+icYoARQ)uWes#xb}AEtGvr)YI{SCl$`dcFPCHRO_PJ$i%8$D(5fvYs%4O22Lg#lN z%c0+TetzdIW5lO=gEmk~sz&ouFvtma+Ky5-qhud`Sh#CFFEeBtD}Z8!noywZcq(Eh zr3FNi>P@DNy=5RS5EHwMBqm;BGwc!!1yWSY*T+mrK;sr1lGYXWPZ1!&=Id;nNS96C zLmj$-u&lyuUsb2Udxpd{mM?wc5#!a?_C$K{&zO--4ZOdGkr$g(aKEdjK>QjOf1R-s z$}k!EHE{^b>!xjne7(JpNDmyNR!Ye>x=H{OYP_KuaY;!4HJW!xt7{E+Q6(Y`$t8Jww$^$w#fPz8oxi620y9b)!?f%}7e|UZ3GLim{1lG9 zXC2wV3N&~#CMAc*OOn<`vKxNdET2OhRZ9Gp%jX0NHn*)tKc#bCZe&ZOH*$5S;J&n} zfjv7$1g*ie-XoQIl*-vdry@3gzE72j6NBX@(TD0=qhCF`iBF`{Ncb5m6niLrWDUq> zk>UK{YyDVFmf*1&!5_8?Qs#a=#1qT&yA}nkpA69|x>m~g>oySCB$+Ow@p2`vnhCkIhx2)cFsKMi)Fy!= z*95T_Bi+!ZK$iSF>3iVsM9N2^NcYs1+2>7za+>fsmG}VLsKS`FB08UX17+(6I96_h3aO{9_$6HqCUN>7LKHj^6qK=ulusrkeJ2h{) zXH}o&8YJqq1j$OQp9ffq*}j_nqH*w+#ZZ+=35xU-cCW=ru=Mc0Dd+2lv_VfoIZDIU zn=XakNQ1`S*y$>>5jg-lu>xk5U0sIum%QTEHEfQNjU(`}Vey(ySOPSG+v=i2CYKmB zGzfpSvw$%$2s*6BukVWli(U`p5(5|O?4Dv~w{ZE1?#f*8isYasQw@`>qp1Q%XRhPi zgMf@n;39bT)|+w2Ah`(z{2+il@@Ob4wkZj1D!t7ykYmr+DV5_eUzO|>wg93L)-6-c zwUQK+=!&yVV8)ATCZQLcys33W(HtN9uw2O&mn!~us7kQUgGeW z%%006KeRc#$D3}4F|;Jnyp0i}z;q<$GQM77ERxy0S0!UBWxtT7RWZB2hR(J~w4x=| zO(Q&x*aVs8FSD7OvXpw46}8nzDje|vF323Ka@||R{<=}MC(d+Uhl@fzTlEprEXneh zwK~CQ^-lb08sWvrneM_j%9zgm%5&`Ef;N-0+$~GxXYL&ea5;p9CQNN83iXfU34mmN7ma5H$!TcjfW0 zF%v@&eds}D`4eDKoheQ{Iyo<8y(|sI9vY)D;lEDHB+J+QWdxtkQg+J3jvLKZoFQM1 z=`!xBUw4~I)uMml#j)u5EG&w1(l`khrL^lt3o)1t+C<2jH?*>nN_Z%q6HN*Owz|SmESk_iZ zlan2Pbkt_5)xbyt=Hr^@CP>|l(SIl683pZo6kneSi65ljgDv8747&BIR zQrB>1Eo!J`VO=r1rhFUI^eva9^Vc)y8drkLS`<3Wk$r{tQ>)8n2M9e)V6~EpyJ^u^ zHIQwFH;e3~>4-c{H;+h(Y(n3{Hc7rqBMp{DAD^ zqfoanvwwXAwSuU^9pd|JS|AfG{1e6}7=Q+AeRgQV)hndtvh8=nb5su@jn2WJZNqUf zss89`R4lfGT^V|{&IjgfitGAIuWM9~Qd1kd^rd|diUk9Qcuw|09d=`8#aF$;rEXG} zM`WBviO`af@spSMOPxEvGWz!#7V3OvPeUQexI}tby)W6K-LIqkP$p$SzAmE6UjP=k zpL~^_gE3n-XRp6IM>%ZpfJ1TlaSVxb{STK?f!?_#l#j0NVB1Je*8PM<_I$nFF3T8% z^ZeIRHUcS@Zk-_+Nnb(`@_4bN%{4gW3g+S+l{u2)NWEh=_QdNFx3{w2r>CzgR5wVK zPuq@x4C`LW!#ri|1*PaK7xv1r12}9jw~fd`!_`@fH@QOR zLLd%fn$B6lD_wzGgL;V1Ro_8Sv2Nqk1)t!R7(r)E5ud0RGbO%@uB%f`Mn+`FMuud< zv?)ws2H(~mwrA}pm@#JsaZhKvv(dDt?0b~6qOJ%Efg(Sy6ZdK<9a_2T)9ax%?Pr-n z*|JE(*w?%qEarzvcQ%b1o+rNA~Bs;cOyzTf>r zL5(CxP)eCQG4|WpbDR*2Jo99}Rn)<>Qj$Jb;D`Uj|4Uh&bYi>(M1L6v5e!3BW^)^_ zs||c-8^0f^q){)il(t|(nh#WnsuglDp+C{To1K=BwZ$+)Y4?(HV=!klbBs`1Dq}hjy|2qb5iuQ2$P&r( z7-_t135K(MWR2uV;nCnRy@JayJ0Y$%)1T}6drGV@#%ODz6!>Z1S(VUj^UtB?4rIh> zI=>QViz6)@7qMbLGnSz_X3ZkiO-g>CS|Vk{o-hTfUN*8GyXnf=wb`z!2M^(^`G>3=bA^sN^LaQ%X83nV6S0aMj0t@t|TW8Z?rXV5Av7)B1yt_@}h|9F@( z#UPVC@6n#zmS#0RVklF9Q@;YK0x6PL*McX$rN@OdV1@mzivc)>IxCu|i+HU>HR1S5 zA^TgToQkXbktiB`x3m7qsQZq(H8W{*SjY-BD#UX1tE!s}v~~2x(kL}L=(>8JE-j8Y zBu%Fa?3JEl_qOMAS#N)Z0C3|#tCA9=(spQ8!IddL)Uo^82{mzg#B& zVnlkfu=Y@JvG;EbLo+B?@q>YQmN6+g#piFpd1H>{Q-J^q`r!Z`WjmF8hcG39h95^7 z{nL+NlGjq3?KKAYxSvfSyj!$C8r@VY`-m2@-p-Q0>GwdFq!9boeVB88Gxd+*5?!U+ zzkPwjcF>X`fG2#hFsVNYZO!Afji++krUKzUcWh=I?>}L!CM%|@FeWBSLRb+DvGYF(R%jV7q!RAkKE1oWHG)cp`7H6pi%I3u$UHoYCH<`q z<0p%f&pk_u!4P?yPj|?uRct<+H~#d9`N9BK{+s}hc^TUgr5iE2$60M%_wTG+X1Soz zl_opyh7qUhP17V&S!p=q8E4j&CZ~DnTht9B`P70r*V*zk;8v{SG6|&dl|hnP1PB~O zw`g9uDDG@s<#>H?W9s#}&jf)NbjTJ-s1@;x(YYsUVTqv#+Z?J8)95s=G}ci|rPxQT z++}m~efCf6RdT8D3!ML?Qv8hT;ykgjXH;LSMFS~eXJsZ15KhNWD6oS;P-00eiZ}3* ziT39F*Pf(q*a=7uYaxn^^lphQF59%ftTTI|2IV4)jxbfdt3~E~n{hV5o2V;8QSnZ8 z1U*nJ3ic%1FV`=|D5k43FlZ#apK$__Z3y_{VP0@=G5jWyjnsm}0HmZHnG*yRD(1+2 z<6>z!eYg=^u?z41R-*FFMO_4p9 zJ(hx=)d&(MpapOyE9J|pd^0>l`q~dY!$9@JS1^P?@b*T+o9eUpCRfJkJ?7|SqJ%+D zDm>ehnXvEot;}J@yA`J!nF(NYfXNt_m;<3A$>E24e^NI%CrPzdkTkiG0x6}O5#|QJ zLt3cz=YM=gwPqM6{$;PU@WJEz~z)f^IRJv0eZm#&}?hbW$J(LC8QoLiLq zA-ePjK>!+fK~hgGd@6^wyg7&=v5n>mq-`=nv$JPNb80hdBBMvEU;R=?zxS^CWfl!u zvZDC_6YK_4KxJZ8i;Y1$6?aA$R`8@@#%U*~!^JYK<(JX2BRL>0`k_H_S5DYS9g{M- zU+e~9cT$99r+K+hIwZ3x8EDR7%9G<-JB%K2yT!(<#F z1|VGS;JHpj*_P}GQF|QbR8bY-I#K`$d*9q#S>_~dwYf)IV++Ae+E>o+=jKnbHiMCp zg?75*bk`!Ju|?)T&u!D!6*&_EJc`dQh)@fpT6(aE2+U23tX1NVTfK8`<^? z5Jw;f(S>O^=AD?X^dO1JRsAG`ZbA=&_daPlFF6ogiz)eC7Y*>%yFeKqE zIw7%(9JC*vESZnIXM>`DLXax6O0JUqPB@XV(YQr+!k8|oml9&I;OlPbG^~${DHBxq z^daHhngqmOF#F1u=dsyr8DcC~2+hu{1ERlRAPV}F zWi2*SjZ_fIw6;5P?OTGy`bgEvOs8QGH;RFPkkIX1Vo|BdxLmn+3ep+3B>Lkd6O<%k zPJfjNm8M1{#19lF;t*s;1F7`H^wP|~W9NJ>44L6DHd_`f@8_Wew>r_s$?~4rdZLk- z+KEdnoGw{OyPh;OW^e&XTiYGPXNmL`zn6SsdQ+xzl!8G*GBMppxfLk)S^FaHVSgk%?mkMpKoqc^y6-u0XZ*B+Tn_)^ zW|OT`l=53Aor{^n1Ah0pJHj-poRaaQV=sHuQD~QN^gsd6DigY{w&;thH?qqIa`?6^ERJRZmd|ctTddR;=Qmis<-fTf zLSse6qLN#FXukDUN8hAh()}v8xL#keZL$Qt^dDKsNIc!kI02L_t40-Cz|-kqz7F;H z7jf-}3|dP6(XlBC<1=H+KZGXZa9eO$N$s~8zwcCkbVRN~ou$-r#=A{_^y|A|PSGOA zy@_x1S zAmT-3HD24}&GzGP z6EbzI{M~}w{5$9Kv`-v|yvG^JM2ySC^PL0*c0rTshIMkThbir<&u#j=M^a717>+MRnycAS%KY$i4i1`xu(~1fNCaH47rL)`$*Uy?G4pXm+D?RRZbc zOupflut^9zywrMqDRlfOUiIaTSsn$0HnofMwxILDZh3nJ{@MXiSDIC2 zyWb=0$@t>+=exZc7=qDHGJ-<_iLeLfYG4M|qHzxU;HSkp6=Gc zAFedblk+kV=$5fxY*#3bs8=P9B@77l_u#&I5KuFupE|xxqnMSUm-`UjvgdgVl8HW2 zV6up88$8Qm?m091YQ_km;w0(FkMQBYZ^fX?`gDdu@ZK}`O+<(FklQZqMiq^8$bBdK zxp=j%U#@T=3FfgFo@SK^zn3iG2NeXZ&H`ET9Wzj0Tpz%7ZH<*l4Isr4O};x&x2c2h zwFxL}n6a9u=cN3VnS97jiT`Jhp1KbdE`rT|AnZ&+fM%Xe`;i@pWs78Y{*-Li1}V#% zf&wZWD8>s^!j`iRM@nQq(Ak<*xy`QMz3Vv3Oep{{gS?lD&H+qwi`#<3YPD3z*x@P)jGmLgk=bzTq_9s~qt#u3YGJ z)c(qQ+@j>T=K$3H$8Z(S-Es1GnGU@9y}#c3(DOHq6~an|#iQ#h^5&q}sW&=CTGkRL zgt|GN+8P3pa#zivuRV5_oEopa5ptd&ydu0!ALtXKT(FFL#d+vIc}J@qRHz7Fe!KxG zal!Ke4A2dO+|Fg1XOy@_PfBRO@s+gzxt0yQW=NK4j|RP5?R&X`^CVG6@Lp$t-*L5x zKJK`)>(W7u^=Joa&UNG{8iUnGp3a6-bW7EKS!mnRQ{>HKN$TI={%rK+tE5%87=R~k zQsReZ9sn66htu9m=;#F3`jN^z?>BX(T7%XO`gKVaE;}8TWKd8Gx)$0}y54)FL=!N_ z$<_-gUo^EUObcHg7RA8o*vlX4(Z}DyrD~dPcVVz9^v{llkG-k3!zSLiu5$Ve_O9)( zii@loQr45LjOp@p;f;Y*%>Z7Rgs9qyoX#mEKs5yi?v?AhZm@|qK_)JT7`^gz%syW! z0sWB}%qJWcdSSN|fe5k@M!h$cu~7ShmCk2nKSXp?efS4IgoIB9!NL$pd4qi!o$b6c z%e~NW0RjD?dyX!fF~0uz=D1>fpm4fkF#b&^CW)L@8Y`iW8Z6!tBkvAy%LY(RSUf|9ubFV zi!@h@DvQyv(A5QH7O@iqc&Bm=S~Xv+ZG0oYbrCUyZo5An7X1Cbcz6~wo%>`g7sARX z+F~?Rc`T-j7LHkFnUfD7SAMe-f@#JP@X7<}p-$-LyM4yiKwe#Cx%|eT`W;?uI4q@i zszgPL%?^WQ1Op$qmW%BRRR97S zf1U#75YLpv1H=P5eGl;Z<}(Wgt>OKO{*ex}TO(98y&ZpS@D;W?V+TVF(~F z#YdLh>DNnMPDVkrOgHkwUKM}jbyNk+!sYo5AR2}X6O21gB0CH3_*N5)J-8fS?4Rq* z!;eVs3nDr6S~iEQ=WE3rA!Rpt>+$>Zn&3O9LvkX;C&eWu8UqL2GVunDt11XC2;g`g7niA3X-a*^2 z*97@xsSACmCuhm;u6!}ku6Y{aVuwbDF0q1uNbXJ|@49_w8mj;omP(Vz*lmnqs(ht8u_{R2jqyj}{5=C_vqO zwG|FBQ-+Xx7e}QrvH9vGRLPN1s=;zYMbcUn{4+&pCeMs*cd&2q{sc?WMu`t7@M^9++gYI)gx*fC+ z51z5%VBpmI^)WbdYqC$qAgj-L!6HY1;$;3F%d*PxkfRuqBgxf<7oM65dcKnTnSbim zow!H$MY-?Xso!214==rRH2(V>dQbBh-b*?RXv*D+@$ke$4~NEd4;G#33cq}ANnlo! zpXU&NC~vdmpx?jsLF2?LJmzU5j!4PqD!o9Z$B!S&&dzuRp*|V4?H#N8$0snrhhL0t zzZrjVoB61B+tm5*iY7Pl?v3XsQO`O3_s8ip?G0^s-z31GZ$)d(bKt$89Vq*2+5WHF zTtf=)(!W0bb01$(vv=UXUVTxCHL4S}?ZApB{-CJ$j~Mdb{`PzRscM*P{l_gn{`m9% zdM~`EPmg~7j~BwjTWPCbhY@4rzun?LN7=N^d}**b@{};Ij`iRE@ArdOv5)`9pF#Uq zp6I{+#VdDW#J&C>xBCwW{>QK0eA=1sKVFLW>Z`Gzy3_KCI$nsoY3^S$#(#ZLyy1oa zi;wvKVCztaQ@HS7#!J4THviw-A8%Ua|KcNV9r+*5$%_h(+7^G5ZvXN-pFV3V{eQI` zLN9OH{mc9OzI#!EHT*+UFnxnfyTD}*bAfzdYGTRGsCF9HEmJ zNcAreNd;zD_^3;1a7jg6V8_^{Kaz~T!}9I!)JNW>iZz|X{VjjkoILeDSH~5P;w@19 z*Xi&azpJck9W#y5>3~aZ>iDm*gVtoB=V8GQ(h}!-PZPl z-^`AylAR@L_~SJx3rXL4Y^q3CSK(bAf}yg7qJgnk=S8t&xzd6=6Svqd8y&%8jv4u{ z;YH~-8)nbiWi5KdCs((Db>(9ok+MIU&5V?k&&Nwrzk!M_$TBnU0#mA}i+N%JoASsN zB7cAXGUr&|l$GFq_29RbF?Rh%R=9=Y23xGgW!?J!!`yoSMVW0;qpcXOI_QWBN>o8n z5ipRm3L;5Faz+p(Ge}MiqoX1SC{Z$^KvdmhI+kX6lCBsX#Ameo9VDl>YFx@gnQQIt=J(zIj zf@44L*#yj;s^B9_<2j~$D>L=R1p$@9jhEU)iMMz~qNFYT2{-d+-<(?O%Z;g6X0 z^t{?(EPa)2KqWuyFZ1&OPj+EP>tL&(cE${v}K(rEqpRT(oMw^*H^rVvD34&2o#K zrE>DZr>_kUTFy{JOhm5vHW6BLCh~&>N_xw@-4y6BH&ixEAuy&}A>gm&)asqd5Cs+(+9Ou2~%W+JpY?`5ZJLc%7O&(sz2FW>pX`Bj|H>;xI z)WY*2V&X*C)6p|45k9&*=REWMu}p~>ctuS|_SrVmf|&)3bLi&0DD)(&*Gr;%C_1ca zq#{XxYcbf#=JwLulS7wo-c^|>m7iJ+vm1`hoT-y=Q=s6NtrE1oh`F)*7uWO5UlYq+ z#WRwSoBuJ6Bw}LsK`j2pP1CMD9@j=p$39-HemdDxLXpUV37`MPqg{%Ni@Bys>0jmN zuXo_?JM0oPRA7SdR!nH%kAj#&}%><^rEc>Cj9Q^RCnVz3tYxwP7^f^O|)5I z8|jEBz(UH=WU6a)H7D!z)Jd>1t4Lvl%r?izQ(JmUJUvsZc$jdku;?)Y+Wv!k<2;+$ zQ{of72-f`pccZFFF*AIdkD|B`*$#P4%{4Jmjstnpt%4VP57pA#jyn@ESGE*gjlVIK z>-Gq{1;c;Ec5_A@9Ey?*AhYhYz5D$=zv2}OjUIkG7BSw$sJb|w+2iqo$I{?8z``;3 z+B|jgsJOEJA~@S+cdgE5)HXECh>2S2%F(6ET8K7kc5p|;MMQ@1Sx+rvgsL}( z%5WEy8iwO>RU`53mOZ62u^!{6Y?I16II2b@LIneMV!{W0-;|GT2wQhpDx#>2!wpNQ zidZXTl}@7+H4ghRqLBo1Fm&?)I-Wfo7eCbsdid3r)+?`gnKbQ{e2mZCi=D<`EKE1A zt?TXKhSV2_<*-UCS+V zV3^*Dv2gmu^=eJ^mYp~6|CpPfQv1Qq0@DPwi*~usX_5Gym{Y-1TRVRSWzK8U?dH{e z)gocTrSLgWulgBzjkJU(;#>l=b8VFYQMjs5bV!>E-*#0O-c`9>=yWN0&QF0DVuBuu zXJaS@dk-n^=MOOAC~s1;j79HK`WNpqJUyKQ7c!*YNZ)lOO0nuqFn8;uWzART~Sdn?Is}6%|P?UHeX-g1sYN* z{%L-;enD$_dHGuLEtuWWxz2MxgTqg2a*B!rl_E*`$)Z(KNlCZhT0&lO`!BxlS({BU}K-4AJ(N`|Ay)i2;1j$&W5-EQD8`B1u`?ft>;Q z4Ype_*^Ix~k8g^|ReP;J;@taV>1JZA5zr8p>Zn6&=J$)`cxMowNDgVUYQ)e^N5#{Q z0?x!j^j#%>I=B zY3yLnJ8v92y(Q$tKxq((^UYsmm}~3LYSl9$ORw0~Oy4P*lz)tpV%M^Y#lM{kqda2M z0u8%m?U#8N-9kxA6((S62+3N$b{KBfuTByKH$JNtUH{yCz(QmDCti(*Cfkkyd|(gR z2ciBKhgG<1W!!qYRjP+kq+WGG#JXv|DNWB#x9;Y^7R+GhFIKe{yEI1e;Sp)YNPLd9 zk;Pr28($ZExS&p#;~cXJzlD>OM=m$!)b?NO^_Yl=CI6E4corM97al`*&|8G5U7g9nG$>P?{^Drj(NMd=Gx7B017`@TwL_z4K$ZA9|}pX zlkWm_E0QpH&ysWqkP7~WuC6Y^m?DXL2Gv+|zg%AKWfT-!Q)_@=)=ea6N(+v#+eP2~s*vXGOQT*w`m@D}$nDvn_Y6W30lzY~D>i zE}*D!r?(o!!;WnWeOjz|vdA^TZ25Ie&RA@2equ%^&vl-m*Ab1v)pfznDFta6JM)T8#N6Cm zLKAowYMW1gqQxyF*~j<$KEdTbmRIKrq?b}!q_s_lhljiG?_p8uZm#B=mTZUEVC#70 z@m{G3Ye_7|)3?7U8KAyjJK1pgob z#{Lkj4qJ5y9g|l-_q0x@KIdyKUzbA<<_3wbAUQ=5~WCfzi9@c`9d#B+1CA7(h@+jgG6Wy6`xhWE%N0hLe54ZS7kjkmsj5E|VG^e2_(`ozecjE7H z{b6Q0Y|??g@IhJxF~$(Nv~1@pcEhHGOgJ4Xt&WQ`PXw1g@6$ik=*4hJ2_1BeuCBG! zIgWw6*;d0zoYP8tB)`^MXSSL+)2@6s8tX)SllTYx873yxMXy$`qi4fsHnT~m&iD28 znGw{jixx+4VbHkSpZ3Cf3SY}-ZsL>jI^ULYO#py=udUaBc<8=CS|}Rw_25fC%&hpx zd2o5@YFNY4iEu25Emumg<~8&Oi(E<0UnSy7!Y@7W;~9q@<{oE~y?XkDhK54HwS{Wk zCX!zvG%_}uFQ3?!b=}#UN=HmXxh@Lu49(SYVQ>0GyiR(Z-!GOrwdg%?EtJugC49+d zrdCunpB93L#(YCv0)V^8nR@9GS!sS>TEUee>9*i$0R6rAg83rWbi+|7#2IlbrSzU@ zBRt`63UB?Kw0&Y46;zp5Cq11CO>)M_;pUo1IGHR4zp$=jm$Ce0CF59_^gK51ltGBT zlqIPQoNyWICE5LzS#WO4%o(alifX6>TxLG?WDUNyH8$~ZT3hO39;2~tKcCWKl!Ke- zDpiT zQ?2XgBX&3ab5IIS|ja2-}FQNq}Yzek)h6O9LZqcE3 zXC~~$1{-n@;GV=iNcSB6VC5245#n0D-=ReKZY z{N5I-`03UUkM~Kh<*v-O_1qO)QU-pSaboG>osZWZEUzvS3W_NW6ZYQ?bF?x-uSLVl zoq^vE^OR%Kp+7Kvn^t3EW6@|!s!mP#B^pYSL6sI3TDqTtxAXO9e_i8W9DHT=ZaalA zq>fe2Aaj{He@5-`TmHh8eb$J@$hN5Ro;1XoS=$w4tA-gJ!1^{=e||_2MciF*f820! zX2^EzPm-Nc?-Zj$tW<)}ohOJms}V^+bOads9KcMYpy-{-41e#6);v>?IG`8^Zs zd;Xuf+xdg#6%{=VveNEor{*tInifOVI4vDx=(1QK;7^OnmPY?{C@e~Zqf)f926z>_ zekb*G2MVY7YCV&;pG_yQ5{KXrt3#%{lQpr+C%q;0?f`ym%D>?3`5FVWz`(%ngw!fm zSe)W2-ihG3JlF`ivygEcUbezjcoULhV~1*pLWIo(;G6p`e|#YAfr#}~f+dTA)H4=` zGc~_nKQAV85qq-`H|{o7i^#1vrzF;>X0vEeLzQGH+}CUw`Bk7^OJP@Zm&BDLCdJ2JgpETaDe2g~J;Set zLWU$ra|{v_63*`B`sB|FX|Znh&wQFOYG%M&BZ$ZZb++c z@YxN0L{B&^vz_)6h-mD2da+6`3p+d7VpcE3S#-XHdb*6)(4;Dd!+S`ngNC2(6yIj> z&h&=CVwlxwK|(U%!U^nLPFE3-wKTHP*VPwA0O8I|74~IM021o~8r6#0ai(^NAXJ^2 z`7D;ks`(g8{(N`oom7cj6GT^XoA(!Pr%KD>(%9jXA(C zRSD-qDhM%H)La@mZ#mp>7)ob^Fah>u&Ad3Tb2X=g^1|(FM#)9w$z>;l3eUtRkWrar z-ujJ}j|=!%@6MfrEsJv#VFV3FVxtax^z2x6dTuTs%Is`!OkV;M9v#O11Ws#zh#)vs z_BN&uEb*=Ya){6_e;*@`}n{^k)BqmCEW@l%|#Kj?4GIQ;+ zk=Lhqh&{q5y`OB~LtY4)l^c96)Dz{y3iz6umsfa~kKkX|#K|kFvrpI~ApE#El*3r1 zf*_{V<-CQ22kqR=XlCYju@{Z7)_$}#-o5t%TzXMdpSx@y7jG>54$o1bqnMlTxpPhJEMT2abt zAP_R_SEH_n1QIlKedSg1uWyBh4W|`9D>6OeyPb4thV6)EGcYt%ZH4`yffCj~OJJ?- z90Mz!-5rY2MIU2RU%kvfA4nPWDmUV6oV{Z48+L^!>`heUjd}5|>R)@1FgK2h%{Kxf zrS2(qmuO>WXPsO5bvkcaQM;(d?s;w7`>PPywo(mZi5%Vo%0z~wHj19y_ZfCpD!ctwv#mjE@L zWSHu+Z(^hK`!=VwmFfHOC$9$|?po@cgG7B|jsG*fCm$JwhRrRFF39c8{khEP&Bw30 zQ~Gkw?qUr7-O|z$0AW=yx7o+@C53<;8|pIZuk#kI?@zUVhJ7J}VxN88UI$EErakgc zhR%;TMdT-$W!(h|3+ubOLX>=jLzmxh8 zA-*qfNn})2gJvs(I9W{~yQA3Mlg#0~(&FF~>aoQLYVt94N5xV7|o~ z=SEoLPY$1R>`ihiVs)C2j#0g#S@X@1i&2l(EWnz<9V>QPAvi%mF1k=n2Qhtl+`7Px zWG|Oiz(7*&RJqv)Qci@Ho03%aa`!&V1ahigizVE`_aE$n>jA7~tr?-HeP2I??qZ3j zJtNj^s&aCTWx+s>m@-KHby>0>Kp^Gn^O5a-^I`8g5~PLGZ39;s5fycE1>2SHsMF)l z>A^qOesQ+TX$?ZJ!CW)M?sZKu6uUT-36jw|LILb>qMDMm7e-|hpCG}%>R z*V;?3p(d*{F}a2CI=y!=kc6a zHjsfZqM)LG;{Dv)3y@1SzZ?Nv_=KS8#DAwclm^mDEl#sQ$-I(X=gX&eAs1GIxy_LzHI#bi zLVyx5Cps%c0@9cJD6OIBm;{_q+@59rDk+I2(SESz<#~gKnCr1r-`D4kRM3Hp)oMW= zVrbPES0J&*c+XYN;;%{Fsgwu%3>8l?7S0CBUE(ch1hkP%9U_N={C$ zah^2i3=N%h|X)500LCZ&0<(c0eD5PHw9JzuTXNQICDEM{FfCfq1 zRUpoAOwN;U>KbMHOb^|fP4a-nBLMd$wW4%1>){_Xa5vHdam49NriQJYOOA8=#1{H$9ZCr^U}3IaxpF0^Y7JH}c*nKAds){* zCT*wti!$x8IX)y>E1~GybgG_Ld~&`>pLhZmrvi zwZRodYn^MCm**4Oy2q^VSd`^$Twynvv&iGTFYoubk=~-@v-tz~yS>R3EIEax&*|G7 z)<9-*gAzMm_UuVqy`vRDDFJOA;nzyaV?jPIvj=bJ2yiH}$9ZU$xL z$tw?@GoWq<5W=3~KR==>hT!QZF!AGk>u6CV|9mDxn2 zBp5UAXc$)z>N|s|DAa6BbLZ>23B6y#h>$%yqiEIhsz2$d!i9UIw_f61o{1K82Qd^7 zAI1`t$6|oqM_Lqkv-v@cH)~S9TlCki11gJ2z_-8-B8hNXTdm+H&^dg$w7gL51ft#j zSTB2%l(LhyD-cRtnUXS(qiy7u4+)%e9BAJ8a>e|V=1HJCy4e!q@TLwuRQjiy0KTd$ z1_lN7K*c0AaAH@bI0{Hm{ZwQQM!2;;4VtM2st#w03jMX!i8T+Xh4Q3s5TrOe4i+pn z;_A+;68!$U&CGK2Z;^_eI77mEwd1~Y(@V<+QFI1bnMw;e8aS@%T#@xrbZ{uhUM$WW zom%SoYps)?qvN*w{f2M%A7jD4MaiNR>9)YGSxe+tPjs4fSP~FH*45vR%G1^P62bKt zW17EkrHn zd-<4F#R${%TFko}0*{`)qMTe|q`wBG=KOn#*5IJOuw%fpp=dcjtXM!vN=mA~1_bJ} zM`lDopndI7yw9VAlsh8L_4%(8Ricm~dtT^xJb@_3|sm);iaLy8uqIl(vD-xTOVmMH{2l9kU9J685wzK zsuQ(>9lAS{SM5IRR@t~gHSF88YL8`fVeK`Rnsq$YjpiFKLp~RO@CxPk(!P88aFRL* zr?)88M@dx}I`vR>DH+Yn8;ZSXn1FIv&&1?VAh#;9#Z`#lLZc6NN4w;crqwoxVJ z(>nR2YMRGx%nB-lhRx@P&2ZFIs%#hf{B@ubWgbJUZO8{*vEq3kl4uDU_Qn!j;4EVL zyLu3K7%LlH{E$N9@IHZgtxleoO&o1Tsa#`e^>s(1lV&`fmgx})vM{doLglAA9kJ3B zd$GKtEn{>72xV7A&^BZn(?Eecdl3^KKg@!ks}IRtQdtemnT@L!cDV8BOT6$EN1Yj! zG{40twrwN#r}ujJDyS^lUHW#X!e?~d)GFOzoSk!QV}sP3FDo`*8cmgMhblwjH$`ziK*MHGLq}4L zTRyyfpa5AF@_`&@VHZAw6yr>=aMam#!f)$(sB3D{p(Bx?XX#(JG-T56ly>xvWI)H~ z5ies_xBXcBIgFv1!15oL+P^$5*`x7wG%UpF1=tNH-Y2ERDHxd3ATf!qt**3LjtvOR zWpTLq_z`wc89gJcLYdH%gu%h)_nU=X-PRwB6crSbT6fagX!-c!O}A77QP7=I!D7gb z>i*mb#<3DQC%$pW4_rXRXd|X^Rt12{bn6&t)ps6PU=~~mQ9+-f;=rlPdTl$W?l4&d zOun_n;z0xmCZh#cVy_T#Aty{GnmRHW7#R53X{21b1zF7EVNyblPp7o?#Yy6vK%*s{ zpYE$;-cPjkjPzVfPwZDS=ARyIJ8#v_yB+s+7&Q#TRqG4kqwP>iITeS8AePoaFoBzC zJBD^*)(9L$V{K@?(LYCck4dOtE#S+a4w(=J)j4Q746rU2ur1SzNS1R*82AV`$zZPYIDYuZhrQ|>E05t<2GuLr zru)fIU|5rvC12SNr!t;!#2Fos=BvF!S{raT^ava#%cqCWN5~r3Lh9~@g59XTpwCY= zYQ)#8a>|QmT{D~i{CL{n`sD~LFs8o z_N%$BptjG3lo#lBY8{ed*~M+>kup_es%5+4?pgAJ^pR+oz?f#v*IG@ey6IrR44qej z$Hjd@`oBAiUGpRs!`z>_J?pZGQlA>U0txQ{TI0l+j^jTmqaDO&gTadP5RT6AUk} zhrA@(MfI)%@+4|sc}<2)r39fwh%wBL(*xC>h|qY_ld<=$c#~jaaxvuzP3zNnVdF7b zu~|9;qQX+?+x%XzaIc*qdOj{@WoOt(FQ<(AiddWvl|b#$_LqvKZ~;tbB|psEZi~BU z1n>l%Y$OyOfcxKevzq3-wlJkOesyt#but)XU(-nf!o>(#M!dY$!uttX(SbvKCeVer zl#pXbMwYtPuspMu;fd5JdmEL36q_!NHasz%m&PG4lEk*t2|2ef?z|C2E zntPjt50TVCMeJw;#PE(f4Z}oyw+p?8(lC3FNw&US6~I03^dJC2t4kA}kb_4mR*tLS zWW={da5QT@hzt!&IJ5}+TcnN{TWYB8YXdZ#R*2VWJU#SRuv=V< zezyN_nDa5&;wif{40qC4+{n57(hGle4~ZYyKE5wP(c8`f7-H=I7~qZNcUoPRK7`45 zO;jy&3OhOCK`!2O-qJ5AE~fzkvs_0`+?9c9ri!blOKE+wq zFD?Hg7m<*l!}}gDP>^cXlZ#F(I>sRCz~@XBfoehx*+Z4`aN}a9n3DWGb+Y@u+tirj zEV&3EG^vssk%CnQNz^vOvC-WSMBLVm1%Lrd;8fEvhB@5JR{eY#h^i{VwPgXC zd9k_K1Cw(3FHPOH{e|q<i5dQ-lZ+;{W=r__1gFLP8YoU}5%6n?x z2$VpF`Ry!nqKzMra_B8&14BfLRnaIbwn;oe^+g>v)lfgyjS|EIYA~DI25m!>b0%A2Y{f#9zKNRpkndRAOp1W&GN3dO z-KC}@Rk&Esp^N=iw$SB|z7po|Wf`&Y)^T6!-x_}KtkdrO{8vZ*h!zGs@Hh#F!)bOa z(fGD6K~Y^gvecB3e+UWp6!xpY_jjYvfie$bV$;rMioZ`rxxCq7y3+q}mG3qUb?ic4 z@>Dq!D?W-jqx}NZ2br0pDHSS<$gp8it02k;m4w;yWXS;Y*^!PMYsy(X%SAYIVMKNR9Td#fO61%+*MN`mj&%>8=AOdlN5;K}0%P z1vu-3$I4U%>wxh%Cum4)qr>PhcnU1)B~#Ilr?;65Eyh_kDMz3Bx+TlYE3^w$;*0-M z^r!h{uj@_!BhRwPPan9T73;Z`e=g zv^A9k9B#Ta?Noa82o5*d+@y1$$}$i26zj zU{q;^dU}n92FY`g?=%uW6z$kceHfH1oDTepdy>~*tnzYl7A^BXz-EovloI+*oKCNG|6J9Y3Tr(ZTbR0VkOns*;KZ3 zXU^ZP3bp~W4~0E8W#|)Bgn>J5FdHY9L4_EYVWyQ56>HfQ0WK@P`A-jq?l^(^cpYhD z+Y@u-x}>ey3|4N9{r1+?>xM?l>WQQY$rp#(jPk7fn>Hu`3jv7kZD7KfY6S=YSgGu~ zQ`zOnhsbu-7q|d1rw{MR)$L-z_DgW5>h4fJe#{(6$^k=0a#H@{uoCEPZR#7Zi}~^dm>gzH`nS5u7O4dyC93O8 zoCDxPv$7NZg&^en+l@oSqDJ}Ej}nW#?H0KYqojiTXvauj@B-HWPHX|v$}@5uk;_;w zOLb^D7!$D8AG>+(&WOz97aARZTMeC)LBg*@WOOgc$jH-XwU-3Ruv9d}(!CxzxFtMC zX+P=_Q=mqFE}PUYv@Tg+!@Nz|^){jd+4nizB3lh>%^&Zj$yU24Xlby@Vu8|94lXe! z#swv^i`9JI>E7Fw#JCj01RU#TVUX!m)dZZi#|DOZ7$)T#^$iUN!7(7g=LF)p84y;* zfYXBs1;D|TpgNu@G41B#@$V?x;rK2KgG8i!)&r-X)i5cL2-#iJto(FznmtVu)Brvv zZ~WD~jkacTfgN1a$SFm`5MZh2b9&LJk>_5OB!G_^;Se&mZo9eyER7UF(HMZt_E@wR zkhN{CfshXsy^O)VynD6rPP!jMvSyyWiE>RT6pd8)qCWWZ?F2bS==6TT*2tVQWc5(i&- zMrpFD1z8{?e9(BvAMbY=7t;W6hKR_5Oh3^Y zBuT7gn)TF4?c)TH$%0#lOc|eN4GU%Go_ZEPiaHTmpFDPoS5sMpqr8Hb@-KzaxNrB< zS^5fwd~EI3X;x3^-9z+2VSUB8@${Jao%5Ej5WLO3Z# zGOLw3GI`j*R#$CZhc+zEWf6(DYZX(pm?nX5i*|eGbyw35k=CYv5+ZJ?>_HAg51!$e z+%NHPIEvSntJex)m$nhV)4p$0KqBkMj|>mg=cnKQZD`aqdaq;f;PRdt6-3q8H z;<;89iEgN!?9pBtN`G}LWeF!TKD-`AVT2MH+N=l`u(TyBb6s>_bv`)bQ(NC;$Bo~# z7feiqr!(G~?WZ6*VyRz7nE}B0l!(2PqsH=B)Up(_YQ>!kwQ7ZYu~?ENVO{x>`yU@A z@oZ|bM6HE}chRNkq#ydkmsxxpCXACdMw3C`K*F^1odE!ZK- zy6TTlf9~wxuEm_KRq&MvQz3((Ei)O5Gp=4f_+g9a7$qw|Q9;snz?R=D4mcTOslj~l@=8!&N^^IFw`GZVaVDvJsbkel_RLt2CFk^T<{ zNTn6=ZOGmVzPpWOQxA`eJ7e0D1G1#W;G#%19iPpY=%hZ-EnI-RN=xGePn`v`oIU83 zT!Y~crWOHpIEman0suEiWseLhc0(h;K#^}*jD60mx=dDAuEyf}GJ%hZiF9x}Hi?Ga z!1p?C!(1?E7sjwG#5*(I!HcRbi@E%V8UQAn!6w(eM|1vi_M6kn&t`xVihQT&#=;_A z)nZXE<}pbVrO5Dua-a#m31w z;JzM#I)>ihQH+2J!k3VNJm9GArojVRF9gE1{`1JjLWW0+H626!n=3pmqpZrJK1QJg z+OlH%LZ@2YC-ieEb++55rk-tMW=%Qen%CyJ6{HzBDA(q-3fzx;Mdnk8h;LY8IbF(gz-{Pvde;vGq(OPMVWMGp|G;TV}@4@Kz;lz?yK z2zVif$qJkxffomv<_GM!%j{Fp*A1Ic+%u9J^rdbjB?hg&PJ%>{n~%zpNqvT6YJWS2 z!yUIsLgnk_J_~UlZ?Pg{p9^hq%$oZs*-mw2j@PTOHRyxlN|P<1T#%MFJB57G8usAG z*dCuF*C{QXI+0=JI^^BynY_=DZ*GsgRd=<#eIi?ow48#XL!9BxnF$(_9W&!IfnOJV z5bKqvH7~q>wrGnUeOvk-KNj#ApgIpI9OIm5gXTB4kVPPB&r_cIOFL-}qJCFse6b$P zhJ8nmKw(Tmf?7BCp+kp;%uxYGL%HV7yus2?pl>fZN&-^>z0vSZrXAUv~P zL-d$kx60dB*jy*0><`%sE8F+J8|DqSo*8dTW_M0B&K?>LQQ3fHH@LRGnU_Ph4T74v zR4}x_B6U0R(KQ-mT1OlaxKtWXq=syhJJI7bB%vuz(R)_v<`uFRmPLmYg#j7*aa-MV zy0?lf{1*Q2B9%cH@9{5K)(r-A&MlF?h4=-If_}+?zwwVgJnctAU`3offTaZUGi`?Uh+;l8ATY-Jh2DyKXcKTx2f@JoA&zAx0ndKx& z+)rf5o2i?x^~Cx#O+jppoL_`dr-aTX| z07e@*$eY3CR@Xt|&tY}50u(@+5Zw;TY3q*f^#nPqa*ua{h#lB&RkjAN_iqCa@1NN} zfDR=|Bzasy!c*jc&x*DI3#>c5Omd_nsFjh1i6~4*YFGmRECM@zUEMffcq1qcna6s$ zy=vli(m9+n=H%dr(pjrsTb9mlG8FT2UqKc3Om)*pCI_h1_ZtwU#(0LQ0{k#~1xog> z8oQg~X`g1dJ{s%@cH@5T)?aE-$zPFLYkH$ACLu{F$?PgF)gU2%u{=a!qt)w%($+Uh zCS~tm!gk>1_andt52g)MNU7H%yU-YLNjA})mY;`T!2QyL9YwnS>P6kGi)g;=(>jm&E z_{Ikt;}ts>kUF3S!p3?VGj0u-M;8Dtx%oJUJ|^ZZN>9KQ#>PK^IIrwKKSg*=tRXoV zmG(qbVME;sDu=Z1Hutf6Z~c;MISflIDcTRwVlj%XtJb@pIzxwbnvm()fVB z2&)cV$>@d+Ug_ZDAKc>EdADWr5ZS&8Hua2|6Q3nfYY1|7BG)MbYRLF00AM56aOZD_ zpFO(9T$XD)-5ue^Qa-{9CBrN5C>d9&Lz`hYU?z7^nj<17z(6McAQWi;&5+l0Aniy! zi0ZT(NwfjfP48dP=Z+LrE(jiNPeY8fGeOdSU7c0~{rvuquDe6(Ln%xNtdDg|Nc}$# zk^zSf1Jz*`lZ%C*@x*uS8{@Q@f6KMt`k^P;KGf6R#O}C8AW?=*bO1WW<@`mvuz9ai zvLj@Rsm?oS8Kn|cLN06sxf|4f$8yZzEKDX*yH|E%vJr=6zw_}$ZCw|*9Z_9PhGSx7 z^@N1Xh3fPZp{ZK$?q`D3GPSs_xp80&KhGg@Clku7=|w{WgKqF;s|}koST6T-7Qkm; zFR{!n0fUAG#h}Xt8Fz3_b+5&SlG)bDOFcY;y0W!mJ{`|VTMFK>9NW>%>N}Is;OP@w z?Q4mNPxLHxbWKhPh#6NulBXEwG38pNnxf)YSy(GNM%~w^8^ZC;x!Iv9o4p{B-mL56 zB56_8#Ez*biN3IrtTFuW!79-MZtW`xK-!P-6oALZ3}R*>fY63m%d@}P^FV{~@gU>1 z3;FIGt*0P@r5|QDXdtUT!lrja4LCgHw@9;ZfBgvOIA&-DjIt<{+cLu}k#irQ4d9LG z=;55vd>NPydTtTd&PkdQm%t+ZAd#F=)z3Xn<4|q16YlUio5-1L?1M!sgL!#v96zTA zzKOb9ek058PBK?K`27tF?UH!!i(qtSf1qJE%I#phj;!Q{HiBeW=@M|D(?LJNM9Lp8 z4_xOumJfX;#VG2pcZ$KjGcea6V_SE1?1xX@3Po11kICM@CupuM5}>VQnH~vz$j}UZ z^Xc3;a1t@cOtgA~pzND&|@ z97Xa@FRaPsGf9v0O2&-qw|5>+^+S`QkmpS>3?jplT`q_j=Ydyw!EbLxnZRb${D$qf zxVT~O@GxCv{YYPA#;h6{A>cG~lGIUu2^=g}s4o!8MP3>=fNQVu37~K%zuQ4oh*-;d zG=88Cijb2CQoi6ezk~Xx5L31;G!irmu2d_>Wb-bqym5fMK?50#qPtsSmC}+Jwk=k- zB3oe8K!#)$=jZA=yM3d8#6*d ze&>*39z^+F4$32pA(@H*R}LdjaRyW$h3(zfGOkj|o5cW51ME z0Ed#3O)#-%MJJJcCxuZuN1KH^*V0E4?ZKKY+jBN77B>#}ZO`6-81Q^dnfpKNEcUE! zW?h|Ir2xA}sN19BD3&ZZ67pvAgmllb462S3Jsat+i1BY10>;^j3G$BrY$qFft@(Op z=@K{A>mL1TNwRm|u0-R&8s2#@yb8RQeAkhiC(Dt?MjA=zeXUn zsq~8@2RZ}bzmP+8Sh<{LLj~Ff#;b|vxvsjaoB(p-+S3*-;oGT4OgA!_-S0noFVAL_ zmrnubEi;r{vy<+l+X6dLMzia1i{!~OXUerBv3U_r1sZq0*7VNz4Lpp5?X+l}Zyx(` zaahjl-RL^ZR@%Fv;~PIYv5{`RpKQ#3H7x1BoYf=Qd+$g+?fxUv7$2Aa+1#SOhj}!A zU(VO>gl@5ma@>2)KDPh)`F%o9zQlefkG}urzs+CT+-iXNx6x7mH?GEydwb(r|8>UI zajLA+z*(14WOSXE`|e@fc-;SRZ)B;Rx*e~W~fCWVP|MPwD`2kRUzoOl7 zxo_4#XY>2Npl?nk^p^^o-+JPO_Q*eej{fJro8t9fy!(G0Ko&4_^!P`T z8^`}=1IT_g6$|sQk-BB(=q+03x=-~R-qgPr6H+%8vOwhDl^{-%A?hfr8oCVL1Q5~)0jiXup`;O1m_U$K1Th}=g_v80n zW#K-<=_c8=MiG$mGUOr$dfZDR#l0Iz8a)iY9L|!)E`Exs+Ebt+w=(@a$WgMFyqzjb zttt(YKis%`W&`Q^SK(F2v--J<5>cKf|1piNKafY+ zWEDqG*>%R<);N$X%eLT@)gk!7vuM784b#}S7dY#ww$kjMYcY$ViGZS2GI*v`mi zUkv7;h&cL*1tc=%mK#-GeZ=m2QD-;BtZ5n7464~GC(ytuJ z9ac#vV$z9oQ%7{>>rH!%vGl|iS^PeqS+yp9@~o(#q9l}uH~m_4OrGFz5IwwGsAZN2 z+7t*6{JiP3Y{3Fv+Sr+KS@L$$>}qKrrK>uM*8FOlW*0dh()i$XJTR(w_e)zV;T75a z^1O#~$RK*@dl=dM{_AX55&!w&A;Y~?8fUH0rOf=X>Giq$C^a=-_MaonUrm;PUkQJ_ zsSmayezFohNtdfyQf#w4{8G(>pT{{l_dPQVJXFhPUljO3%~81iwYDN$uF$@dKW`;J z(dnm$%vS@WD00xE*}a+CG!}*_O-()F#W33RLhsntx-JP9Ghj{}-;_VH0dX|+^#egC&f@|)cZx>Y;k5nH%i5Mt z2Yp?{jX#C%md*&U9ap5t3Jh}Ncp0FvbTz74MkGfNBNVIWDrxuZ9Jn+$&w3h6y5N{E zlZJ_7aAvBX%ZzA8m4;uWL%`jQC;s+#m`c>#yZwEJSI1vz3bR#@E(Uzu`&6>4hhCQ0 zl2m{QH3#t)Y5QaUd{~V@Kshu0#V?%u8||&eeB>QjE|B2Jkq#qN#`KTADfGCS1GiHg zkEhnn(JSK3dksuH^lXx+ci@x+FXZiYmu$2O;Y2Kl#j&5y!VRH-^2&O80@tyK+0fF) zRr=!@pJacNJ0wHQDwUf!5a3#?E`x4`#gR>q+!in;v)|FvbI@E)_(KKcWZ|Sq_nB(F zz}ngSqvbI0i(WR_Wbv+#=M%|v)AQmxF-o7I>ldJbtNK;3(!ljbt&K7VT>{{>B@Od*C=H ze;+OQkw4h@!O`fS(E<9&zq~B;er!DM@4tKHWK38$6pZix{7kh^=CE$;dn|%#~$a! z@c-2s{oAdHIOq7gj2@k{-TREcN!g9}_J4XMeAnjQ3Sfw$x75GUYv11pd?SSY+ll-4 zzyEhP;J?|mH)UQ3%vWn*8g}Np|NVL!-{fc-wVH-0{;KQQF6U!&;7&etbgm= zE*`o272j^-C8xQF^mY(#&YO1TN(F+LI&gJqc8tk#9Gnc~XizSwSQN<)3sa{~pMJuk zo?QrrdAg#aq96;$qjSHAF*lf*6qBB=cY_!-#z_S_!4P6oHp~d}VW5eAs8Km-Zl&0r zsw4x1H8gSd6%1PmYS{UVS9{l{+Zp30=6%xm-n_i-Z!|n?s6B)fhBtVP2*T7Uu#Jco zf@-Y>MnQJ4NBw+}n8pj>cv5M5hsGme!30<`+!)VfiPhKFUvIyJezWio@< z9(&X?k&1(entWC82J%A-FvnkMVjc}^0%zBuVlcAIHG>c46_}*J#`x4`bzz_fw4~D9 z0;86cOwhE}d*tCV6^BwWS(~a*3`Vb}eAWJj{Sth*&R-u#&=Z^D}UVu!j-hA6m%cK@E$T`CBiRZ3^Z;rC;U{w!l4g6 z#l++WlHWPm32=O~lVRDxz>oTVFIe8Xg$BB0UY>6ZoLkp=!RVJ#?0w^GIQAEz`t8Xe zG)+)5pHFrI1~jv$1fk|wog&vm0gGq~185mbOas4xwI1|goFu<-Ynlyc=mxLp!teYd z#%p;y!gL{JA}GL0^f}z1MJ6mFyLMzhG9D@u!7rvtzk*@kJWLXU2{KxqpHwD-!X-za zvj7|Nt_FNI9UnC1sm9uZ;lWg41@!8MYl1?RXW~8+H{IJ(dT7`#%{8fw5J3v{5CL^- z!&sR@pVPA1Eh)M(H*b>h+s{6QOX8)`Bg*@oRVLHKF6c&$gSg9P(w=#9g@G^x9WRn$ z0{rS|hX>7a%;GXRtDI;_ZzLNs0>F)nS#jhx+vwvL-F zj54NLaE*_gS;Z%|qP)Btjq^h`u@UH5Pf$uezCLyhjYWj{>Q#2tFq!(gLI;d7KSOMU zX;le!(6K2WxUT`+%$uIs$O}%((Rf@bzS?lc8_IT+%vyaNohs-z48hhD#B5QlL}r)uUOw|tvenVBo& z2_s3M1!c&cIfTZGqbaShW>l~!*FCLAo6o~7C4Wp~r9=&*N4dp>)bbr{vpvCj zTLj%M=zht-gp@?p3=c4Slz?065So;jogwvn`F97@z6jG1o=OX@u%XT&S}@f(&OjRx znqh)^+Brt}e+KK$YC~|s6!;+*eH>w^*D*G{z4>T3WJsZvJPsKvx$9d3m!S=x44fbOfNPk9F z_M4dOtOiI6r1}26)(6SSqf!OQpmkK=w@G7Z*@K2xfyZ!LX~=tO4YwT(AhyEz8v<2( zS`vf9FbtuSdMwxYh~dRB^5@!pz%}9FmSv(be2)c}Z$YTdy*a>W$oN@h1bA8kG_uhk z#W@nI)fD}Ur3(U~SfL}`xb5Qz%r_9uFzL8J1k37+s;#k48RF4o!T>us7`W%yKLbx- z+|Eec53hm~fh-`A0}NuWFq;s4ra{x72YjqA9~uzYXNx+Ff1_m6do`dA*6vAo%kuhT zy^p(~VUY&%eEZqi-@l*huiu2fWRj-a?lD8#5X|5MlfaY8sl~{z$v=fLZSg34Ds(`* z{Kpaad>~{}l8Lp1z}iQeMAAUF^-W9)SaDAaKo@jEG90dRV2mv&LiPsmM0!H9o~>u(Z>nEaV$5^KmphvRYut z$bHm~Ob87smF$QRahur}%WxD;AVnD~Q3dY`V{%xqUmnoCCc&X$776UA!qND>;~^!1@*{~yBf~kZ1blDK%7HD zv3M!4_U8?pyrm2gs;U8X#qzkE+11eci~$o5Du2Kt3JcH_(DaMgA|B2icp9C>5n)g^c#pK(q%XX?Hl`NFtJ^l=jk+wpo;<)Kk)+si#F+O7*+$ z4joRuzt`*c$FDz*hf`0_=ks~L-}ilA_jO(OgiJNden^IdiD9CGqmbG2*lD6Rd2wOM z>ADQQXBRK6&1Wx)4p7s)J+2qxAuDSA#^4=3l+qOMYj`mG+csya{Q!Fj-IeqWX zO$h+6rStTZ|K`j7!ej{?v?B_(5za45$*7FfU_-VlP_BjW^Wi9-0eg?ru!Z)2%sorv zlx%NW=N9m(3sn~(2=6NiQ{aJ1bLNnNEV+h#Ohe{mx%GIu+Jc%z9>SfatX0QG4(bM&SS+3p%Ij9SSB9Hocs!w z|ME`efADI~L+<>d2x^uh>-1ort4xS+j8RBQvt48UkD|cMkBV#T}p; z55(siaJ=IO8=9a4UYl0;pF8fdj4d_}sie1}>s+DeG7;Jgy8VZ-IZ_KnO`cOu0>xcE zw+Kk&JU$*B_rYr2ZwI?>g_Mx9XV2?Oze z1&-(3h)jW!*xfG`#P8$9*ppGG#rFzpyS+aH^S)zpbHFm|*$n1aHE9J%GSYv}1Oh5} z`Sb|O&n^Z+v0eS;gQ zWbljjSa;~=!E^hs>_obBS1yd>i*?7xX`qU01%O=3Rf%IFpD$*vkjXrZfxC<(IvZw; zbBPA&)cMDH_1U0Yo+Oq1CDMU6ejyFLvEaxsFK`E?D9zLfaV1lz?-@|6SzQj5#s__c zpG_8Np6bFaP9uYNSaMDHUh=0*C7yL( zkHp=ty&x4d7{JA~3lCZQ(b=({uS31Aj#1aZ5&Aj3&5m~fX-PInwQijQDMu7hiC$dz z?L9k5+IAwJDM_tI{=paH?CktTlw2!5!;;G+;Wpe7)*M7taq0n+&N$WP5kagdVOc{B zY#<}uU5t^Nq9|OD7$^3uNS&so+O6Q6S zC8O@jB-26Z5W(pDvc-yf!)wX7Ps&LvN^}r~%-?j5-Uo3%@8}c{UabX~4jUcYS=wXo zpvZZ3VurJ0O1;ha&?&aF*evq8r^+nsSwD*9)wb9_yu5072qQ`>DU@Sh2sc$2Ad5ol<=1OvFFVzQOVJscFp`&$J=Q?Wrkt*!B(hI-rgT=@yc?V zOo{Cjw$4A>miR}1T`-C@x@wn%#+RS+udCu?4$JC?DTJ$~D$2;n1f~x0%E8p0Nq)PR z6OG>Tkvr8#Pyn%u*Ug{p{+Bj!sQ+P@M@|SJLi#4-vQ1b%_aZGyX03G}qliFhK@N{P zE-M$Szz^pDnV&Uww;=$$wBb$fkR#Ua{F{ne{~nU=!hq!niGwrDPml`^b^)`DOf6FG5m zeQt)}TveNPa7v8V39|SDNzttEJHq&oJ4|8QjSJ=H1#H-47Shvln;M#kdM8CHHR<#{ z1)RU(>0c;ai+dbHg5cS(`J(v|stt)I6~g_Ma9^KETZPW%8InZzziX{I zv_n*>7%+lQe8!I`8=6T^dYxLr^KgfrlmhvJUyt+znfj7a=K76T;amR~7kv0UiS z<~M2NxpCxh>sLCe)} zIMi=~kx6pLn;A1Gb5(-gj~NwTPgqu?m3slSn6N3_@zPIJL<1#xRAuXjGPh4bwX3fc zxf~lb6lBLF|*pm?D9y9TIl$Q^$uIW?XR0`HQj5A`NDMjeQrGX4Wgj33VIyF6hwN_^Jt3w)yba?sxtb=<$FUK>Mo`oZP8k872o`$o#IFZ zkyI0s5+zYk(WUZzQA;sr%(#9vl+^>v(RdbR?uxzhutRsC6lV8Kbn{+XGDafm-Mf_A zHrgqt8r>#5R6}Xqdp`2<W2ioc3?1yMWDoR^NS=mjFja7aho&D;Xd)Cq<-U=s@KhFsXjp?SF|Wi1t|s<3 zd4|4w-}@phu^-vnWlFRQ`#T}me5?}3j-&TVxKytLHZNbNWoBOm85@~1hnobQ9*ng- z#+V!THD_vgOk8U+X^#?5+XEm|wK#bas0D!mGaTK^U*C*WmXmXf&P0$r?(1D%q{L-d zRqH-RD+4g*Ie;bsNDnIf%^6luzubQ80{ zcYy2oEC7=?wG0|7PF${q2gfm~6A|9xAPUKCKX-uZAYb|%U zJ(-yi#*-`?85+7uo<5Lua3-S zBa9mM+mT!dWkb@VFHQS~!+z}*#@g`OLsh*>N~_)gsYxr?Lg}O}4<>Bb4S@=kMEo*UiR{S&BHaw1foM zLjYpukP-Wa!w;2Tg-sbgE3Y0x4&{cYDcE#A8lcVE`KuBa6T(Jlo~kwgP1{7vbQJCd zKwS1~sHt&Ne6>XrL2FAJ6x5dx=S2*g9R?a4czj5cq*F|CF6dj)wN%H0+y*(OKMHl#@QOu;H8&l%kce#M1-KM1{xx-YTbdl_Ys=UrR1?|6Ou=flK^i`j+*syCiY4?JT@lGV5& z9Jbo)>_)3ym^}X|jw({IXOj$sY{!_~AwFhO%fR{NV)DV+*}H@x)i-Nn#yvK)S4U<4ePDE2HOVvGb1(g zVc$}U+a)9%6aCZuMT&8ptTxG)PE}|%IWE{Ob6eSovy$VB+guj}qN=-3A_SLN6;{FY zgs&504zByfi_x0u^?H$az;LZb%BEJ-M;)>CUa&W4^1WTo=psZhUHXsxS<#sTy&VFm zdv49aInn**wu83eN@=^T=n1fn4d+viM({{`nRf8;b*5$h>+`7H1S0$(bWI$6&$_GG zGef+w8uEw>>dU)1L}4QS8IT0^4Fk;^UNzne!HiVC{(+P!-Kl7V2|YMHeP&{ps9c_x zfHN83k_C8Vx8GgD#2W40%qI(f^f$1JrWq-Kg!nAluRcOz5+Y~b6leNR7+oZOH(-(7 zr`zd_&?#GcFY~-ZhWbWm3gA+M^tPBboxmYSPb|SUASGK1HY6W0%!@@`&gqxB;;sR% zvg-Yoxg4Q9z}6Aj*3DN)TwIZ2;Y>@+ZibQ~vlY;~wS(KrUCd|0i-j?`a)O(h$WDVD z-J^~7q@HexVd130D~MefB$9mGRgjSY zYhjb4%({2)mAP+_0(8un5Cb;_N<{^&?c0|x>qa}Eq%_CYP(ruz!nwIMisac>}Jp;+J{I*;R+x`548Z(C{`Mz2X`Uvm=Y`7Xuf zcQj(s+NFXIw&s)gj7K18ecu2NpoGDk3!9^DT9u0#48?xYr(}h4;B)F&hRpSECZ89& z_nX2xCTqgOqt{z76SHyUR52LT3^{55{PoEAtcQk1qxVy{x$$lip>((cH;^|J(NIyj zWiR)hq*K6+T&M&h!+x0BNe@=v>L^4@hFY~X)TI=&>s8S2r(&Ht0GNf~47i97=KWl# zWL=#m&I-PL_W?!}JwsaTZeM+Px_>|m~ z{(X_j*&opu9df!Vz%&SshRC*UwtEjV@sMYr@X!*z!IR9ZkAtewCep2w3RaFIa&~=P z2da`3Xo~KuaOBC&Qf(F*UHyt=QT)REx(qMF(W@H1G5#yQ!p-6n-K^WFvdTWl(?aZ3 zAxuOxWA*yXZ|$=o=nK}YI-oYU5M2N=;Vj&Jpd&7tj1}!7(W+4j8Zb*7&P*kaRW!uk z53l?Z-}?1Mx@fTgS{;#ncc)W7XX%yAOUNRlBVA_S&9!?kh%TSf;ti+9`ZB7Tdl%c# zT6MCpEYlC^K}?!WkJ}a?dLw(U2JCYR0MBwXEHHb%`170F1Dh&x$!@PRq4ih`@Zo!f z-5b!T(ftfd=i@8_mype>Hui@`3}-W;6h2QDCtJb3aFt!r1x>-=6^~;CpK=a2CS~}U z=?nEcS;dN8nXO*|-GwQM-*yCwf@#&jn!a_bR_%$QtefW1@YPTPLtehrT9UF0zMHAQ z;~pjr0|%ZKd5uoa?-{>D#ZS$1iF3tB&?BwURK^EA)rbLFXx$hAi47>nuw z0DRe;Vw-s9h{N@k@K$?_KRcFX3R43@GR7g204kR1?jV?c{{2qiSvpxXdPQpN-YE5C zz6J1$*zen6j%4;(aE@Y?J7N3Ey?llp8Lq%z| zi-l1!w|f}!EBNtTY+n`HB*@Amj>=C&AH92*oSoxY*c(dv;7&9uM=L7=yHu~Y!1k$u z^>pdsp5n5}KoDn(*`7%Q@Z5+H@pj0O9+~@Gfe+Ytc^q!N_}DxGZ5W|&1?{?HEIL06 zprU>5exN0l&y5jLo3AI|Ac)>ymNgrr-TR-<$Y69TW(?Rk6E0{mk@5Pl(YNS-6*Z;% zm$OMywpN626hQCwD z%iI`|;o;8zZsLVM(Su(5w*W|&m64f>{({Doxq@r$0l)7?b(3jU*^*SH+2R%T8ba6-3Sw+dF(E)G|+A-$LCVjMt%Mw%>?1wy5d;g&UPzGqi9HkWXS-MyK0;z zFSy^)6$rPXAPAqjNlDS7;Cm5x^>&WDlk5VtEhN+5q6mEW6MKD@cabpo z*rU%balT4!4E8=+6e{a!ja2Vqn_ZhtacdAn@v5CDar-#7$u+5=$^VV2ZtU&jSyEC` zJYx=$DS&6m-qI4d&IcwWbgU^E!09qHngvVw_}KGgo2RwK6i3)+Vz98PtE(vmHoBzV z_3tA2i8tgpu8nMOmHG+6CvQ|F$L9^CJz#a)J@hBoHvN7g+0kGSgqP^pl z`MO}{EJ%@*W3fCEGc#K>OI>MNaZ@_sKNyS`mqj-j)T8G`$SYjYtrKp=(;}>anY{O5 z5vUaNv*(%bC%IH|J7vL9R+c2~o3vi;*h!0yF!PlgM;_6L+2~wlYTLbuz~? znM))MVsX5<4A0!49lmbVh$QYv7VzN0KUMa5tdF`UiB(*4_xi!`4H?K!2w7W5uHrgI z)USl%It$3Zw`LNEH;q%nj{cC`4~Pf<~vqbcPF z774bn6P66Y3pC+2s^}}7Qe=dD5rfI@WZ5LYKRa=VwSbO;vh^z((?MRYH*VZ8`s71} z81=aciwV4U&n#*Dip5%lv`(70`Cef<@tPtql+avZcXaRf(yv|FDkrvU0b&DDMuxv? z`NrT*w_eiqvsyuHw4pYkS|U%A4{<_Jo6I178tzR1(|YfysL2>PG&Ir^ftL&~cA_$8 zZb3_95Bq6f*us!jUs-rR3wz~C%@vXL>0Q7PiM0`NLKDrhnxRk*E8NB=1!^5BRUoa$m8f#-1@J&Xp(IQ&G>USui&t{V9@b;O2wZPK5P)YLN3MjyFtZz$*9TS{g{HvmKJT3>YP zr$tDe){$Rdf6p2HAH-N5jzac9I`;m*k)(gm>HQx>@~~AG+ZF!5b7Hyby39u0xMalz zV;j#6HjkCClTj)8aoqm&ZCAB^T1CVE{{KXu|2>mAd{vT1#2{fDRBuo(5>3IQc=iP# z8t5TSBxe>Pk?Dc;q>(g&7Cqc|08cdZwi%lZzlakO3(Gx{F&^ofXuSj}sH)za=9w1RNNIeNy=NY2c!d<(plA`{7rysA}Zwl=_fWzK}Ds37b zHAIJtP6tKy45G)I-6*T8|C({|BJG?0|7gkZCXQPtKI8_NQx20Eva9h?H^r&65#wu7 zABxaWl#{QvH0%sH=xb(X{NtAn|LK^w?HuRBUJAQFDmBQw?C>LrEqD za75F?{zhuIg0>1CkmA2PogwU&) z4QDgD$#z7t+&+~F6Qem(L_1{2ONdO2joK53FYo&c$M7}MTsy61mZluzb+fz2B_aCS zf7CrHfQj6J24~n~o1wM^g?&u6SO$WevUW)`&OjJCev|IqgQQO6Gm5=u80yP~syW{$ z3062{3`*|NCuu$ztXvhfMgRM&L4!KT5y-YDNbWNrZ_)WD5>NLiQc-|6b2;+l{4?U8 z(DCnbUMnge=*$8d1`Y46B}C15im>6;IRKN$1>|auR^xr8zLCEoG=_U8gGtpo4PA{y zxuY=)L@x^ML_mI11KfPyD;ufDNmpR8OCvGKEjt#_`|-*(>(;qUT>oGZ%_8vW9<~Y` z{v|Rc^xlYb{4rAiLJiTts9{IRx7|J9womB+U|X5?c5+i{^(JHZXNO;~w#)8#+eLW; z9Du9mR`p00DH94gM1vsgmt41#qTl@+x&Qf5$21u~F%DLWi*@`gM(4J3lCdD>G56kmv`CUH2nB@z8SP&<{E>!Vd=35`e-)acT15tv z#y*i74Z4225Qa5Ini(*zdR$uKN)5xIY7UUgAHKpCm&lu*%$|vFp^@|h8Xo?ojH^QJ zD;Q1KAp0+9MG-q5q|As{ zd&iLTC|k{N6hH|O+3SC9a(KMvFK;;Xmv+_4F^9Qk&C)GK^&9g)zoP}^k7_6QO-j1y zBj2PpK5fFo-YlVvX>fTr4_<(63FEe<7G?j=7yR)<5@I>tFcPH&O8(u!3$Sk};fqDe zBi!Tc7rFX&Jy*7X1A%>MW&492E0R$PulL)gG)4*-G>w8n9(T-XT$!IAGrXEq8W%)*2iWe^c>iT!`VB@H{#t7c53>ChDP}EXyJ^ zu}@s!zi-KK%u~^1Kb(i23`)3pbkYO7PmnX03sWG6cPW(Ilql0|xoB8H| z+-Dy@mtVU3%4MQqlDC%B1+C=-FrMlu<|g9X(Bw^2g-p4S5ubs zivlR)NfWZ|`9w}%Q5_>hlSW<&;SXb(RIbpDfcJ71__py`(HITWg8@=AVE1@Ds6-zb z7x>Mqh&05}hOrWNC9Ow>3B6F-7GNu=b`o1<^tef-wVgz*+FXB2JB~)TB_9}`u=MZ! zU9_r{`<2G2B#vyn2x%KL(W4{8ktBIa{!H^IF_568~34+r;Goc7dT20Np*^8 zF)!bDQ^BSJ>?0AKJTLdKi68(XaVyrGHR9L!-=Q-YWOjby(1sCGg={05=>@QftYIT# z2?M8YL6pUB)?OSAdjoVMN#&rbO(ttJ9_o4t4QM0oc|JG%+p1O!oBs~~{-U(@8`>?R z=t?2rtvQMI?QYmD2cvwmptO+01ABsa2J_NL69&7xLN*n{HE2S?;JHXIfB0>NUvTCP z25qbR6X@16^vVAKu%@eHgoKlL4-Wv@3@Lg@*m4u}qa!BMZ+4>L@eIppA(i$hNmCzh znWcVWTLN?hOz{!oUB<)uwK1daO(&ik(Vd47co%8RssfC`DO5fctrH8d)s0V75EB=Z zrQ~Y*M6>$ zfr{syc972!k~$g*L82Ffgx$lppn6c&Hj*rG zGfum}yLaz0`~E8`VF+scQ!_bzk3vn1$ekdykh^f>(g{Ou!c;Wju+{$X*y+2Z<>2k4 zJi&K6fhSR+U{N9XULxQb0ThY23Vg zH}o(mmTj6X7Q{lJ?4Pw$h5Td*d-t@) zgZ&Az`u=hK{q_WLEa4hU$R}yZ0}#gb#sjL1W5Iy1NR@Z^vW8!D`Z6N9Hz0e9w8jz? zi2Z2@^!M+PIH#ac!fEsh{LDGz-%M7Bsx?SOBjf#s-)8s)D|Orq$4;=Rve5#_D0mp2 zI7D&;Psg;dGbcA!`)hk$lU)q%ws*pc;h*~PLPAV$1DgscrDq5R{tRNCg7v}a7ZXiZ zDOp%F7B08$Zq*1h{LNxr=2Rd7Nd74WW{`8k--uEF)X9lc=g@zj&f$0uDXz`TveAG@ zwk;RvL}op1(rlTPAQG;>LT2`$GApkv8M&`Y)o1P+ZH~hoaJ(q9uM)Hf?T(KHp1pt+ zUD4uD`pQlECFl>(7x){1+-~r?wE+M-lRz}W^2bFE|LJJ^8iga2r(DP-3azV%wgv*- zbo<0R=KuZF3`XdDr^JNy!#_OyB2rnl_8L|EI4-=xQZ1U4Li&gQ=kSZ#K!jdlbo<_n zxUj3@Ir3v=7YwhOC0Ka?`+aF>TW~87j8n)>%bK9d2_&%%QRQkdl5=kfs)X?01~pmD zv4=%Lq$0wA4vSi+lB4bsap)g14F(g9Jv{OPjU+ytuk>&Kv0O_>CV-BdQympY<1<`S zj8TaVSaI_=QT*y9R?44wZ;8Q=4R)CvTMg3y4O%cqWc4z#Il+vgHJm78P-wb>DPtS> zr=lUc=S|f#oAhoIfRXI>y4;dJO$e<7^$!f+&L1!E2oI0CM{Thoyb~E>SclKDsfHCY zkt`PY2B_OoB}+yeRt&v89D#oVi@%7Q!-rE=A;gX{6po2=U)~?Nyo(-;b1y1FN-!HU zFnrZhQ6~_=gUH6d>LUxn!{)LxIbYJSO~{~B+H62ZaK)S)T^5S9A^j|@hi1;)31u)* z($tbn=KG3RULkP`!7x#kBOguswsR97lG2a{lr`CzqIdu^cW+Pe`Bx(z0rz=@nRllQ z43)e8Lzch#($+ZCJkM~0_$_c}SqURA!qN7IGMfr2Yop#DH~Zf|{aa@;Km6w0eZB** zwS-*)l;XJOyGD zC)oJCmRc=;8z~^k2#Hsz4W3uoU6&D?$!sZ#A%&Txt;cwqN6SdpS_~l%G~IhhIDso( zBe2q4>KSB50SO74{1BctlVHosF#2zNM76fx-pmROtcIg^)TtiDYTAJ) zys3uE2F^5#cEEiuZSXd=rvCt|1AL9-ZSgm!;7*A{v!iNi0EA+mN6hL!+D< ztN(Wn3_w$-nBYE_s0!e|ktqoY30cgM5RIPRr6tnPfGvtZY>SRfghJSD=;Zv(H;1ou z@6F%#mobCo{P_6{g!h->vv@U8BVbl^CiC$KM3TYa_M(g$z^alcoRPT?^`U@$9==aX z%E0J1Fzrx{K&&!F;-3P$mTR%Vb+_CSFjNX&zjiHV!dw5o*x^BOL1R~teeMcg53mo! z##A-KV9<)uw2jMx`o@EF5Q)qYXAwj1Idb+0lJp>r(skhkI7?Lc1Daomxiu6VBGKjb zPFwI}Z4Lh^A@-agADSHl#Ml!=v*MwV-C(alld5R#OY4~^FOwgt9m3OtDT2SG&4`B( zzVh!s_6BjxYZU#IG$C~1052!c6jF%%7$3v0y+$eWA{o`AvY9~>pPIp^hy{y;1I({T zB9PIQawcZIuQs%;e)@N#ww(z` z*bl&^4~&d7I@i>NL1Z+Rg!^{~&Yjhlx4r?n=>ddfH3Y$R_~2-?JKj^g_`{6s+brCT3CcPqLS9 z&AQ^wVB{!SYi3`WKjaR8!KI7omZ`Ojv?B7qgMgx}u0D^1EWK1Q5UjF7+HYP^P*CiN z508nojeIO5{~bmF2M{3_^rsY)5w&S(q{1VI$x9xSqf90H;ixE7;*BHsyuZk{uwasme}xi@~q&kJs|3SR`Tq6J-Sp$oZf8BAE$FKEjgw0{Y1Imwoh5nM!; zvgOhx!EXhm$|gP;YD$~*Hb`ql=U;K3MuIWKz%(FUswQLvaByy#2B4<m0|!l#c<dhvtj4i9_#Y19Wp#k{mBod_1x4Wpd5=Jwz>-K`z3 zcYFpk_Ke1=L00GBlt=|0X*47Hf3ugl;{x;+(cYiE#q5~VXW(q`qnJ017yv1L!WIUT zz^_3E=+>47uni7g(2XMEjfc9~UZ0a$bGVrMDw`ls91=v)bTFtZ-K=<#d0-CpG@4Sj z&UX9oUH|^#u>9SDe@w_8jX7L_mbK~-`wL1IW8rTG8gSS#aQYNZUJqYV@HpT&qEC1pSlwSC+$~0TN+c~I|2@7JP1XW?qal$uU5I8i1>S-PPR?Zo0^Kk#?P9K z9ve@-@KI5_&zBNtTW9>*da2d7?Kfz{qUiinlkz^6Q8NrBQ>hHA0#btFdcEaw+Auirn!Mtdl^PAz@O z`GX5*?Fe?=$F8-GS1Y|>%ih^ad~H!qZwj-y^EjLfx2a@XRxZENmS&j1Q`VC?_t$kF zx`uNJgZwm9J2CS~zHvzBgSrw8)NtE=kVbWiC(c?Q*-o8UiGf=W&YUj)`t{?P_qp9i zW#8s-tMX6Cx7L2t&{&hRcks%L)aF0L%Tr7{Op5PFGJj2XL-*1z0xfPxJ`~gtZDRuQ zSA=ap81XvA2y3DL{EjExZCe&*ACT8{x%NdoqPS_<9W{r(Z&4kw4)0=~NhekC_9j+E zD-BO`A6kr^syvEU#V+n~vIE0y+%(5TC~zZF5QW_R-Vxc7gbRup_S?2~d04Bes=AlZ zPjN?ojkx)2`Hb6F7Qe}^`&PEMRLN{!m6b=2M%#`61aV=a(Ik+kJUp_e=HUGx4Qw?&d$)J*Y9{wEXYST{ZtFco{sr5%i_{*2eCvZ;H8s z5&ipnMY~#T-u$NxReNd39MzF~{56(%edVoWv(Gxo9{fsc4|ZoJJ6ZmEo4tAexz^%S zFP?_f#2=q1*krfAcj4yGo+&23M$zXpb`>xxV774*0ux16Ii9z1dpTzmy9t_fXdd2xqGS9p`szZZd5j^m=YjXUgq^JcKCdOjtD?YX#+>V}%z7thz~ z;=gufCNqrZ3dQ{TbN-S+%_}9+<-QX|jH1_j_MF1~+RW`%`(=_(&wh=hUoUFYchh6R zN0|+_^YF0@uiA9K?}%;8|8tasaQ&g* zn|GzkC7`yCqA!{#fVg=gSdj*>JW;r%f`puY@DeUt$sHBt=3q zJYN27+i}Ei#OK1rg^t~)bhkRJJE!_XK}1b_a+r(X?Z1wu5AL!`8{GBuUQ$lLp}w=5 zt@8sm8zo5=1cWVG%NQVk)2-n=M{0UR1l&(qPdp&8O`m6c4v%3Q*S#f%mWrohJ-9@8 zF3q=ibU%6tk9{#e{4*)m{=%IX@lqYk^64(( z&!PftgjsRU^xj=hHcVQ#|BHT?RE(0z#sr-R>0Lo$R<7j^4~O(2dx-Zn^Fyz>?qe@>&DOvmqKpAdw|})A=<_)k0*z`gShKqcr10&Y zFHx}+e&eh?2k+c@p>^Y9ZcwH0TzUSTO|QDQ3g2^1_j!TPM3&3*!(9O<#jcN>p!#LwpYCNHu`PG*UfaB zSlK>`ni@SvudT@E37ctuJnrkfBcdH9ht3QR(Et9=*JPjUngV2q0gyG14$nMUBS|S* z@p-M57MR);1Imy7_U+qf>#YpVCvPQO&rPlGv`#$nVLMdvdqhq*Up+D#;GnylygcM3 z8C2lMg2}inc!PzOELzLIi3L*0 z8}ymU2cm9cy)#p5pnaChT%m)2usFgisvHJ20Gf;Yi(&GhekKeIicL2)JGIvgXbgT( zu6voiabcT#?6gbHaRSe^=`-J1pl6toEjU{D+>!2-T661}CIR!Kvs!<1IgUC8fB>vv zoj3Wx05~gwFxX;m7J;u%q`K?rma?zi>(n2}&=LW;wYf^jkhvB!?zB*S3%}21mvLzB zEEU{-E4wCEa}4rqKZ7SR``^BOYYj@uDg504t=`T;4>V*yUgm%G8V_E>_~MNh8z$L7 z*CdQ?*4fF4!mn$eXSE7(zrtfUn<0}Ceap4`Wu)6UyZyM3MG8-1q8*m@d|D&F^GMud z+5B5K{>NqLp{!29~?ce=1_(lyEH@^BT#IY&VwIPNbkPw41k$g7RI7Nfhc zW8>M&9T=U?jJ5NwVm}1td0CvUj@6lH55R*u>WM||XB9JxiH?KyeF)~B?L z2WMsxklhe8_j4Oh8Gwl1TQxWk;^Q-Ka3i!$g^;vHLyGSIs`u!9u;sb#@h)DxZK>pC z8PwL1vx4wT)f!$+`VihT>MQy%24FTAfC!P;g4W4nhj;t!(qPrZH6tmStk{;Aad{Y4}ktJ?@D#~R)>9jK(UIWo$;=j=R%;yui! zyOo1|fot|)t!6ZU9cyR{iqMO5?AIAp16~D8V{ed?)+tQ9i-33_u{9U%Jps$saz{Hr zN_U{m7X-RDwppm#4kcp>>Q@C76}^_++}!wHOdl;#csl?fDspPL83-Y-!2a2@tDLK{ z5V$6Ngo{?#Nd^-`ala)Q3wqPAOn(*$g5Q76yF6#qJ;z|(s*|S|8(_=Oa@KcJ#&FjI z)-WTB;LIdzq-b~mMdW;1bA8USQ|Q|@Vj!;U<{mH{M{U3J@^u90J&GKee9yA8n}{1k zl*$dkq+amtsKW!|T;=Vm?N^e%<8BU7^GUn$Fau^uv1MIwT>3*sP;006j!fCXm&fuw zzwGmVD&yV1p^x^&qRs!T81siuxMXiF@#an2t{2#Ru=BG1+tP}mZSw1XUhhZKN###v zY)d68%)3qb)}Cuve#KKh?sSWqLoyx?9`>Yg6Rh5%fL#+Etf(JtZ_DW9Z60-x(7X6o z!nqvje79@&L%(y#J=<&B0RoWi->qG{HsNdnj=V^jZuI=d+{l~d4K9*_tL5f-*1%0i zJ@y5+MA6t^7kp_rHrpQ$FOZFeSRWD!mDtMZ3 zqWy&3pRpb}H(JwzM4S=9>QNHPYW4M|U8IADY7(Tw8k# z%@Xanw?+acx~PoX&ukqq>u#+uR1g;sNG!Y0*u;6nqBCI584H}~20$m+`tnwW0yAZA z&VBKs0E`c<<01z`;tR#TOlSp<)-8Wl%C=29;ne`wz8&Z9VS!X#YMKryVLzr^bB=cm zUw}lIUm1I6MZQ3MZTzh%!O_I#&VFdynd(ZI+XtKc&>;)~!$=WluOH zdtpO_zT(QU<(WEp+;+grI23`rDsJ2MXQnM!>Nzc^*%d9ZWZ3FJ=u}`s%>6+%Klx3OU18+%+S^QGbcUCQaUn;wZqyv1wAt{hpzx0i_s#S z@*o|UrlZ;R?b}^gZN6M?zP@%3?bu|*am^7&h5cWAI6?lmy$A`B8$#0GjOjj&R*wPN zfXtSzmUpxhqdS)MVcD%x=tuZqt0U6(#}rB-Rt;j0CbmkW>)zk+1h}!B2K*${Kvxga zHa9ctKr2Kw>}s9*dXM!0AbH-s%FTU)jP+X`5eN^@gPp>G13sH&5kwx~jt66p9MFtO zD(G|RRc7%cVUF3$+37XucAD6B(mXl#d#3X}$T#k*`IPK_PqZihY2Mq9r!TB#&RK9o z>o;p)vX5u#k@^R>ROi&GF2%`X7JrGiJyK_7)Tr^chW&s4?f+f9p?4sEQ|) zS|WRBf+<(TDI*=7U`Q!tR+k22zd&j8|D94!veeQ zwS|ZUDm*bqIjHrl_Kc*?H_?IPj}N(es_sgK32 zc%B;-$pPTdpFKnuxB|Zmlu3ZH!_f@E-r&YXm5sX!Ce4YMh`7##vz0%N2l4m2bV*S} zX~AG$=3xA&2p&w^23gA*gc}RwbV~1{=WJEHHpfU1x+co^01201o#;7*w}>in7Mm7j zmInlH$w~g}GUc0n3jeH-SCXsaHtZ_cGJl74k?X|7u z1cTrY33{*dH<~R;yJDgI8^$`E-y4G0|Ap|a$dM5DF5=ao7kSazQKu`>7>Za8oQz*Q zV4QFlahs>8w!VJcjhdPoLxoJBKLZjW>F>fh;}=4bmCPlkGj4Z_^Bh}2`)_CF!<(gV zt4SN=r~@myIsG#+PIVt>cXM zR^#vJJUJNl;L4M)@`bKN=O4ImD3;t`c=z$tLH|cKOBgOL4T~8E*z$KVxaa2YU>ICd z-25~6<8>1`SOwVM1NXv^glU4t7K04i$(m#kzG}&m0LjiZ8RLsO#va$Q^V)ne zfJit}Va|$g)2!mbUFAXdQiXOk7#kXOCx`IWU9rI7StT*I-L7A+`|=g}yv!P?uf8sZz7`a`PmYI)mGy> zjqOC!JWQ`u?D{M+S?RRYd)>z}pOh_a63gU5>ke&ivwSzp&zZ4F(C-3c)7Y)E*z}CH z4tq)rofHyZ%%D z%^TA!^yCh@OD0Y}TYL|$xyypfGspNuFCkMu_^ypg2f@Jru|4R%>z4Z25qA8~bB?Xp zrwPO8;&IYc5rJHm#Ej8utLjGUM9ozMM8k1SLM*KthJ$||cUheCc2gf=8k@L+&PQpM z8OIN7uFDPzE1EBSq;#FFY>HU@)vVTEfhfBE&5rvW<)p(b-f?{hKe>W2wf&{xWmcnb zV10;5)(5nM3ek*f<#pv2kiz)yY_TSziWC9=G~2Ic$8~gib8+;`85u=dh-}=r@qH;6 zIm}Q8n}xT1eAEMb87!^RF^5rDJDQPES9U*)4Bflnrz$I89k<3|I;VgQyG_gdi;3b~ zu9nH_iLLo0sGPdw_Zd;Rw zNm1|dxd}&}>2S>&Q@`rz@+6s%%K1e>rYnOM6v}E$X^Q;o=4ThCF5`|a8c1w)usSGt z;PSa9oBc-KRu?!cs3wsS=ZPu=JApgxZ%B zZDXImEQH_4Z?ZvD_ECK+)di~8U#BN(*iGK@AU2iF-*cmu2XK)lUdxs;#cS8Dxsr&h6 zS?1Fmv$DQSbqscF_!1g*?}z8=e+JQ7=P*Y2HR*UcWZ?%ji`8uQcj z28~nJMCKife^+d&!aLvcLjlWXzbR_7aUTU3^B%xXebw^iyO2}e%RwCCNHNjW)RbT- zfdaXiOJc@M!M}z6$ytyTx?fqnemxUPgn9S!C#@$wO=8O{UlC;yOE2YuapxyE1@!bh zTvk$EUQU^!Eg0xgz3pILvyUVT3O&5~3V3;*uipIr^rYL;&bquw_is#o!F{q| zqws*6cI^paRzfC2#sfaX5pZK>rWlVqLdhPB8THP-iS{r7j%LX7wrr$Z$)?iZ+ZjK~ zK4;}4vY+I5#MkX%18h=JW@?X)DN=+>E-q1Vd5<4&M}D{Q@a?Z-^n=;YFX5jS8vyuV ztcH+?h{&QI{kHoC1~1%}v(=$sTJtdlZ0ZEeRfquGszUj5&W1^EjRcFw@_&xq%E&!7 z?yfugVSzgPrDw7`%s%#Oc^_mjH;R6hAW>?w>l+h!SHaWkDiRtd=BCtkNUpZ1`>JO7 zNrcCuK`3=bb;r|H7IkCI&MY0>HJF_DT2bD)^W*dJ^9#kc4&|0V7T!O9>PgxAcBT&W zKb%z8{4(mzX-lKBu*Mm>tPod+;;IGddwi`EW8QSJ{dEDHX#IzdYy#oe{rsYl?)2uu zl=$M3B}=~5rJHL)L(@FvAs=jm<>6yc)mCHMQLvk>4nAbe(Ax}dNgR}rQK+(Qud@B( z;!p*~yFXmB)S*5s@!4YDf$gjiZV8w`>8+-u>#+)_8<#b_rZ#!PMjgrlryu8E#N!IpfvlOpan3(?2FZrq0@_vMQ z+yQ+N7s-Y9i)MNk)0wx(=SJ@&^J|O}V(Sm>C@8&5F@# zt=?&&X_dk3wpZ3H@tgWF_?7H*{myq9J~DOvsqH~?D_1c!bRTq-h#mQ&II`Sy;Su7O zHa&3bj#j3MN{nm#k!LGJPgGtPZto;Ht_uCI6QIs|Ig^%|(WD>_ELYcC==PL%M6)()n zKBk}?c&mFzKyjl&&J8GzBf4zGZnG}UxuPU{^yty}k3wg4>{2n26Q^-#0U~owPjrD`&YXr#R^RU3hvnw}?;v;$Igh{#F#yA)dr~ zk(nO)GD?+`?{%eI%s|`M{KTV`1}@%a?g2*wxoc1Ud0r=LWy>oW4jW1SORGNuMm|4z zzJZgPC!DJVPB*I@@Z{S?7TQ5I4Z!x`@mjF()^TBAmt6DbSNQjjo9c59RKv|D{MRSf zPKO!Bp*~S&nnV{y1YCVHXY)?1y*JR(%E5KhG(#2c9&(|4T2qnSC7L%Jj6m@%erWOG zY15|lMfHJMF!n}Ycbis3#;yKRk;4#9X>KbP(DH|{s*V@PBM_gyT*@~gL~rBO0yS}4 zlCQ6C6pEm+%Op+%58T%4a^526$mDc^q@5y$9!HecyofD)aly?WXPx6~cP*ojUv_Ec z`DleF`1TFzyq)w+E#*Z>koU2%8bQIOrZg+m)v}m11gah1Q2Pfm-j8I{iiQy%|1m7PIST%NbEGoXrdANt zECJZo4Z;eA#tihLm>kQ)*Ds5JltXIIo=+ZmD<2>WFSDLLiCw4t$Sy*BfpTiLd`?bI z<`%b4!)AA=0>e+X>HP|RyMjqPYm$yW;}C_FYhM&vTsr9+6iS62ASsEXKL>C9_hh23kH zKM07lV^2(MLTiRqvA!ptwWg3$Lm*1U@V;RAnGwC1sW}{G6Qe#wlxw)mvR5>zsNQrS zxz2@(f-ybW;sdhA3-((Ow(JW6*yUxf|7zJ=J%L~6%++MLKj}kgX|{PDwV;k3?Xuez zKBs&WmR?;$gCShIm2ROU^4H^M(0my?Ia0cyv;~8}sQx7c%GWGsHQOU4oefO*SKk2* z^lNu=g^3270}@*=%-MRH`Zy3G4%o$H4x|ujO6faDf1x+ZdH^dKf(y#r!dn7>RRaeC zTXq+#I9JMEJ?%_vX0+!hs|t$}eX!)%*z|%?lj(znY>fiq`0Voz4w?4-r(jO7NhPyj zbCz#yVf(4uf-Pm{txbn6Eb}%IE+{_iO2_r48&Pk?T>6qiDl4^2^&WP&a3wxxMVmOk zGc;^4y-^X@JGHS_Fs|i}bwbJCQswh)6=kI+di!N=+CIfWJ;3y)%{c?pLs#xJY4S4d zbW@`o592NAOV0!cY$tMf79vg&P@bq~1JyM(#Yh;bET$n<=b{*?Xum}80v`58rWlp6 zbJwobNM7NgcT1oo@0;ojsC8W-eYKP5Plo2w0#z7$Gylply1Kd%>u-Iz_wuVsUfs8U zej)z5=`YI#zG1$Nsa`J+^Y)sA)!S~6-hEpHiTfzVAN+5Q0dG?B%f7q!dIG8dgWbUZ zb%q*F&p1z`hZE^D^5&AxskF-xCg~ zRLTD>6758wU5<<}Go(ezh^X!}9=t5teLG**9YX`iBe)4hUUJAbi=#C}P&UIi{{=@5 z&+63^LQM~f!CfwU)Tl??0abcuPRa@%$S93!s`|8QY3cxPu<3lQt~2#DL(R%R?2BdE zBIcfZGdF0mp>*-db-jAK-)U`HX>NB;w5#Cnu$0(b*1}U~FUJ z+n{ZU8Y)XFI{kK+Tt2~iy!aK5IQ#d`nuPqZ^-|r~@5)}@+OOF88bs}|=B)8>EYN`Y z2*zY$Jx4LS`C>_PzXE8(^w!->O2TxUk2r4P{`fd|lit)?R1Ri+j1x zWBZr(ATPtGCpVbyhAwRC)q-hjzw`zX9RU2t@cLK2hU>x;pt3UDwvs<05N!)#@(e%@ zn)~5I(Hm2S|N4zhqXl$==aP4yHTq7YyCAT5TNZ8(`sPP;kxO;o>GTw1NLK&xn36-r zjGM_RA23uf6cojmUgPg~Kjk%l=Yf-0Wz7VoI=Jc;gD)C_hb?~CL00w}JYDsSi#KYi z`adS&0P zC+dsZVCGc-&&D}LpCKPEfzI7H?*QM*rdHm=Ig^|Xu6X5q9fsEQR*g+|vaZPxEh!aR z?u3^uewG%w5ha^WD>MGO3I)@lL&oS9e+1_^UXYPDcQw!EMeyWuMr%YE1*`v#_?HhO zQ#AEsu%)~IDfK3W{>Qe#x2* zJHj;Q%)K+Ck10JW6if=S56ZZK<9DI?8CJ64ZLq(EEgHk>3aXnGVoGm%l{PI~Ctj~3 zTHL3=dGKTC9-9_1e(%n_(=WSTp3dtsoNljJpw+jzvpHy*)TId-ul^5PUjY{Nw#7Su zq+lZg0%C!Tf=VbLp@;zz0z-$0bV^DIN_Z?lr9(n`DCq`81?lb(rH2w2I^Npgy~q3B zoA1;6UC;3_|JZx4^{Zw0vbb=?+&7cv+M4E{RdFrA*2XH~=b!5udg^9OtOrfzQ zVDKq;`jix}t*+iJ>Z?)eQb7A*{IO0CByOH|{p(F`RTo;7^^nSkC&-DuTIghemky5J zh?!tZnDZe^O>G@*NnW2)_s^0n)6ghX9-fhZl6O7MyFsVYI1Af7sL&96i1^Yp;qgOr zJK*1dXgp)+ceU-)Sj(q0mCeJ^Jp;tC2J=r^Cv%KEVLVT6z?wk%B)_0oEb?1AXr7CA2~N)-*J90KAus?D{>WYUwcm8ySP!`RWpnsAy?3 zFdR!|%2p~eCE#~*0vc$27|=N-6npKVU9j3X`03|{{R=MtW*1g)22;4tWNpYKvVc-qOaR} zMK{2r>0owNKbUlM&odzSlAs%Bu{w`)U1WY;NsDLlV@hgZaOSspoYX#ZIirnF_*BnA zdYL4r(9`+7sB7?tuHk#b75M6XkCPS4EYI_q-tMD(7}41g9VTn5_M~7<&s&A1IVLAZ ztFu(6+|}T6VC8JJxlOAv41f=g*5Y~nywNj+wNC3`ucXX)hd+ zyqf~&BI)ANSW^X!J>p1G8}Zh#sB%Y4;$mZ!x=ICu@G;OCa0&_*D7y-b-fe4z7|#9Y z4Gi)-px$a4YJh9p`G`xXEfW>=4Ic2XkmxKE59pgWC|^%0LbUUoQ53^NPmuc5^S|GzNaF z4(f~;p7?W7MrI>1kD>L~EiZ+rzxZzQ)!Kq9kaAR(GMj|-Bx^vjo1L@jfL>F5snRS# z)7CZ{`Ff=WRp?(c@}gkM7ASEDI@B(}-U}hH^-6znx+b)>rYw)`jtN-wCW5_I+WT(q zBhCBwW57Xj+jQ_#{Num&wCYPUwZ?}%rp|4zhGuHLXP03Ahds?W9Lqs+>`ted0NpJ+ zqu%EhCAN;927QNHeeAN@m?E6X^3Jv7jt8H~Wi1cttMz==3a`TQ-GQ=wl)IJX0 zxKNSYU%;(!DRM}`&a)VI?W5G)4BT|7E1dZE0dXHY;Muz!amRMekpV#c*zP;0+9Pai>7Kb?Jtu(qo{V?{zS14jUF-@F6OT@ z$Z*@ZudC-klE`0?@ey}~3}ZwCicoioQlnzMxBj8>iO~_fOU~<-gzeG0;zsI!v5NeVL=f%Fj;Db^a+s_=-UeNZ}76x~ixN01}g6%m{Gr1-MN( zDM1?qVbx42hvdDW$R|Lyy|0}x2ggmgJ=rLc-NaX?&^8I;ygb|=6CqBdT5nrJgZ`k+ z554?=DfH|3ynfvV+N7S8zwf{;jogoFblD9Akn4;q5QA<&b?j+~X$RaQ0ZN~>8YtJP zC-PSn`hJm>_ZM;u&+uYQzu$T$d_o3nu(OugzH- z{L|+Daev_qnJ>KsJuUB90*FPOmR<7bWBvmf&>4g8bhGF8@85_tYqWD-7#rNHI_FfP zT;2lZf)aic1#YiG5H23h8T=K>YJ#SRfy~y#d1Wkt0R@<9#psLVX_z@S>oeHv%SiD$C=v(5PzASiNvH2ZdUe z&|in5ZeKH9oki58vjZvZ8wZ$+z_WME)AKqeknuA8oiU}1N)mC2@i&@Lk(Y2=?CJSR z>Tjl7bR+UFA4Rw0L*kc&HVu#6GA5j+7hUF7X|ZE8pN#h#w;cS?=VYM#+@OAm0>_ac zU$`>B`dX|lu4UEhONRpdP56kv`#HaoB7Tau7uLImY=R=t9CHJfN4(8)XV+L;*Gphn z&LEEiplCOUJvpWf^8L_?rjiBLx+g##Fa|fP=prv~swrblFC0aG98q=Qg$ew z7dFfI?%XSP3rw3EIA2d8oB=8eajD~8YzZp{ZN zDk`E$f_z2uf7&Murg5J@s;s3wS&3uaVWgZFkN7Difib$?_ik>7sei# zVf{G;1O(208=!ShK5{Z(o69u#M+xRtso6EBgXwh~b{60VXu_)< zAo-UF^LiB3V0EEzoh+?}OW=29`|J#1bn+^XEYbDiG{-)sH5tji*iFX6MAfl%ka}^; zuW?t%)=+4NSkY?5WsK2rbfDa@SFLoVymzi^=v_jqU(a{)LHLguUG`%AaIL&;+7Dlw zIoED>V$AQuNNlXXrMc;^DvLcb1y`=+$t`h;@aBIEFZz~Xssjc0U5lLOc^wnKclJ_u zt2aSG37}PxM_J#m3EZ0bjc(3Rgd;eRKG+tze5}9YmG|zMjI&AyLFlG^I!g>78YjqD zd0LvN3nUiAI)&N7<}d-ezLblcNWdDf`1Q*dG=rf9oY<`&^={(duQ+5|4@)88n6ee_ zt0>B>X1e&`AJB9cGi`_(9Mg~lsc3|mfJT;xSUixTY*r{yxLpnHGw4oXCJxM;gDVz# zA@Y@rd(V2=+j#CvVwa+7w{NBfGAA6QDqnTEO}KCL|n6yK_d1kG0}gfXbY__hoQQkXtos+ z^nrGu(m2D;9<9IzfjX(6!|xHQr@hK6(NrDcDhv}PhU|+xR$Xgjk$va*@ff%Xt>^l+Fz60-M-H?fL{1y`(BF*S_Crz{nJ!WTEEc%ZkS8j_ z)^saRHRMIjvi~ufWcBp+GzxoPT9wKnhHZl0_-b2M08iht!N=(<6>-1TX6;b@1|Ru= z=4J)tg}{01z>r0So2-tpV4?hgMjGMTdCr`| zbmi6sny3XL0;xoRJJ1d(MP#I&tS~3F1llWh!LbV$p1hmgO+#a%BqktO9pDXTWX?%zFi+%9A`DJIaBn=*sWA;I%kvRiqln31$C=G9y@nPL?rhl z4S81u)Z8%BGo1il0{HB0gdp5;{a}`>Xe9lE2NZfQ3o0zjLkjq3ki@`(6KgsYtk3{4yaGz5%jA%3a zyGfUtYDrR~dvwPoTcMkSd^_*E_3-v3`;%)-36DDrI&GInq0H$GjXu31pOl}lt9a** z9--mhljtb@Phj`y&Q^g>rE3W?Aa~bZEmV^EdO?IbELBdVyOW5Ti52aS%CVG7DweDc zZQ1ul?4M`=jnrYf^=B2vPXm)n&8cJk{Zzo`7baZ%{5Zq#hu+WfJSUxl5p_WvX zks%LRJnzcF{rsYks<7)l+s+S$P1w6N*A)Bp^fxXZ-gGru7he)B<|0^A$95r8f{Zxu zE<&ezvULrK3{STLM_M{5rqHIlSb3gSpWetJDj;2N)LK*b&UR&*s3~TKBo6g!k$kQ$FBxIEFEWK_4_YC=@b49{gj=#|dgDAqYEK)JV_I zR|Q+rd=L~!iJ)aCo38A)%QNp${;=xk_=G|1`i&dOX|Zu}ZI{DM!7kE%BmnFazlLmu z!u~$3ZtZy{aWm2;Ry1s`71H*pml|73z1(``&F@39yY`EMQCz;^NNRg_f z?^#T4n7YTU*;|*-@8=Gi7ORYtJtI&eP{fxg1VuK~0fnoV6S#$5*z6qr?z6u@u^AFF z&eJnxk3_q6&GsE>Pq2KWBO_c&aIA}4mJkEWu0Z1yMH&d9Fh%a}2{5xMMtS4BL|pAm zY@HuR3+Rdrywtyb`$mD=+IDl~nY2~Ap`H}jk;1Ymn7lLwZK_D_Tn z$Y~V*_cB?lNsM0onPaKH9J)v|d1mUJ?bxgb*`_5tvncL;=yl=55VjuUMKLwM0s_tF zjKz~bTtynmeG|}t<{GF_<QS1VV*A?@y^_7=+^;4R{dshkcrGnyb`lL6O-EuX^W>%b}GqzH)`ci zq&qY)7=!$=U8>QJ9n?TkN2}kpJ)>hj4cO+}x0zmyn=@ORauBBwM}Ghp0cAr8I7~>^ ze87XDPCOwbq>jK+n1YjH4aNc(y^##?xN(^sDDUF7Pw+LASTmca>#d{z;DM?>&=q1^ zDX_U)mzhHblGrg!%{nu0<=J2-XG%5x(ZAZSMs>S8cO#=qvLDpXofcVkV-##vPXRn9 zDs6kAU3bHFCYv45u{)f`0X|YpRD7|hM32qVE=}|AeE!;xg=-R63iJnsqPf}C^`n)k z7c<@QUqqg$r2aCu7Mu|MVd55Z!VGSQhC}J-UA+S0p}e-ee8pvp50lMfv>ar4+SAs5 zOt|D$;p;-YSw#m@J&o$2OGcm15+Eq0KWjgo=vcQ5g|XmZ|jnQ5X=T_ zgIdCMlaAww_qBjF|gCuQ_@^?=RkWeoKSzDEH~z z6u%myplMVyev{;FP%n)_Y(7Bb4Gr4aub-^!M%-0)L|d7nX7GqK z5z3e4?d@}M+-J@p4lD`ymbStvn2AH-kEdaxIR!~d0pLs;-s`jq;F2xmAB_Pg+yv-Y z?AVymii_w5D1MNoEgM3Nq==v}PhCUl!V|y|p&~9db0ye7KA^n}KjlEjy?PP8JAAIlC>L0Z(U2)VX4-6d za5<5!-=}SkT9mGgY;3ZE;!^=E+-1FGZd-nr|AJ+v{608MB=YX*@MYWTK1t~|WNvtd zJ4q7BfkXLu*C*ggCuXw|^a$X+w-*Zn2POGAxarQKj@z~`kx+)K^Smk&7#vaj?d z$P_VIbh+=jEzoCsoZ%B6x%tm2r-yeuS%b%$_ToLH@w{78I`iruPlx|%|1s?uyz@|w zE!ctK046`p2EiyjX)Qn{b1XV&nUfPCbC^aS$qZpiU>tZED$5#Z1CT`(@HYqOm99c| zK8ddUFla0EycBVU(0nO3DoL`r%sv5h^IH@4RO7&_9ik? zXZTkHbfu`~;hLI(oP5anW z>Ej#z+G|?19yJuGFSc;$TQ=NPY&JCFp)325xLpKO>FUzE>!+z+0u6lxfYI$-jw26P zF-B5Q785>@w^7~;coY{G=EBtJwYwPL0i)mkQauin)HxUP02JD?myN#mzVCF-E9~y9 z?t@l1?Obeum*bsz+WhXwe9o}bI+N{0%SZliw6@q)Q-9U4Hn?c=(KT6wp)Dt<>`a^6 zV=lU6*~cGkdx}`GRg=9H6%*J$v3H))fR<81&^6>ZDR=rZQrSj9Ey#cp(eDkwcyRiO z+GBA5BASA`nPiQwLf*i@Aixfq7E5xe0?js_lzS9vuqP{ZtFYLlAbJ926X7U?Kum+v zK*liS3{BQL@(a?1IzaTo4{ArLC0IY$6ui(`q`!)!c<$5O z>Uyk4;tbPVC1oU)%woS?`EvXuyqAx?+S;Csc(U94YzNIr3bHi7KpxLu&XvXsWVh5`%d@A?_umL{RQh;L>;5bI zXX;&K*V1Zx3^?Dl>@SU!>-o|{{H$*QikX{X_H8;;%FkjmXR-6Y8?!G@3TQ2<1PR4; zE(|4{;v!C!`84>w-;B9Va9Fl&p-#)#ytEUD*S`91VT} zraVIm{tzPeg|BZ1B5>smeL5g+{a)QExPwdUdg>7X!a>5=?RJ-}LIduh⋘SKAQ#+ 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 literal 0 HcmV?d00001 diff --git a/archive/bridge_crack_study_500px.csv b/archive/bridge_crack_study_500px.csv new file mode 100644 index 0000000..00cf235 --- /dev/null +++ b/archive/bridge_crack_study_500px.csv @@ -0,0 +1,8 @@ +trial_number,trial_id,state,score,loss,duration_seconds,epoch_reached,learning_rate,batch_size,resolution,encoder_name,loss_weight_ratio,model_capacity,gpu_model,max_vram_gb +0,10,COMPLETE,0.858018802569735,3.420776633773675,1666.775988,15,1.3000740917849293e-05,8,512,efficientnet-b0,0.4569677019352898,N/A,, +1,11,COMPLETE,0.8570024923984412,3.167852641157963,2674.331473,15,0.009016956432821428,4,1024,resnet34,0.8759492571891342,N/A,, +25,35,COMPLETE,0.857793011512137,3.20810041447732,339.773174,25,0.0016227567399957443,64,512,,0.9312941039111402,wide,, +26,36,COMPLETE,0.8579996422931605,3.450359302231028,140.290804,25,1.36045241634878e-05,16,512,,0.7556562618734615,narrow,, +27,93,COMPLETE,0.858148133915902,3.164196773923399,142.660719,25,0.009599869896398518,16,512,,0.6784632970852156,narrow,, +40,178,COMPLETE,0.8553575962463495,3.1704550543917884,564.41964,15,0.0011162195120225547,8,1024,,0.8482662431928073,narrow,NVIDIA A100-SXM4-40GB,39.4935302734375 +66,204,COMPLETE,0.8557610089292585,3.171118880123026,1338.960238,15,0.0034464570787237354,4,1024,,0.6326231660481053,narrow,NVIDIA L4,22.0343017578125 diff --git a/archive/bridge_crack_study_trials.json b/archive/bridge_crack_study_trials.json new file mode 100644 index 0000000..bcc234a --- /dev/null +++ b/archive/bridge_crack_study_trials.json @@ -0,0 +1,2858 @@ +[ + { + "trial_number": 0, + "trial_id": 10, + "state": "COMPLETE", + "params": { + "learning_rate": 1.3000740917849293e-05, + "batch_size": 8, + "resolution": 512, + "encoder_name": "efficientnet-b0", + "loss_weight_ratio": 0.4569677019352898 + }, + "datetime_start": "2026-06-03T13:43:13.054883", + "datetime_complete": "2026-06-03T14:10:59.830871", + "duration_seconds": 1666.775988, + "optuna_loss": 3.420776633773675, + "optuna_score": 0.858018802569735, + "epoch_reached": 15, + "primary_score": 0.858018802569735, + "primary_loss": 3.420776633773675, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 1, + "trial_id": 11, + "state": "COMPLETE", + "params": { + "learning_rate": 0.009016956432821428, + "batch_size": 4, + "resolution": 1024, + "encoder_name": "resnet34", + "loss_weight_ratio": 0.8759492571891342 + }, + "datetime_start": "2026-06-03T14:24:34.312874", + "datetime_complete": "2026-06-03T15:09:08.644347", + "duration_seconds": 2674.331473, + "optuna_loss": 3.167852641157963, + "optuna_score": 0.8570024923984412, + "epoch_reached": 15, + "primary_score": 0.8570024923984412, + "primary_loss": 3.167852641157963, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 2, + "trial_id": 12, + "state": "COMPLETE", + "params": { + "learning_rate": 0.0004925474076170948, + "batch_size": 2, + "resolution": 256, + "encoder_name": "resnet34", + "loss_weight_ratio": 0.31234114076019825 + }, + "datetime_start": "2026-06-03T15:13:22.895474", + "datetime_complete": "2026-06-03T15:16:10.457808", + "duration_seconds": 167.562334, + "optuna_loss": 0.05479208334007218, + "optuna_score": 0.9898519602789447, + "epoch_reached": 15, + "primary_score": 0.9898519602789447, + "primary_loss": 0.05479208334007218, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 3, + "trial_id": 13, + "state": "PRUNED", + "params": { + "learning_rate": 0.0014412064723689244, + "batch_size": 32, + "resolution": 512, + "encoder_name": "resnet50", + "loss_weight_ratio": 0.43849321487231385 + }, + "datetime_start": "2026-06-03T15:58:28.404445", + "datetime_complete": "2026-06-03T16:01:28.153573", + "duration_seconds": 179.749128, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 4, + "trial_id": 14, + "state": "PRUNED", + "params": { + "learning_rate": 0.000592418490813305, + "batch_size": 16, + "resolution": 512, + "encoder_name": "efficientnet-b0", + "loss_weight_ratio": 0.5883136720418263 + }, + "datetime_start": "2026-06-03T16:02:12.182605", + "datetime_complete": "2026-06-03T16:02:29.436115", + "duration_seconds": 17.25351, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 5, + "trial_id": 15, + "state": "PRUNED", + "params": { + "learning_rate": 0.00866044884543897, + "batch_size": 2, + "resolution": 512, + "encoder_name": "resnet34", + "loss_weight_ratio": 0.1362870798746597 + }, + "datetime_start": "2026-06-03T16:10:37.069107", + "datetime_complete": "2026-06-03T16:10:54.118335", + "duration_seconds": 17.049228, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 6, + "trial_id": 16, + "state": "COMPLETE", + "params": { + "learning_rate": 2.6272157099819104e-05, + "batch_size": 32, + "resolution": 256, + "encoder_name": "resnet50", + "loss_weight_ratio": 0.6982048083978067 + }, + "datetime_start": "2026-06-03T16:14:55.109066", + "datetime_complete": "2026-06-03T16:15:48.273632", + "duration_seconds": 53.164566, + "optuna_loss": 0.34094087169643195, + "optuna_score": 0.9900689937983594, + "epoch_reached": 15, + "primary_score": 0.9900689937983594, + "primary_loss": 0.34094087169643195, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 7, + "trial_id": 17, + "state": "PRUNED", + "params": { + "learning_rate": 1.5280813833109962e-05, + "batch_size": 32, + "resolution": 256, + "encoder_name": "efficientnet-b0", + "loss_weight_ratio": 0.5494909479212793 + }, + "datetime_start": "2026-06-03T16:20:02.454960", + "datetime_complete": "2026-06-03T16:20:10.660498", + "duration_seconds": 8.205538, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 8, + "trial_id": 18, + "state": "PRUNED", + "params": { + "learning_rate": 0.0015560408056926069, + "batch_size": 2, + "resolution": 512, + "model_capacity": "wide", + "loss_weight_ratio": 0.6541735359413245 + }, + "datetime_start": "2026-06-03T16:36:07.874740", + "datetime_complete": "2026-06-03T16:36:24.756243", + "duration_seconds": 16.881503, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 9, + "trial_id": 19, + "state": "PRUNED", + "params": { + "learning_rate": 1.3128228293775808e-05, + "batch_size": 8, + "resolution": 1024, + "model_capacity": "wide", + "loss_weight_ratio": 0.8645862853362126 + }, + "datetime_start": "2026-06-03T16:42:10.936967", + "datetime_complete": "2026-06-03T16:43:46.471404", + "duration_seconds": 95.534437, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 10, + "trial_id": 20, + "state": "PRUNED", + "params": { + "learning_rate": 0.004406605185153241, + "batch_size": 2 + }, + "datetime_start": "2026-06-03T16:44:51.217700", + "datetime_complete": "2026-06-03T16:45:22.544899", + "duration_seconds": 31.327199, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 11, + "trial_id": 21, + "state": "PRUNED", + "params": { + "learning_rate": 0.004606786973558291, + "batch_size": 64 + }, + "datetime_start": "2026-06-03T16:45:43.747381", + "datetime_complete": "2026-06-03T16:46:13.669698", + "duration_seconds": 29.922317, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 12, + "trial_id": 22, + "state": "FAIL", + "params": { + "learning_rate": 0.009238447012502312, + "batch_size": 8 + }, + "datetime_start": "2026-06-03T16:48:01.525344", + "datetime_complete": "2026-06-03T16:48:01.552523", + "duration_seconds": 0.027179, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 13, + "trial_id": 23, + "state": "FAIL", + "params": { + "learning_rate": 0.0006548097272413485, + "batch_size": 32 + }, + "datetime_start": "2026-06-03T16:48:21.372164", + "datetime_complete": "2026-06-03T16:48:21.385289", + "duration_seconds": 0.013125, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 14, + "trial_id": 24, + "state": "FAIL", + "params": { + "learning_rate": 2.4349414095370824e-05, + "batch_size": 8, + "model_capacity": "narrow", + "loss_weight_ratio": 0.9316343050565551 + }, + "datetime_start": "2026-06-03T16:49:13.338941", + "datetime_complete": "2026-06-03T16:49:28.821244", + "duration_seconds": 15.482303, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 15, + "trial_id": 25, + "state": "FAIL", + "params": { + "learning_rate": 0.00014020437791801606, + "batch_size": 4, + "model_capacity": "narrow", + "loss_weight_ratio": 0.1742679306216336 + }, + "datetime_start": "2026-06-03T16:49:28.830983", + "datetime_complete": "2026-06-03T16:49:36.991214", + "duration_seconds": 8.160231, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 16, + "trial_id": 26, + "state": "FAIL", + "params": { + "learning_rate": 0.0016347032047742205, + "batch_size": 4, + "model_capacity": "narrow", + "loss_weight_ratio": 0.03717144506513459 + }, + "datetime_start": "2026-06-03T16:49:37.001315", + "datetime_complete": "2026-06-03T16:51:21.026869", + "duration_seconds": 104.025554, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 17, + "trial_id": 27, + "state": "FAIL", + "params": { + "learning_rate": 0.001424331563772705, + "batch_size": 8, + "model_capacity": "narrow", + "loss_weight_ratio": 0.38679370235988164 + }, + "datetime_start": "2026-06-03T16:51:21.047576", + "datetime_complete": "2026-06-03T16:52:06.743811", + "duration_seconds": 45.696235, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 18, + "trial_id": 28, + "state": "PRUNED", + "params": { + "learning_rate": 0.0006710326147742782, + "batch_size": 16, + "model_capacity": "narrow", + "loss_weight_ratio": 0.002323245785979422 + }, + "datetime_start": "2026-06-03T16:52:06.749228", + "datetime_complete": "2026-06-03T16:52:06.767498", + "duration_seconds": 0.01827, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 19, + "trial_id": 29, + "state": "FAIL", + "params": {}, + "datetime_start": "2026-06-03T16:52:13.957698", + "datetime_complete": "2026-06-03T16:52:28.811225", + "duration_seconds": 14.853527, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 20, + "trial_id": 30, + "state": "PRUNED", + "params": { + "learning_rate": 0.005569662100056995, + "batch_size": 2, + "model_capacity": "wide", + "loss_weight_ratio": 0.9243719613528233 + }, + "datetime_start": "2026-06-03T16:52:28.818704", + "datetime_complete": "2026-06-03T16:52:28.836298", + "duration_seconds": 0.017594, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 21, + "trial_id": 31, + "state": "PRUNED", + "params": { + "learning_rate": 8.635129594504407e-05, + "batch_size": 8, + "model_capacity": "wide", + "loss_weight_ratio": 0.2159129532506059 + }, + "datetime_start": "2026-06-03T16:53:10.479054", + "datetime_complete": "2026-06-03T16:53:27.942296", + "duration_seconds": 17.463242, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 22, + "trial_id": 32, + "state": "PRUNED", + "params": { + "learning_rate": 1.2671531921681287e-05, + "batch_size": 32, + "model_capacity": "wide", + "loss_weight_ratio": 0.8084256426884586 + }, + "datetime_start": "2026-06-03T16:56:05.615491", + "datetime_complete": "2026-06-03T16:56:34.427798", + "duration_seconds": 28.812307, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 23, + "trial_id": 33, + "state": "PRUNED", + "params": { + "learning_rate": 2.5298748345870258e-05, + "batch_size": 32, + "model_capacity": "narrow", + "loss_weight_ratio": 0.8252409941205194 + }, + "datetime_start": "2026-06-03T17:00:35.578893", + "datetime_complete": "2026-06-03T17:01:04.174560", + "duration_seconds": 28.595667, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 24, + "trial_id": 34, + "state": "PRUNED", + "params": { + "learning_rate": 2.223369003885487e-05, + "batch_size": 2, + "model_capacity": "wide", + "loss_weight_ratio": 0.5713714827302104 + }, + "datetime_start": "2026-06-03T17:02:56.369030", + "datetime_complete": "2026-06-03T17:03:24.500350", + "duration_seconds": 28.13132, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 25, + "trial_id": 35, + "state": "COMPLETE", + "params": { + "learning_rate": 0.0016227567399957443, + "batch_size": 64, + "model_capacity": "wide", + "loss_weight_ratio": 0.9312941039111402, + "resolution": 512 + }, + "datetime_start": "2026-06-03T17:15:55.022375", + "datetime_complete": "2026-06-03T17:21:34.795549", + "duration_seconds": 339.773174, + "optuna_loss": 3.20810041447732, + "optuna_score": 0.857793011512137, + "epoch_reached": 25, + "primary_score": 0.857793011512137, + "primary_loss": 3.20810041447732, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 26, + "trial_id": 36, + "state": "COMPLETE", + "params": { + "learning_rate": 1.36045241634878e-05, + "batch_size": 16, + "model_capacity": "narrow", + "loss_weight_ratio": 0.7556562618734615, + "resolution": 512 + }, + "datetime_start": "2026-06-03T17:22:26.684040", + "datetime_complete": "2026-06-03T17:24:46.974844", + "duration_seconds": 140.290804, + "optuna_loss": 3.450359302231028, + "optuna_score": 0.8579996422931605, + "epoch_reached": 25, + "primary_score": 0.8579996422931605, + "primary_loss": 3.450359302231028, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 27, + "trial_id": 93, + "state": "COMPLETE", + "params": { + "learning_rate": 0.009599869896398518, + "batch_size": 16, + "model_capacity": "narrow", + "loss_weight_ratio": 0.6784632970852156, + "resolution": 512 + }, + "datetime_start": "2026-06-03T19:53:51.414244", + "datetime_complete": "2026-06-03T19:56:14.074963", + "duration_seconds": 142.660719, + "optuna_loss": 3.164196773923399, + "optuna_score": 0.858148133915902, + "epoch_reached": 25, + "primary_score": 0.858148133915902, + "primary_loss": 3.164196773923399, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 28, + "trial_id": 130, + "state": "PRUNED", + "params": { + "learning_rate": 3.126406973760604e-05, + "loss_weight_ratio": 0.11912673266270057, + "batch_size": 8, + "resolution": 512, + "model_capacity": "wide" + }, + "datetime_start": "2026-06-03T20:41:25.704340", + "datetime_complete": "2026-06-03T20:41:59.084033", + "duration_seconds": 33.379693, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 29, + "trial_id": 167, + "state": "FAIL", + "params": { + "learning_rate": 0.0003129825951856815, + "batch_size": 64, + "resolution": 1024, + "model_capacity": "wide", + "loss_weight_ratio": 0.6505910029778382 + }, + "datetime_start": "2026-06-05T13:58:19.470631", + "datetime_complete": "2026-06-05T13:59:43.751567", + "duration_seconds": 84.280936, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 30, + "trial_id": 168, + "state": "COMPLETE", + "params": { + "learning_rate": 1.2908087536051017e-05, + "batch_size": 32, + "resolution": 256, + "model_capacity": "narrow", + "loss_weight_ratio": 0.0336207768294422 + }, + "datetime_start": "2026-06-07T13:59:34.848713", + "datetime_complete": "2026-06-07T14:01:51.748743", + "duration_seconds": 136.90003, + "optuna_loss": 0.6156984318660784, + "optuna_score": 0.8673244251587711, + "epoch_reached": 15, + "primary_score": 0.8673244251587711, + "primary_loss": 0.6156984318660784, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA A100-SXM4-40GB", + "max_vram_gb": 39.4935302734375, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 31, + "trial_id": 169, + "state": "FAIL", + "params": { + "learning_rate": 0.005529602980992804, + "batch_size": 16, + "resolution": 1024, + "model_capacity": "narrow", + "loss_weight_ratio": 0.5764868199904598 + }, + "datetime_start": "2026-06-07T14:01:52.028617", + "datetime_complete": "2026-06-07T14:01:54.489417", + "duration_seconds": 2.4608, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 32, + "trial_id": 170, + "state": "PRUNED", + "params": { + "learning_rate": 5.619733318203318e-05, + "batch_size": 32, + "resolution": 256, + "model_capacity": "wide", + "loss_weight_ratio": 0.2684312186462182 + }, + "datetime_start": "2026-06-07T14:01:54.754181", + "datetime_complete": "2026-06-07T14:03:10.632234", + "duration_seconds": 75.878053, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 33, + "trial_id": 171, + "state": "PRUNED", + "params": { + "learning_rate": 0.0023088874045366766, + "batch_size": 2, + "resolution": 256, + "model_capacity": "narrow", + "loss_weight_ratio": 0.6236688985236228 + }, + "datetime_start": "2026-06-07T14:03:10.901884", + "datetime_complete": "2026-06-07T14:04:00.814544", + "duration_seconds": 49.91266, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 34, + "trial_id": 172, + "state": "FAIL", + "params": { + "learning_rate": 0.00965489857451385, + "batch_size": 8, + "resolution": 1024, + "model_capacity": "wide", + "loss_weight_ratio": 0.5779408885200207 + }, + "datetime_start": "2026-06-07T14:04:01.090942", + "datetime_complete": "2026-06-07T14:23:47.673944", + "duration_seconds": 1186.583002, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA A100-SXM4-40GB", + "max_vram_gb": 39.4935302734375, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 35, + "trial_id": 173, + "state": "FAIL", + "params": { + "learning_rate": 0.008667747925003964, + "batch_size": 8, + "resolution": 512, + "model_capacity": "wide", + "loss_weight_ratio": 0.0854227054576977 + }, + "datetime_start": "2026-06-07T14:23:48.011546", + "datetime_complete": "2026-06-07T14:26:55.783299", + "duration_seconds": 187.771753, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 36, + "trial_id": 174, + "state": "PRUNED", + "params": { + "learning_rate": 0.003964260072586964, + "batch_size": 32, + "resolution": 512, + "model_capacity": "narrow", + "loss_weight_ratio": 0.35151491618316477 + }, + "datetime_start": "2026-06-07T14:28:32.046807", + "datetime_complete": "2026-06-07T14:29:22.126494", + "duration_seconds": 50.079687, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.8560799390346749, + "primary_loss": 3.2290616176299407, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA A100-SXM4-40GB", + "max_vram_gb": 39.4935302734375, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 37, + "trial_id": 175, + "state": "PRUNED", + "params": { + "learning_rate": 6.190175730149249e-05, + "batch_size": 8, + "resolution": 256, + "model_capacity": "narrow", + "loss_weight_ratio": 0.8010454023210211 + }, + "datetime_start": "2026-06-07T14:29:23.171195", + "datetime_complete": "2026-06-07T14:29:50.114575", + "duration_seconds": 26.94338, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.9895626247940745, + "primary_loss": 0.4081219550426499, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA A100-SXM4-40GB", + "max_vram_gb": 39.4935302734375, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 38, + "trial_id": 176, + "state": "FAIL", + "params": { + "learning_rate": 6.88390653876011e-05, + "batch_size": 64, + "resolution": 1024, + "model_capacity": "narrow", + "loss_weight_ratio": 0.7564837061499687 + }, + "datetime_start": "2026-06-07T14:29:50.971427", + "datetime_complete": "2026-06-07T14:29:53.344969", + "duration_seconds": 2.373542, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA A100-SXM4-40GB", + "max_vram_gb": 39.4935302734375, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 39, + "trial_id": 177, + "state": "PRUNED", + "params": { + "learning_rate": 3.193828708214736e-05, + "batch_size": 8, + "resolution": 512, + "model_capacity": "wide", + "loss_weight_ratio": 0.8196726856529919 + }, + "datetime_start": "2026-06-07T14:29:53.663673", + "datetime_complete": "2026-06-07T14:31:27.262921", + "duration_seconds": 93.599248, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.855850139872812, + "primary_loss": 3.454779870399443, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA A100-SXM4-40GB", + "max_vram_gb": 39.4935302734375, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 40, + "trial_id": 178, + "state": "COMPLETE", + "params": { + "learning_rate": 0.0011162195120225547, + "batch_size": 8, + "resolution": 1024, + "model_capacity": "narrow", + "loss_weight_ratio": 0.8482662431928073 + }, + "datetime_start": "2026-06-07T14:31:28.087930", + "datetime_complete": "2026-06-07T14:40:52.507570", + "duration_seconds": 564.41964, + "optuna_loss": 3.1704550543917884, + "optuna_score": 0.8553575962463495, + "epoch_reached": 15, + "primary_score": 0.8553575962463495, + "primary_loss": 3.1704550543917884, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA A100-SXM4-40GB", + "max_vram_gb": 39.4935302734375, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 41, + "trial_id": 179, + "state": "PRUNED", + "params": { + "learning_rate": 8.376984597427127e-05, + "batch_size": 32, + "resolution": 256, + "model_capacity": "narrow", + "loss_weight_ratio": 0.08460947756974468 + }, + "datetime_start": "2026-06-07T14:40:52.830605", + "datetime_complete": "2026-06-07T14:41:18.493565", + "duration_seconds": 25.66296, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.97001750168377, + "primary_loss": 0.4865190936291771, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA A100-SXM4-40GB", + "max_vram_gb": 39.4935302734375, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 42, + "trial_id": 180, + "state": "PRUNED", + "params": { + "learning_rate": 0.006814745505395586, + "batch_size": 4, + "resolution": 256, + "model_capacity": "narrow", + "loss_weight_ratio": 0.08170498386801406 + }, + "datetime_start": "2026-06-07T14:41:19.613878", + "datetime_complete": "2026-06-07T14:41:47.336577", + "duration_seconds": 27.722699, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.984673943843873, + "primary_loss": 0.11464002246689445, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA A100-SXM4-40GB", + "max_vram_gb": 39.4935302734375, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 43, + "trial_id": 181, + "state": "FAIL", + "params": { + "learning_rate": 6.435284774708397e-05, + "batch_size": 32, + "resolution": 512, + "model_capacity": "wide", + "loss_weight_ratio": 0.5264320365100166 + }, + "datetime_start": "2026-06-07T14:41:48.511069", + "datetime_complete": "2026-06-07T14:41:50.863072", + "duration_seconds": 2.352003, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA A100-SXM4-40GB", + "max_vram_gb": 39.4935302734375, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 44, + "trial_id": 182, + "state": "PRUNED", + "params": { + "learning_rate": 1.1932677758682133e-05, + "batch_size": 16, + "resolution": 512, + "model_capacity": "wide", + "loss_weight_ratio": 0.7292200426298769 + }, + "datetime_start": "2026-06-07T14:41:51.173891", + "datetime_complete": "2026-06-07T14:43:22.767690", + "duration_seconds": 91.593799, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.7932973447561847, + "primary_loss": 3.5894378851234663, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA A100-SXM4-40GB", + "max_vram_gb": 39.4935302734375, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 45, + "trial_id": 183, + "state": "FAIL", + "params": { + "learning_rate": 0.0003957933664968164, + "batch_size": 32, + "resolution": 512, + "model_capacity": "wide", + "loss_weight_ratio": 0.3679429819753126 + }, + "datetime_start": "2026-06-07T14:43:23.404815", + "datetime_complete": "2026-06-07T14:43:25.751226", + "duration_seconds": 2.346411, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA A100-SXM4-40GB", + "max_vram_gb": 39.4935302734375, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 46, + "trial_id": 184, + "state": "PRUNED", + "params": { + "learning_rate": 0.00011093794054144854, + "batch_size": 32, + "resolution": 512, + "model_capacity": "narrow", + "loss_weight_ratio": 0.8695930976572441 + }, + "datetime_start": "2026-06-07T14:47:45.801056", + "datetime_complete": "2026-06-07T14:48:35.169769", + "duration_seconds": 49.368713, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.847776949003498, + "primary_loss": 3.545791560587501, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA A100-SXM4-40GB", + "max_vram_gb": 39.4935302734375, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 47, + "trial_id": 185, + "state": "FAIL", + "params": { + "learning_rate": 0.0016064112990469893, + "batch_size": 64, + "resolution": 1024, + "model_capacity": "narrow", + "loss_weight_ratio": 0.912719707686549 + }, + "datetime_start": "2026-06-07T14:48:35.876373", + "datetime_complete": "2026-06-07T14:48:38.240824", + "duration_seconds": 2.364451, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA A100-SXM4-40GB", + "max_vram_gb": 39.4935302734375, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 48, + "trial_id": 186, + "state": "PRUNED", + "params": { + "learning_rate": 1.866719221020291e-05, + "batch_size": 4, + "resolution": 512, + "model_capacity": "narrow", + "loss_weight_ratio": 0.4256559084249397 + }, + "datetime_start": "2026-06-07T14:48:38.561165", + "datetime_complete": "2026-06-07T14:49:30.149907", + "duration_seconds": 51.588742, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.8444927109183233, + "primary_loss": 3.504164194758934, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA A100-SXM4-40GB", + "max_vram_gb": 39.4935302734375, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 49, + "trial_id": 187, + "state": "FAIL", + "params": { + "learning_rate": 7.550066161936539e-05, + "batch_size": 32, + "resolution": 1024, + "model_capacity": "wide", + "loss_weight_ratio": 0.022409146295909954 + }, + "datetime_start": "2026-06-07T14:49:31.635068", + "datetime_complete": "2026-06-07T14:49:34.006746", + "duration_seconds": 2.371678, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA A100-SXM4-40GB", + "max_vram_gb": 39.4935302734375, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 50, + "trial_id": 188, + "state": "FAIL", + "params": { + "learning_rate": 0.0053350126131481235, + "batch_size": 4, + "resolution": 1024, + "model_capacity": "wide", + "loss_weight_ratio": 0.5049364450109906 + }, + "datetime_start": "2026-06-07T14:49:34.285916", + "datetime_complete": "2026-06-07T15:12:57.731695", + "duration_seconds": 1403.445779, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 51, + "trial_id": 189, + "state": "FAIL", + "params": { + "learning_rate": 0.000777600200657905, + "batch_size": 64, + "resolution": 512, + "model_capacity": "narrow", + "loss_weight_ratio": 0.5033823758278679 + }, + "datetime_start": "2026-06-09T12:27:32.048350", + "datetime_complete": "2026-06-09T12:27:42.606962", + "duration_seconds": 10.558612, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 52, + "trial_id": 190, + "state": "FAIL", + "params": { + "learning_rate": 0.0003510810257947738, + "batch_size": 16, + "resolution": 1024, + "model_capacity": "narrow", + "loss_weight_ratio": 0.3055155227639622 + }, + "datetime_start": "2026-06-09T12:27:43.506462", + "datetime_complete": "2026-06-09T12:27:46.401631", + "duration_seconds": 2.895169, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 53, + "trial_id": 191, + "state": "FAIL", + "params": { + "learning_rate": 0.00029149980386619276, + "batch_size": 8, + "resolution": 1024, + "model_capacity": "narrow", + "loss_weight_ratio": 0.9024384903900268 + }, + "datetime_start": "2026-06-09T12:27:47.394528", + "datetime_complete": "2026-06-09T12:27:52.254843", + "duration_seconds": 4.860315, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 54, + "trial_id": 192, + "state": "FAIL", + "params": { + "learning_rate": 0.006280369628664032, + "batch_size": 32, + "resolution": 1024, + "model_capacity": "narrow", + "loss_weight_ratio": 0.7468149969124053 + }, + "datetime_start": "2026-06-09T12:27:53.012884", + "datetime_complete": "2026-06-09T12:27:56.525848", + "duration_seconds": 3.512964, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 55, + "trial_id": 193, + "state": "FAIL", + "params": { + "learning_rate": 2.1608825057250263e-05, + "batch_size": 16, + "resolution": 512, + "model_capacity": "wide", + "loss_weight_ratio": 0.8276164452067589 + }, + "datetime_start": "2026-06-09T12:27:57.553135", + "datetime_complete": "2026-06-09T12:28:01.985592", + "duration_seconds": 4.432457, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 56, + "trial_id": 194, + "state": "PRUNED", + "params": { + "learning_rate": 0.006946225010911096, + "batch_size": 2, + "resolution": 512, + "model_capacity": "wide", + "loss_weight_ratio": 0.34343077115154985 + }, + "datetime_start": "2026-06-09T12:28:03.278519", + "datetime_complete": "2026-06-09T12:31:35.254234", + "duration_seconds": 211.975715, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.8567204705534222, + "primary_loss": 3.1828140226597523, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 57, + "trial_id": 195, + "state": "FAIL", + "params": { + "learning_rate": 0.0005722650461041644, + "batch_size": 32, + "resolution": 1024, + "model_capacity": "narrow", + "loss_weight_ratio": 0.5121602362719397 + }, + "datetime_start": "2026-06-09T12:31:37.404846", + "datetime_complete": "2026-06-09T12:31:41.226259", + "duration_seconds": 3.821413, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 58, + "trial_id": 196, + "state": "PRUNED", + "params": { + "learning_rate": 0.000421080095182812, + "batch_size": 2, + "resolution": 256, + "model_capacity": "wide", + "loss_weight_ratio": 0.3569257328985008 + }, + "datetime_start": "2026-06-09T12:31:41.992999", + "datetime_complete": "2026-06-09T12:33:28.062304", + "duration_seconds": 106.069305, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.9897473019936772, + "primary_loss": 0.05817848326096052, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 59, + "trial_id": 197, + "state": "FAIL", + "params": { + "learning_rate": 0.0006999779382349623, + "batch_size": 64, + "resolution": 1024, + "model_capacity": "wide", + "loss_weight_ratio": 0.32626756206545826 + }, + "datetime_start": "2026-06-09T12:33:29.930419", + "datetime_complete": "2026-06-09T12:33:32.766389", + "duration_seconds": 2.83597, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 60, + "trial_id": 198, + "state": "FAIL", + "params": { + "learning_rate": 1.660719226277921e-05, + "batch_size": 16, + "resolution": 512, + "model_capacity": "wide", + "loss_weight_ratio": 0.7440646190555177 + }, + "datetime_start": "2026-06-09T12:33:33.487153", + "datetime_complete": "2026-06-09T12:33:37.287858", + "duration_seconds": 3.800705, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 61, + "trial_id": 199, + "state": "FAIL", + "params": { + "learning_rate": 1.6136763657195547e-05, + "batch_size": 16, + "resolution": 1024, + "model_capacity": "narrow", + "loss_weight_ratio": 0.7194931228626711 + }, + "datetime_start": "2026-06-09T12:33:38.041676", + "datetime_complete": "2026-06-09T12:33:40.859672", + "duration_seconds": 2.817996, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 62, + "trial_id": 200, + "state": "FAIL", + "params": { + "learning_rate": 0.00644351011993277, + "batch_size": 64, + "resolution": 512, + "model_capacity": "narrow", + "loss_weight_ratio": 0.9831121443518326 + }, + "datetime_start": "2026-06-09T12:33:41.664483", + "datetime_complete": "2026-06-09T12:33:45.502201", + "duration_seconds": 3.837718, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 63, + "trial_id": 201, + "state": "FAIL", + "params": { + "learning_rate": 8.015086163668646e-05, + "batch_size": 16, + "resolution": 512, + "model_capacity": "narrow", + "loss_weight_ratio": 0.6247208153386133 + }, + "datetime_start": "2026-06-09T12:39:52.680426", + "datetime_complete": "2026-06-09T12:42:09.573070", + "duration_seconds": 136.892644, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 64, + "trial_id": 202, + "state": "FAIL", + "params": { + "learning_rate": 0.00010798109091729242, + "batch_size": 4, + "resolution": 1024, + "model_capacity": "narrow", + "loss_weight_ratio": 0.020768120149031843 + }, + "datetime_start": "2026-06-09T12:42:09.579066", + "datetime_complete": "2026-06-09T12:44:14.995999", + "duration_seconds": 125.416933, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 65, + "trial_id": 203, + "state": "PRUNED", + "params": { + "learning_rate": 1.0514744422959588e-05, + "batch_size": 2, + "resolution": 512, + "model_capacity": "narrow", + "loss_weight_ratio": 0.11626061471968552 + }, + "datetime_start": "2026-06-09T12:58:08.785871", + "datetime_complete": "2026-06-09T12:59:45.650142", + "duration_seconds": 96.864271, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.8570720076452774, + "primary_loss": 3.444088376524076, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 66, + "trial_id": 204, + "state": "COMPLETE", + "params": { + "learning_rate": 0.0034464570787237354, + "batch_size": 4, + "resolution": 1024, + "model_capacity": "narrow", + "loss_weight_ratio": 0.6326231660481053 + }, + "datetime_start": "2026-06-09T12:59:48.671857", + "datetime_complete": "2026-06-09T13:22:07.632095", + "duration_seconds": 1338.960238, + "optuna_loss": 3.171118880123026, + "optuna_score": 0.8557610089292585, + "epoch_reached": 15, + "primary_score": 0.8557610089292585, + "primary_loss": 3.171118880123026, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 67, + "trial_id": 205, + "state": "PRUNED", + "params": { + "learning_rate": 0.000655602631496065, + "batch_size": 2, + "resolution": 512, + "model_capacity": "narrow", + "loss_weight_ratio": 0.6165970770863961 + }, + "datetime_start": "2026-06-09T13:22:09.164919", + "datetime_complete": "2026-06-09T13:23:44.873486", + "duration_seconds": 95.708567, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.8502720038631986, + "primary_loss": 3.1791283390190026, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 68, + "trial_id": 206, + "state": "PRUNED", + "params": { + "learning_rate": 0.004488241212661547, + "batch_size": 2, + "resolution": 1024, + "model_capacity": "narrow", + "loss_weight_ratio": 0.6947912315068989 + }, + "datetime_start": "2026-06-09T13:23:47.124323", + "datetime_complete": "2026-06-09T13:30:50.154021", + "duration_seconds": 423.029698, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.856514060787977, + "primary_loss": 3.1795108579885105, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 69, + "trial_id": 207, + "state": "PRUNED", + "params": { + "learning_rate": 0.001526069664046176, + "batch_size": 4, + "resolution": 512, + "model_capacity": "wide", + "loss_weight_ratio": 0.33976780125584527 + }, + "datetime_start": "2026-06-09T13:30:57.204234", + "datetime_complete": "2026-06-09T13:34:34.567629", + "duration_seconds": 217.363395, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.8513724619433332, + "primary_loss": 3.180013144569558, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 70, + "trial_id": 208, + "state": "FAIL", + "params": { + "learning_rate": 8.036481706507076e-05, + "batch_size": 8, + "resolution": 1024, + "model_capacity": "wide", + "loss_weight_ratio": 0.3172719209651951 + }, + "datetime_start": "2026-06-09T13:34:37.873728", + "datetime_complete": "2026-06-09T13:34:42.152489", + "duration_seconds": 4.278761, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 71, + "trial_id": 209, + "state": "PRUNED", + "params": { + "learning_rate": 2.0588378463008213e-05, + "batch_size": 2, + "resolution": 256, + "model_capacity": "narrow", + "loss_weight_ratio": 0.5418362964684905 + }, + "datetime_start": "2026-06-09T13:34:43.516880", + "datetime_complete": "2026-06-09T13:35:45.401603", + "duration_seconds": 61.884723, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.9735566368410664, + "primary_loss": 0.41913800601717793, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 72, + "trial_id": 210, + "state": "PRUNED", + "params": { + "learning_rate": 2.5824788703582884e-05, + "batch_size": 32, + "resolution": 256, + "model_capacity": "wide", + "loss_weight_ratio": 0.1001732434526551 + }, + "datetime_start": "2026-06-09T13:35:48.655548", + "datetime_complete": "2026-06-09T13:37:33.419525", + "duration_seconds": 104.763977, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.9432637325874148, + "primary_loss": 0.5283325079372664, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 73, + "trial_id": 211, + "state": "FAIL", + "params": { + "learning_rate": 0.0012503262438584753, + "batch_size": 64, + "resolution": 256, + "model_capacity": "wide", + "loss_weight_ratio": 0.36453885264546326 + }, + "datetime_start": "2026-06-09T13:37:36.288939", + "datetime_complete": "2026-06-09T13:37:42.360303", + "duration_seconds": 6.071364, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 74, + "trial_id": 212, + "state": "FAIL", + "params": { + "learning_rate": 0.00015582382174458584, + "batch_size": 8, + "resolution": 1024, + "model_capacity": "wide", + "loss_weight_ratio": 0.5578577099740122 + }, + "datetime_start": "2026-06-09T13:37:43.645381", + "datetime_complete": "2026-06-09T13:37:47.480475", + "duration_seconds": 3.835094, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 75, + "trial_id": 213, + "state": "FAIL", + "params": { + "learning_rate": 0.00044334124591300603, + "batch_size": 32, + "resolution": 512, + "model_capacity": "wide", + "loss_weight_ratio": 0.5292311853371128 + }, + "datetime_start": "2026-06-09T13:37:49.004989", + "datetime_complete": "2026-06-09T13:37:52.819199", + "duration_seconds": 3.81421, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 76, + "trial_id": 214, + "state": "FAIL", + "params": { + "learning_rate": 1.223245529632777e-05, + "batch_size": 4, + "resolution": 1024, + "model_capacity": "wide", + "loss_weight_ratio": 0.3226911430366707 + }, + "datetime_start": "2026-06-09T13:37:53.719425", + "datetime_complete": "2026-06-09T13:37:57.511614", + "duration_seconds": 3.792189, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 77, + "trial_id": 215, + "state": "FAIL", + "params": { + "learning_rate": 0.004344956027911343, + "batch_size": 16, + "resolution": 256, + "model_capacity": "narrow", + "loss_weight_ratio": 0.1358767915932685 + }, + "datetime_start": "2026-06-11T13:55:37.316306", + "datetime_complete": "2026-06-11T13:56:00.102813", + "duration_seconds": 22.786507, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": false, + "failure_tag": "DIVERGED", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 78, + "trial_id": 216, + "state": "FAIL", + "params": { + "learning_rate": 3.2979458231192225e-05, + "batch_size": 64, + "resolution": 1024, + "model_capacity": "wide", + "loss_weight_ratio": 0.6172976418982675 + }, + "datetime_start": "2026-06-11T13:56:01.980573", + "datetime_complete": "2026-06-11T13:56:04.807001", + "duration_seconds": 2.826428, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 79, + "trial_id": 217, + "state": "FAIL", + "params": { + "learning_rate": 0.0011426478938092145, + "batch_size": 8, + "resolution": 512, + "model_capacity": "narrow", + "loss_weight_ratio": 0.28562228694664504 + }, + "datetime_start": "2026-06-11T13:56:05.919390", + "datetime_complete": "2026-06-11T13:56:29.007012", + "duration_seconds": 23.087622, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": false, + "failure_tag": "DIVERGED", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 80, + "trial_id": 218, + "state": "FAIL", + "params": { + "learning_rate": 0.007871157298782676, + "batch_size": 16, + "resolution": 256, + "model_capacity": "narrow", + "loss_weight_ratio": 0.8290396014049095 + }, + "datetime_start": "2026-06-11T13:56:29.728292", + "datetime_complete": "2026-06-11T13:56:43.485919", + "duration_seconds": 13.757627, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": false, + "failure_tag": "DIVERGED", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 81, + "trial_id": 219, + "state": "FAIL", + "params": { + "learning_rate": 0.0002372784275397014, + "batch_size": 2, + "resolution": 1024, + "model_capacity": "narrow", + "loss_weight_ratio": 0.23184013756127309 + }, + "datetime_start": "2026-06-11T13:56:44.586990", + "datetime_complete": "2026-06-11T14:11:41.460615", + "duration_seconds": 896.873625, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 82, + "trial_id": 220, + "state": "FAIL", + "params": { + "learning_rate": 2.922133857069148e-05, + "batch_size": 8, + "resolution": 1024, + "model_capacity": "narrow", + "loss_weight_ratio": 0.23999846195349261 + }, + "datetime_start": "2026-06-11T13:58:13.608825", + "datetime_complete": "2026-06-11T13:58:17.422688", + "duration_seconds": 3.813863, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 83, + "trial_id": 221, + "state": "FAIL", + "params": { + "learning_rate": 1.0827083945741795e-05, + "batch_size": 32, + "resolution": 256, + "model_capacity": "wide", + "loss_weight_ratio": 0.9421656316488691 + }, + "datetime_start": "2026-06-11T13:58:20.165570", + "datetime_complete": "2026-06-11T13:58:45.518922", + "duration_seconds": 25.353352, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": false, + "failure_tag": "DIVERGED", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 84, + "trial_id": 222, + "state": "FAIL", + "params": { + "learning_rate": 0.0013651455341181428, + "batch_size": 8, + "resolution": 1024, + "model_capacity": "narrow", + "loss_weight_ratio": 0.04946229416405579 + }, + "datetime_start": "2026-06-11T13:58:46.714834", + "datetime_complete": "2026-06-11T13:58:52.615385", + "duration_seconds": 5.900551, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 85, + "trial_id": 223, + "state": "FAIL", + "params": { + "learning_rate": 0.0014649725391093422, + "batch_size": 2, + "resolution": 256, + "model_capacity": "narrow", + "loss_weight_ratio": 0.37873331580397984 + }, + "datetime_start": "2026-06-11T13:58:53.699844", + "datetime_complete": "2026-06-11T13:59:07.562627", + "duration_seconds": 13.862783, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": false, + "failure_tag": "DIVERGED", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 86, + "trial_id": 224, + "state": "FAIL", + "params": { + "learning_rate": 0.003206537784664529, + "batch_size": 8, + "resolution": 256, + "model_capacity": "narrow", + "loss_weight_ratio": 0.3470651170360838 + }, + "datetime_start": "2026-06-11T13:59:10.192695", + "datetime_complete": "2026-06-11T13:59:27.999212", + "duration_seconds": 17.806517, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": false, + "failure_tag": "DIVERGED", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 87, + "trial_id": 225, + "state": "FAIL", + "params": { + "learning_rate": 5.02454787833256e-05, + "batch_size": 64, + "resolution": 1024, + "model_capacity": "narrow", + "loss_weight_ratio": 0.6253427203524324 + }, + "datetime_start": "2026-06-11T13:59:30.460971", + "datetime_complete": "2026-06-11T13:59:35.019583", + "duration_seconds": 4.558612, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 88, + "trial_id": 226, + "state": "FAIL", + "params": { + "learning_rate": 0.001075351250772297, + "batch_size": 32, + "resolution": 512, + "model_capacity": "wide", + "loss_weight_ratio": 0.5672267142760922 + }, + "datetime_start": "2026-06-11T13:59:35.874493", + "datetime_complete": "2026-06-11T13:59:39.953464", + "duration_seconds": 4.078971, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 89, + "trial_id": 227, + "state": "FAIL", + "params": { + "learning_rate": 0.001655021881946475, + "batch_size": 16, + "resolution": 1024, + "model_capacity": "narrow", + "loss_weight_ratio": 0.7220736783251582 + }, + "datetime_start": "2026-06-11T13:59:41.214776", + "datetime_complete": "2026-06-11T13:59:45.710518", + "duration_seconds": 4.495742, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 90, + "trial_id": 228, + "state": "FAIL", + "params": { + "learning_rate": 2.4095794895938903e-05, + "batch_size": 4, + "resolution": 1024, + "model_capacity": "narrow", + "loss_weight_ratio": 0.9722243737456475 + }, + "datetime_start": "2026-06-11T13:59:48.610190", + "datetime_complete": "2026-06-11T14:11:41.465213", + "duration_seconds": 712.855023, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": null, + "primary_score": null, + "primary_loss": null, + "oom_triggered": null, + "failure_tag": null, + "gpu_model": null, + "max_vram_gb": null, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 91, + "trial_id": 229, + "state": "FAIL", + "params": { + "learning_rate": 1.2631887352189931e-05, + "batch_size": 64, + "resolution": 1024, + "model_capacity": "wide", + "loss_weight_ratio": 0.6756570515061726 + }, + "datetime_start": "2026-06-11T14:11:17.852541", + "datetime_complete": "2026-06-11T14:11:19.258056", + "duration_seconds": 1.405515, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 92, + "trial_id": 230, + "state": "PRUNED", + "params": { + "learning_rate": 0.0005469877422305655, + "batch_size": 32, + "resolution": 256, + "model_capacity": "narrow", + "loss_weight_ratio": 0.04014802323930022 + }, + "datetime_start": "2026-06-11T14:11:20.269944", + "datetime_complete": "2026-06-11T14:12:23.208958", + "duration_seconds": 62.939014, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.9669699647951362, + "primary_loss": 0.6271721638959168, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 93, + "trial_id": 231, + "state": "PRUNED", + "params": { + "learning_rate": 2.456301968536136e-05, + "batch_size": 64, + "resolution": 256, + "model_capacity": "narrow", + "loss_weight_ratio": 0.518993915804978 + }, + "datetime_start": "2026-06-11T14:12:25.707012", + "datetime_complete": "2026-06-11T14:13:24.007531", + "duration_seconds": 58.300519, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.6370085282990985, + "primary_loss": 0.6926188302945487, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 94, + "trial_id": 232, + "state": "PRUNED", + "params": { + "learning_rate": 0.0016827718590978113, + "batch_size": 2, + "resolution": 512, + "model_capacity": "narrow", + "loss_weight_ratio": 0.3901213232480468 + }, + "datetime_start": "2026-06-11T14:13:27.009462", + "datetime_complete": "2026-06-11T14:15:15.158395", + "duration_seconds": 108.148933, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.8561622114054467, + "primary_loss": 3.172988403698563, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 95, + "trial_id": 233, + "state": "FAIL", + "params": { + "learning_rate": 0.00042245361689695283, + "batch_size": 64, + "resolution": 512, + "model_capacity": "narrow", + "loss_weight_ratio": 0.09698161427820617 + }, + "datetime_start": "2026-06-11T14:15:19.053044", + "datetime_complete": "2026-06-11T14:15:21.502024", + "duration_seconds": 2.44898, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 96, + "trial_id": 234, + "state": "PRUNED", + "params": { + "learning_rate": 0.0010085301314243155, + "batch_size": 4, + "resolution": 256, + "model_capacity": "wide", + "loss_weight_ratio": 0.6825227303515402 + }, + "datetime_start": "2026-06-11T14:15:22.979716", + "datetime_complete": "2026-06-11T14:17:09.701119", + "duration_seconds": 106.721403, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.9848687559506278, + "primary_loss": 0.07858677170699156, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 97, + "trial_id": 235, + "state": "FAIL", + "params": { + "learning_rate": 2.7283826334314898e-05, + "batch_size": 64, + "resolution": 256, + "model_capacity": "wide", + "loss_weight_ratio": 0.29036664563960757 + }, + "datetime_start": "2026-06-11T14:17:12.341718", + "datetime_complete": "2026-06-11T14:17:16.485723", + "duration_seconds": 4.144005, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 98, + "trial_id": 236, + "state": "PRUNED", + "params": { + "learning_rate": 0.0005765481949314836, + "batch_size": 8, + "resolution": 512, + "model_capacity": "narrow", + "loss_weight_ratio": 0.7465559395680328 + }, + "datetime_start": "2026-06-11T14:17:17.619303", + "datetime_complete": "2026-06-11T14:18:59.380148", + "duration_seconds": 101.760845, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.8532715976286795, + "primary_loss": 3.296048546642191, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 99, + "trial_id": 237, + "state": "PRUNED", + "params": { + "learning_rate": 0.0013883083869791772, + "batch_size": 64, + "resolution": 256, + "model_capacity": "narrow", + "loss_weight_ratio": 0.9210913786629441 + }, + "datetime_start": "2026-06-11T14:19:03.614194", + "datetime_complete": "2026-06-11T14:20:03.443717", + "duration_seconds": 59.829523, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.8453963661432513, + "primary_loss": 1.3442585114939807, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 100, + "trial_id": 238, + "state": "FAIL", + "params": { + "learning_rate": 0.00013018682232310546, + "batch_size": 32, + "resolution": 512, + "model_capacity": "narrow", + "loss_weight_ratio": 0.9347399927933793 + }, + "datetime_start": "2026-06-11T14:20:06.042537", + "datetime_complete": "2026-06-11T14:20:08.893357", + "duration_seconds": 2.85082, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 101, + "trial_id": 239, + "state": "PRUNED", + "params": { + "learning_rate": 1.0172045518922102e-05, + "batch_size": 8, + "resolution": 512, + "model_capacity": "wide", + "loss_weight_ratio": 0.005662841475867597 + }, + "datetime_start": "2026-06-11T14:20:09.560118", + "datetime_complete": "2026-06-11T14:23:51.931933", + "duration_seconds": 222.371815, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 5, + "primary_score": 0.8545924746829634, + "primary_loss": 3.441398318809799, + "oom_triggered": false, + "failure_tag": null, + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + }, + { + "trial_number": 102, + "trial_id": 240, + "state": "FAIL", + "params": { + "learning_rate": 0.00010302359944750134, + "batch_size": 4, + "resolution": 1024, + "model_capacity": "wide", + "loss_weight_ratio": 0.7977654326079116 + }, + "datetime_start": "2026-06-11T14:23:53.527265", + "datetime_complete": "2026-06-11T14:23:55.897358", + "duration_seconds": 2.370093, + "optuna_loss": null, + "optuna_score": null, + "epoch_reached": 0, + "primary_score": 0.0, + "primary_loss": 999.0, + "oom_triggered": true, + "failure_tag": "OOM", + "gpu_model": "NVIDIA L4", + "max_vram_gb": 22.0343017578125, + "health_tier": null, + "health_reason": null, + "git_commit": null, + "dataset_version": null + } +] \ No newline at end of file diff --git a/archive/extract_bridge_crack_study.py b/archive/extract_bridge_crack_study.py new file mode 100644 index 0000000..251f23f --- /dev/null +++ b/archive/extract_bridge_crack_study.py @@ -0,0 +1,117 @@ +import optuna +import json +import os +import datetime +from sqlalchemy.orm import Session +from src.db_manager import engine, SessionLocal +from src.schema import TrialResult + +def extract_study_data(): + study_name = "bridge_crack_study" + print(f"Loading Optuna study '{study_name}'...") + + # 1. Load the Optuna study + db_url = os.getenv("HPO_DATABASE_URL", "sqlite:///hpo_studies.db") + try: + study = optuna.load_study(study_name=study_name, storage=db_url) + except KeyError: + print(f"Error: Study '{study_name}' not found in database.") + return + + print(f"Loading Pathfinder-specific trial results for '{study_name}'...") + # 2. Query Pathfinder TrialResult metadata from the DB + pathfinder_results = {} + with SessionLocal() as session: + results = session.query(TrialResult).filter_by(study_name=study_name).all() + for r in results: + pathfinder_results[r.trial_id] = r.to_dict() + + print(f"Aggregating {len(study.trials)} trials...") + # 3. Combine Optuna trials with Pathfinder trial results + all_trials_data = [] + + for t in study.trials: + # Get basic Optuna trial info + trial_number = t.number + trial_id = t._trial_id + state = t.state.name + params = t.params + datetime_start = t.datetime_start.isoformat() if t.datetime_start else None + datetime_complete = t.datetime_complete.isoformat() if t.datetime_complete else None + duration = t.duration.total_seconds() if t.duration else None + values = t.values # This is a list/tuple of objective values + + # Primary score and loss from values or parameters + # In multi-objective: direction minimize (loss/obj 0), maximize (score/obj 1) + optuna_loss = values[0] if values and len(values) > 0 else None + optuna_score = values[1] if values and len(values) > 1 else None + + # Get Pathfinder metadata if available using _trial_id + pf_info = pathfinder_results.get(trial_id, {}) + + # Merge data + trial_record = { + "trial_number": trial_number, + "trial_id": trial_id, + "state": state, + "params": params, + "datetime_start": datetime_start, + "datetime_complete": datetime_complete, + "duration_seconds": duration, + "optuna_loss": optuna_loss, + "optuna_score": optuna_score, + "epoch_reached": pf_info.get("epoch_reached"), + "primary_score": pf_info.get("primary_score"), + "primary_loss": pf_info.get("primary_loss"), + "oom_triggered": pf_info.get("oom_triggered"), + "failure_tag": pf_info.get("failure_tag"), + "gpu_model": pf_info.get("gpu_model"), + "max_vram_gb": pf_info.get("max_vram_gb"), + "health_tier": pf_info.get("health_tier"), + "health_reason": pf_info.get("health_reason"), + "git_commit": pf_info.get("git_commit"), + "dataset_version": pf_info.get("dataset_version"), + } + all_trials_data.append(trial_record) + + # Sort by trial_number + all_trials_data.sort(key=lambda x: x["trial_number"]) + + # Save to JSON + output_path = "bridge_crack_study_trials.json" + with open(output_path, "w") as f: + json.dump(all_trials_data, f, indent=2) + print(f"Successfully saved all trials data to '{output_path}'.") + + # Filter completed trials to show progress + completed_trials = [t for t in all_trials_data if t["state"] == "COMPLETE"] + print(f"Total trials: {len(all_trials_data)}") + print(f"Completed trials: {len(completed_trials)}") + + if not completed_trials: + print("No completed trials found to evaluate improvement.") + return + + # Find start trials vs best trials + first_completed = completed_trials[:3] + best_completed = sorted(completed_trials, key=lambda x: x["primary_score"] or 0, reverse=True)[:3] + + print("\n--- FIRST COMPLETED TRIALS ---") + for t in first_completed: + print(f"Trial #{t['trial_number']} (ID={t['trial_id']}): Score={t['primary_score']}, Loss={t['primary_loss']}, Params={t['params']}") + + print("\n--- BEST COMPLETED TRIALS ---") + for t in best_completed: + print(f"Trial #{t['trial_number']} (ID={t['trial_id']}): Score={t['primary_score']}, Loss={t['primary_loss']}, Params={t['params']}") + + initial_score = first_completed[0]['primary_score'] if first_completed else None + best_score = best_completed[0]['primary_score'] if best_completed else None + + if initial_score is not None and best_score is not None: + diff = best_score - initial_score + print(f"\nImprovement in Best Score: {initial_score} -> {best_score} (Gain: +{diff:.6f})") + else: + print("\nCould not calculate improvement due to missing scores.") + +if __name__ == "__main__": + extract_study_data() diff --git a/archive/extract_to_csv.py b/archive/extract_to_csv.py new file mode 100644 index 0000000..47c194f --- /dev/null +++ b/archive/extract_to_csv.py @@ -0,0 +1,99 @@ +import optuna +import csv +import os +from sqlalchemy.orm import Session +from src.db_manager import SessionLocal +from src.schema import TrialResult + +def extract_filtered_csv(): + study_name = "bridge_crack_study" + print(f"Loading study '{study_name}'...") + + db_url = os.getenv("HPO_DATABASE_URL", "sqlite:///hpo_studies.db") + try: + study = optuna.load_study(study_name=study_name, storage=db_url) + except KeyError: + print(f"Error: Study '{study_name}' not found.") + return + + # Load Pathfinder metadata + pathfinder_results = {} + with SessionLocal() as session: + results = session.query(TrialResult).filter_by(study_name=study_name).all() + for r in results: + pathfinder_results[r.trial_id] = r.to_dict() + + # Filter and flatten completed trials with resolution >= 500 + rows = [] + headers = [ + "trial_number", "trial_id", "state", "score", "loss", + "duration_seconds", "epoch_reached", "learning_rate", + "batch_size", "resolution", "encoder_name", "loss_weight_ratio", + "model_capacity", "gpu_model", "max_vram_gb" + ] + + for t in study.trials: + if t.state.name != "COMPLETE": + continue + + params = t.params + res = params.get("resolution") + if res is None or res < 500: + continue + + trial_id = t._trial_id + pf_info = pathfinder_results.get(trial_id, {}) + + # Primary score and loss from Optuna values or Pathfinder + values = t.values + optuna_loss = values[0] if values and len(values) > 0 else None + optuna_score = values[1] if values and len(values) > 1 else None + + score = pf_info.get("primary_score") or optuna_score + loss = pf_info.get("primary_loss") or optuna_loss + + row = { + "trial_number": t.number, + "trial_id": trial_id, + "state": t.state.name, + "score": score, + "loss": loss, + "duration_seconds": t.duration.total_seconds() if t.duration else None, + "epoch_reached": pf_info.get("epoch_reached"), + "learning_rate": params.get("learning_rate"), + "batch_size": params.get("batch_size"), + "resolution": res, + "encoder_name": params.get("encoder_name"), + "loss_weight_ratio": params.get("loss_weight_ratio"), + "model_capacity": params.get("model_capacity", "N/A"), + "gpu_model": pf_info.get("gpu_model"), + "max_vram_gb": pf_info.get("max_vram_gb") + } + rows.append(row) + + # Sort by trial number + rows.sort(key=lambda x: x["trial_number"]) + + # Save to CSV + csv_file = "bridge_crack_study_500px.csv" + with open(csv_file, mode="w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=headers) + writer.writeheader() + for r in rows: + writer.writerow(r) + + print(f"Successfully extracted {len(rows)} trials to '{csv_file}'.") + + if len(rows) > 0: + first = rows[0] + best = max(rows, key=lambda x: x["score"] if x["score"] is not None else 0) + print("\n--- RESULTS OVERVIEW (Resolution >= 500px) ---") + print(f"First Trial #{first['trial_number']}: Score={first['score']}, Loss={first['loss']}, Params={first['learning_rate'], first['batch_size'], first['resolution']}") + print(f"Best Trial #{best['trial_number']}: Score={best['score']}, Loss={best['loss']}, Params={best['learning_rate'], best['batch_size'], best['resolution']}") + if first['score'] is not None and best['score'] is not None: + print(f"Improvement: {first['score']} -> {best['score']} (Gain: +{best['score'] - first['score']:.6f})") + else: + print("No completed trials matching criteria found.") + +if __name__ == "__main__": + extract_filtered_csv() diff --git a/archive/plot_results.py b/archive/plot_results.py new file mode 100644 index 0000000..efa3168 --- /dev/null +++ b/archive/plot_results.py @@ -0,0 +1,93 @@ +import os +import sys + +# Ensure pandas and matplotlib are installed +try: + import pandas as pd + import matplotlib.pyplot as plt +except ImportError: + print("Required packages (pandas, matplotlib) are missing.") + print("Installing packages...") + import subprocess + subprocess.check_call([sys.executable, "-m", "pip", "install", "pandas", "matplotlib"]) + import pandas as pd + import matplotlib.pyplot as plt + +def plot_study_results(): + csv_file = "bridge_crack_study_500px.csv" + if not os.path.exists(csv_file): + print(f"Error: {csv_file} not found. Run extract_to_csv.py first.") + return + + # Load data + df = pd.read_csv(csv_file) + print("Loaded data:") + print(df[["trial_number", "score", "loss", "learning_rate", "batch_size", "resolution"]]) + + if len(df) == 0: + print("No completed trials to plot.") + return + + # Modern styling + plt.style.use("seaborn-v0_8-whitegrid" if "seaborn-v0_8-whitegrid" in plt.style.available else "default") + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 6)) + + # Color definitions + accent_color = "#3B82F6" # Premium Indigo/Blue + highlight_color = "#10B981" # Emerald Green for best + neutral_dark = "#1F2937" + + # Plot 1: Score Progression over completed trials + ax1.plot(df["trial_number"], df["score"], marker="o", color=accent_color, linewidth=2, markersize=8, label="Dice Score") + + # Highlight best trial + best_idx = df["score"].idxmax() + best_trial = df.loc[best_idx] + ax1.scatter(best_trial["trial_number"], best_trial["score"], color=highlight_color, s=200, zorder=5, label=f"Best (Trial #{int(best_trial['trial_number'])}: {best_trial['score']:.4f})") + + # Labels and Titles + ax1.set_title("Dice Score Progression (Resolution >= 500px)", fontsize=14, fontweight="bold", pad=15, color=neutral_dark) + ax1.set_xlabel("Optuna Trial Number", fontsize=12, labelpad=10) + ax1.set_ylabel("Dice Score (Higher is Better)", fontsize=12, labelpad=10) + ax1.legend(loc="lower right", frameon=True, facecolor="white", edgecolor="#E5E7EB") + ax1.set_ylim(df["score"].min() - 0.005, df["score"].max() + 0.005) + + # Plot 2: Learning Rate vs Score colored by Batch Size + scatter = ax2.scatter( + df["learning_rate"], + df["score"], + c=df["batch_size"], + cmap="viridis", + s=120, + edgecolors="none", + alpha=0.85 + ) + # Highlight best trial in scatter + ax2.scatter( + best_trial["learning_rate"], + best_trial["score"], + color=highlight_color, + edgecolors="black", + s=250, + zorder=5, + label="Best Model" + ) + + ax2.set_xscale("log") + ax2.set_title("Learning Rate vs. Score (Size: Batch Size)", fontsize=14, fontweight="bold", pad=15, color=neutral_dark) + ax2.set_xlabel("Learning Rate (Log Scale)", fontsize=12, labelpad=10) + ax2.set_ylabel("Dice Score", fontsize=12, labelpad=10) + + # Colorbar for Batch Size + cbar = plt.colorbar(scatter, ax=ax2) + cbar.set_label("Batch Size", fontsize=11, rotation=270, labelpad=15) + ax2.legend(loc="lower left", frameon=True, facecolor="white", edgecolor="#E5E7EB") + + plt.tight_layout() + plot_path = "bridge_crack_500px_plots.png" + plt.savefig(plot_path, dpi=300, facecolor="white") + print(f"Successfully generated and saved plots to '{plot_path}'.") + plt.close() + +if __name__ == "__main__": + plot_study_results() diff --git a/docker-compose.yml b/docker-compose.yml index 1b295a8..20dfa05 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,10 +2,13 @@ services: broker: build: . + restart: unless-stopped ports: - "8000:8000" - volumes: - - ./hpo_studies.db:/app/hpo_studies.db environment: - HPO_DATABASE_URL=sqlite:///hpo_studies.db - HPO_DEBUG=0 + # NOTE: the database is ephemeral by default (inside the container). + # For persistent storage, mount a volume at /app and pre-create hpo_studies.db: + # volumes: + # - ./data:/app diff --git a/pyproject.toml b/pyproject.toml index 9301dfc..5e013df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,18 +20,24 @@ classifiers = [ dependencies = [ "mcp>=1.1,<2", "optuna>=3.6,<5", - "optuna-dashboard>=0.15,<1", "sqlalchemy>=2.0,<3", "pydantic>=2.0,<3", "numpy>=1.20,<3", - "scikit-learn>=1.0,<2", "fastapi>=0.110,<1", "uvicorn>=0.23,<1", "requests>=2.28,<3", "PyYAML>=6.0,<7", ] +[tool.pytest.ini_options] +addopts = "-q" +testpaths = ["tests"] +filterwarnings = [ + "ignore::DeprecationWarning:optuna.*", + "ignore::DeprecationWarning:sqlalchemy.*", +] + [project.optional-dependencies] dev = [ "pytest>=8.0", diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 77f8fc4..0000000 --- a/pytest.ini +++ /dev/null @@ -1,7 +0,0 @@ -[pytest] -# Keep test output readable; tests are fast and DB-backed on a temp SQLite file. -addopts = -q -testpaths = tests -filterwarnings = - # Optuna + SQLAlchemy + stdlib still emit utcnow() deprecations on py3.12+; don't fail on them. - ignore::DeprecationWarning diff --git a/requirements-dev.txt b/requirements-dev.txt index 02f3772..3b41a5b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,4 @@ # Development / test dependencies (not needed at runtime). -r requirements.txt pytest>=8.0 +ruff>=0.4 diff --git a/requirements.txt b/requirements.txt index 2bfe0fa..1e6aa10 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,12 +2,10 @@ # while still allowing security/patch updates. Dev/test extras live in requirements-dev.txt. mcp>=1.1,<2 optuna>=3.6,<5 -optuna-dashboard>=0.15,<1 sqlalchemy>=2.0,<3 pydantic>=2.0,<3 numpy>=1.20,<3 -scikit-learn>=1.0,<2 fastapi>=0.110,<1 uvicorn>=0.23,<1 requests>=2.28,<3 diff --git a/src/reporting.py b/src/reporting.py index c6d73cd..06fcb74 100644 --- a/src/reporting.py +++ b/src/reporting.py @@ -6,10 +6,10 @@ from optuna.trial import TrialState from src.db_manager import get_db_session, get_or_create_study_status -from src.schema import TrialResult, AgentReasoningLog, StudyStatus, TrialLease +from src.schema import TrialResult from src.hpo_config import load_hpo_config from src.metrics import get_score, get_loss, loss_objective_index, score_objective_index, TERMINAL_STATES, has_invalid_metrics -from src.hpo_coordinator import compute_health_tier, write_ide_status_file, backfill_review_outcomes +from src.health import compute_health_tier, write_ide_status_file from src.leases import _lease_is_owned, delete_lease_by_trial_id from src.pruning import _epoch_composite_score, _pruning_peer_trials from src.suggest import load_study @@ -149,8 +149,6 @@ def handle_api_report_epoch(req: ReportEpochRequest): # Save user attributes for real-time dashboard monitoring study._storage.set_trial_user_attr(trial_obj._trial_id, "latest_score", final_score) study._storage.set_trial_user_attr(trial_obj._trial_id, "latest_loss", final_loss) - study._storage.set_trial_user_attr(trial_obj._trial_id, "latest_dice", final_score) - study._storage.set_trial_user_attr(trial_obj._trial_id, "latest_bce", final_loss) study._storage.set_trial_user_attr(trial_obj._trial_id, "latest_epoch", req.epoch) study._storage.set_trial_user_attr(trial_obj._trial_id, "gpu_memory", req.gpu_memory) study._storage.set_trial_user_attr(trial_obj._trial_id, "speed_ips", req.speed_ips) @@ -159,14 +157,14 @@ def handle_api_report_epoch(req: ReportEpochRequest): ev = hpo_config.get("eval_protocol", {}) if final_score_fixed is not None: study._storage.set_trial_user_attr( - trial_obj._trial_id, ev.get("fixed_dice_attr", "dice_eval_fixed"), final_score_fixed + trial_obj._trial_id, ev.get("fixed_score_attr", "score_eval_fixed"), final_score_fixed ) study._storage.set_trial_user_attr( trial_obj._trial_id, "score_eval_fixed", final_score_fixed ) if final_loss_fixed is not None: study._storage.set_trial_user_attr( - trial_obj._trial_id, ev.get("fixed_bce_attr", "bce_eval_fixed"), final_loss_fixed + trial_obj._trial_id, ev.get("fixed_loss_attr", "loss_eval_fixed"), final_loss_fixed ) study._storage.set_trial_user_attr( trial_obj._trial_id, "loss_eval_fixed", final_loss_fixed @@ -178,15 +176,11 @@ def handle_api_report_epoch(req: ReportEpochRequest): "epoch": req.epoch, "score": final_score, "loss": final_loss, - "dice": final_score, - "bce": final_loss } if final_score_fixed is not None: epoch_entry["score_eval_fixed"] = final_score_fixed - epoch_entry["dice_eval_fixed"] = final_score_fixed if final_loss_fixed is not None: epoch_entry["loss_eval_fixed"] = final_loss_fixed - epoch_entry["bce_eval_fixed"] = final_loss_fixed history.append(epoch_entry) study._storage.set_trial_user_attr(trial_obj._trial_id, "history", history) @@ -310,12 +304,15 @@ def handle_api_complete_trial(req: CompleteTrialRequest): detail="Rejecting complete: trial reported 0.0 for both score and loss. Likely training did not run.", ) - invalid_metric = has_invalid_metrics(score=final_score, loss=final_loss, score_eval_fixed=final_score_fixed, loss_eval_fixed=final_loss_fixed) - if invalid_metric: - raise HTTPException( - status_code=400, - detail=f"Rejecting complete: {invalid_metric} is NaN or Inf, which is invalid.", - ) + # NaN/Inf rejection only for COMPLETE state β€” FAIL trials must pass through + # so the failure-tagging and health-monitoring logic below can record them. + if t_state == TrialState.COMPLETE: + invalid_metric = has_invalid_metrics(score=final_score, loss=final_loss, score_eval_fixed=final_score_fixed, loss_eval_fixed=final_loss_fixed) + if invalid_metric: + raise HTTPException( + status_code=400, + detail=f"Rejecting complete: {invalid_metric} is NaN or Inf, which is invalid.", + ) # Check trial metrics health health_tier, health_reason = check_trial_health(study, final_score, final_loss, req.history) @@ -338,14 +335,14 @@ def handle_api_complete_trial(req: CompleteTrialRequest): if final_score_fixed is not None: study._storage.set_trial_user_attr( - trial_obj._trial_id, ev.get("fixed_dice_attr", "dice_eval_fixed"), final_score_fixed + trial_obj._trial_id, ev.get("fixed_score_attr", "score_eval_fixed"), final_score_fixed ) study._storage.set_trial_user_attr( trial_obj._trial_id, "score_eval_fixed", final_score_fixed ) if final_loss_fixed is not None: study._storage.set_trial_user_attr( - trial_obj._trial_id, ev.get("fixed_bce_attr", "bce_eval_fixed"), final_loss_fixed + trial_obj._trial_id, ev.get("fixed_loss_attr", "loss_eval_fixed"), final_loss_fixed ) study._storage.set_trial_user_attr( trial_obj._trial_id, "loss_eval_fixed", final_loss_fixed @@ -443,34 +440,7 @@ def handle_api_complete_trial(req: CompleteTrialRequest): is_minimize_only = len(study.directions) == 1 and study.directions[0] == optuna.study.StudyDirection.MINIMIZE - try: - prior_trials = [t for t in study.trials if t.number < trial_obj.number and t.state == TrialState.COMPLETE] - best_prior_score = 0.0 - - if prior_trials: - if is_minimize_only: - losses = [get_loss(t, study) for t in prior_trials] - losses = [l for l in losses if l is not None] - best_prior_score = min(losses) if losses else 0.0 - else: - scores = [get_score(t, study) for t in prior_trials] - scores = [s for s in scores if s is not None] - best_prior_score = max(scores) if scores else 0.0 - if is_minimize_only: - safe_final_loss = final_loss if final_loss is not None else 0.0 - actual_improvement = best_prior_score - safe_final_loss - else: - safe_final_score = final_score if final_score is not None else 0.0 - actual_improvement = safe_final_score - best_prior_score - with get_db_session() as session: - reasoning_log = session.query(AgentReasoningLog).filter_by(trial_id=req.trial_id).first() - if reasoning_log: - reasoning_log.actual_score_improvement = actual_improvement - session.commit() - except Exception as reas_err: - print(f"Error updating reasoning logs: {reas_err}") - # Compute health tier and update study status try: health_tier, health_reason = compute_health_tier(study, req.study_name) @@ -483,11 +453,6 @@ def handle_api_complete_trial(req: CompleteTrialRequest): except Exception as err: print(f"Error updating coordinator health status: {err}") - try: - backfill_review_outcomes(req.study_name) - except Exception as bf_err: - print(f"Error backfilling review outcomes: {bf_err}") - # Fetch completed scores for sparkline completed_scores = [] for t in study.trials: @@ -505,8 +470,6 @@ def handle_api_complete_trial(req: CompleteTrialRequest): "success": True, "completed_scores": completed_scores, "best_score": best_score, - "completed_dices": completed_scores, - "best_dice": best_score, "trial_number": trial_obj.number } except HTTPException as he: diff --git a/src/tunneling.py b/src/tunneling.py index 341fcae..e83566c 100644 --- a/src/tunneling.py +++ b/src/tunneling.py @@ -1,5 +1,4 @@ import os -import sys import datetime import sqlite3 import glob @@ -82,7 +81,7 @@ def _start_ngrok(port: int, secret_token: Optional[str]) -> Optional[str]: """Spawn ngrok, wait for its agent API to report the public URL, return it.""" try: print(f"Spawning ngrok tunnel for port {port}...") - proc = subprocess.Popen( + subprocess.Popen( ["ngrok", "http", str(port)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, diff --git a/tests/conftest.py b/tests/conftest.py index 10d69b6..3ecbf8e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -60,7 +60,7 @@ def unique_study_name(): @pytest.fixture def initialized_study(unique_study_name): """Initialize a study (Optuna + config in DB) and return its name.""" - from hpo_mcp_server import initialize_study + from src.onboarding import initialize_study active_search_space = { "learning_rate": {"min": 1e-5, "max": 1e-2, "type": "float_log"}, diff --git a/tests/test_health_tier.py b/tests/test_health_tier.py index 0012bd5..8b9072f 100644 --- a/tests/test_health_tier.py +++ b/tests/test_health_tier.py @@ -26,7 +26,7 @@ def _cleanup(): from src.db_manager import init_db, get_db_session from src.schema import TrialResult, StudyStatus -from src.hpo_coordinator import compute_health_tier +from src.health import compute_health_tier class TestHealthTier(unittest.TestCase): diff --git a/tests/test_http_api.py b/tests/test_http_api.py index c6f917b..26ceb43 100644 --- a/tests/test_http_api.py +++ b/tests/test_http_api.py @@ -46,7 +46,7 @@ def test_worker_lifecycle_records_result(client, initialized_study): "score": 0.7, "loss": 0.3, "weights_path": "model.pt", - "history": [{"epoch": 2, "score": 0.7, "loss": 0.3, "dice": 0.7, "bce": 0.3}], + "history": [{"epoch": 2, "score": 0.7, "loss": 0.3}], "state": "COMPLETE", }, ) @@ -62,7 +62,7 @@ def test_worker_lifecycle_records_result(client, initialized_study): def test_delete_study_removes_optuna_and_metadata(client, initialized_study): - from hpo_mcp_server import delete_study + from src.onboarding import delete_study_internal as delete_study # Produce a trial + a TrialResult row. worker_id = str(uuid.uuid4()) @@ -70,7 +70,7 @@ def test_delete_study_removes_optuna_and_metadata(client, initialized_study): client.post("/api/complete_trial", json={ "study_name": initialized_study, "trial_id": sug["trial_id"], "worker_id": worker_id, "epoch": 1, "score": 0.6, "loss": 0.4, "weights_path": "m.pt", - "history": [{"epoch": 1, "score": 0.6, "loss": 0.4, "dice": 0.6, "bce": 0.4}], "state": "COMPLETE", + "history": [{"epoch": 1, "score": 0.6, "loss": 0.4}], "state": "COMPLETE", }) res = delete_study(initialized_study, confirm=False) @@ -124,7 +124,7 @@ def test_complete_is_idempotent(client, initialized_study): "score": 0.6, "loss": 0.4, "weights_path": "model.pt", - "history": [{"epoch": 1, "score": 0.6, "loss": 0.4, "dice": 0.6, "bce": 0.4}], + "history": [{"epoch": 1, "score": 0.6, "loss": 0.4}], "state": "COMPLETE", } first = client.post("/api/complete_trial", json=payload) diff --git a/tests/test_http_auth.py b/tests/test_http_auth.py index b835a68..1cbcf48 100644 --- a/tests/test_http_auth.py +++ b/tests/test_http_auth.py @@ -46,7 +46,7 @@ def test_complete_requires_lease_for_inflight_trial(client, initialized_study): "/api/complete_trial", json={"study_name": initialized_study, "trial_id": trial_id, "worker_id": intruder, "epoch": 1, "score": 0.6, "loss": 0.4, "weights_path": "m.pt", - "history": [{"epoch": 1, "score": 0.6, "loss": 0.4, "dice": 0.6, "bce": 0.4}], "state": "COMPLETE"}, + "history": [{"epoch": 1, "score": 0.6, "loss": 0.4}], "state": "COMPLETE"}, ) assert bad.status_code == 403 diff --git a/tests/test_integration.py b/tests/test_integration.py index 8b75a4b..68f72e8 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,366 +1,129 @@ +"""End-to-end integration test: broker startup, worker lifecycle, study packet, auth. + +The broker is started in a background thread for the duration of this test only. +Uses the same SQLite database that conftest.py configures (a temp file). +""" import os -import json import sys +import json import threading import time import requests import uvicorn -import subprocess -# 1. Clean old test database if it exists to start fresh (before database engine initialization) -if __name__ == "__main__": - db_file = "test_hpo_studies.db" - if os.path.exists(db_file): - print(f"Removing existing test database: {db_file}") - try: - os.remove(db_file) - except OSError as e: - print(f"Warning: Could not remove db file: {e}") - # Override database URL to point to a test SQLite database before imports - os.environ["HPO_DATABASE_URL"] = f"sqlite:///{db_file}" - - # Make sure workspace is in python path - sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +BROKER_PORT = 8123 +BROKER_URL = f"http://127.0.0.1:{BROKER_PORT}" + +MANIFEST_BASE = { + "study_name": "unet_crack_segmentation_test", + "metrics": { + "primary_score": "score", + "objectives": [ + {"name": "loss", "direction": "minimize", "label": "Loss"}, + {"name": "score", "direction": "maximize", "label": "Score"}, + ], + }, + "params": [ + {"name": "learning_rate", "type": "float_log", "min": 1e-5, "max": 1e-2}, + {"name": "batch_size", "type": "categorical", "options": [2, 4, 8, 16]}, + {"name": "resolution", "type": "categorical", "options": [256, 512, 1024]}, + {"name": "model_capacity", "type": "categorical", "options": ["narrow", "wide"]}, + {"name": "loss_weight_ratio", "type": "float", "min": 0.0, "max": 1.0}, + ], +} + + +def _start_broker(): + from broker import app + from src.db_manager import init_db + init_db() + uvicorn.run(app, host="127.0.0.1", port=BROKER_PORT, log_level="warning") - # Start broker in background thread - broker_port = 8123 - os.environ["HPO_BROKER_URL"] = f"http://127.0.0.1:{broker_port}" - def start_broker(): - from broker import app - uvicorn.run(app, host="127.0.0.1", port=broker_port, log_level="warning") +def test_integration(): + os.environ["HPO_BROKER_URL"] = BROKER_URL - print("Starting HTTP broker in background thread...") - broker_thread = threading.Thread(target=start_broker, daemon=True) + broker_thread = threading.Thread(target=_start_broker, daemon=True) broker_thread.start() - time.sleep(2.0) # Wait for uvicorn to bind and start + time.sleep(2.0) - # Import dependencies after environment setup - from src.db_manager import init_db, get_db_session - from src.schema import TrialResult, SystemConfiguration, StudyStatus, StudyCard - from hpo_mcp_server import ( - initialize_study, - get_study_data, - validate_search_space, - update_search_space, - generate_model_card, - submit_agent_review - ) - from simulators.training_worker import run_training_worker + study_name = MANIFEST_BASE["study_name"] + # ---- Step 1: Init study from manifest ---- + from src.onboarding import init_study_from_manifest_dict + manifest = json.loads(json.dumps(MANIFEST_BASE)) + init_study_from_manifest_dict(manifest, force=True) -def run_integration_test(): - print("==================================================") - print("STARTING PATHFINDER INTEGRATION TEST (DB-BACKED)") - print("==================================================\n") - - # Reinitialize DB tables - print("Initializing SQLite database tables...") - init_db() - - study_name = "unet_crack_segmentation_test" - active_search_space = { - "learning_rate": {"min": 1e-5, "max": 1e-2, "type": "float_log"}, - "batch_size": {"options": [2, 4, 8, 16, 32, 64], "active": [2, 4, 8, 16, 32, 64], "type": "categorical"}, - "resolution": {"options": [256, 512, 1024], "active": [256, 512, 1024], "type": "categorical"}, - "model_capacity": {"options": ["narrow", "wide"], "active": ["narrow", "wide"], "type": "categorical"}, - "loss_weight_ratio": {"min": 0.0, "max": 1.0, "type": "float"} - } - hpo_config = { - "eval_protocol": { - "enabled": True, - "fixed_resolution": 512, - "train_resolution_param": "resolution", - "fixed_dice_attr": "dice_eval_fixed", - "fixed_bce_attr": "bce_eval_fixed" - }, - "metric_score_label": "Dice", - "metric_loss_label": "BCE" - } - project_context = { - "hypothesis": "Testing U-Net segmentation models on crack images.", - "gpu_model": "NVIDIA L4", - "gpu_capacity_gb": 24.0 - } + from src.db_manager import get_db_session + from src.schema import SystemConfiguration, StudyStatus - # 1. Test Study Initialization - print("\n--- [Step 1: Initializing Study in Database] ---") - init_msg = initialize_study( - study_name=study_name, - active_search_space=active_search_space, - hpo_config=hpo_config, - project_context=project_context, - multi_objective=True - ) - print(init_msg) - - # Verify records were inserted in SystemConfiguration with get_db_session() as session: - space_row = session.query(SystemConfiguration).filter_by( + row = session.query(SystemConfiguration).filter_by( study_name=study_name, config_key="active_search_space" ).first() - assert space_row is not None, "Active search space should be stored in system_configuration!" - - status_row = session.query(StudyStatus).filter_by(study_name=study_name).first() - assert status_row is not None, "Study status should be initialized!" - assert status_row.health_tier == "healthy", "Initial health tier should be healthy!" + assert row is not None, "Search space should be persisted" + status = session.query(StudyStatus).filter_by(study_name=study_name).first() + assert status is not None, "StudyStatus should exist" - # 2. Test Search Space Pre-flight Validation - print("\n--- [Step 2: Verifying Search Space Validation] ---") - # A. Propose a valid space config - valid_val = validate_search_space(active_search_space) - print(f"Valid validation response: {valid_val}") - assert valid_val["valid"], "Search space configuration should be valid!" - - # B. Propose an invalid space config (min >= max) - invalid_space = { - "learning_rate": {"min": 1e-2, "max": 1e-5, "type": "float_log"} - } - invalid_val = validate_search_space(invalid_space) - print(f"Invalid validation response: {invalid_val}") - assert not invalid_val["valid"], "Search space validation should fail for min >= max!" - assert len(invalid_val["errors"]) > 0, "Errors should be reported!" - - # 2.5 Run a quick mock trial to satisfy trials_evaluated > 0 rule - print("\n--- [Step 2.5: Running a quick mock trial to satisfy trials_evaluated > 0] ---") + # ---- Step 2: Run a single trial via HTTP ---- from src.hpo_client import TrialSession - session = TrialSession(broker_url=f"http://127.0.0.1:{broker_port}", study_name=study_name) - trial_data = session.suggest() - session.complete(epoch=0, score=0.5, loss=0.5) - # 3. Test Manual Parameter Suggestion & Guardrails - print("\n--- [Step 3: Suggesting Next Trial with Manual Parameters] ---") - # A. Propose invalid resolution (not multiple of 32) - invalid_params_1 = { - "learning_rate": 1e-3, - "batch_size": 16, - "resolution": 500, # Invalid - "model_capacity": "narrow", - "loss_weight_ratio": 0.5 - } - print(f"Proposing invalid parameters (resolution 500): {invalid_params_1}") - res_1 = submit_agent_review( - study_name=study_name, - summary="Testing invalid resolution boundary", - health_rating=3, - policy_action="enqueue_one_manual_trial", - model_version="coordinator", - prompt_strategy="test_strategy", - estimated_score_improvement=-1.0, - cited_best_trial=0, - manual_trial=invalid_params_1, - force=True - ) - print(f"Response: {res_1}\n") - assert not res_1["success"], "Should have failed due to resolution constraints!" + sess = TrialSession(broker_url=BROKER_URL, study_name=study_name) + trial = sess.suggest() + assert "params" in trial + sess.complete(epoch=0, score=0.5, loss=0.5) - # B. Propose valid manual parameters - valid_manual = { - "learning_rate": 1e-4, - "batch_size": 8, - "resolution": 256, - "model_capacity": "narrow", - "loss_weight_ratio": 0.3 - } - print(f"Proposing valid manual parameters: {valid_manual}") - res_valid = submit_agent_review( - study_name=study_name, - summary="Starting with a reasonable base configuration", - health_rating=4, - policy_action="enqueue_one_manual_trial", - model_version="coordinator", - prompt_strategy="test_strategy", - estimated_score_improvement=0.05, - cited_best_trial=0, - manual_trial=valid_manual, - force=True - ) - print(f"Response: {res_valid}") - assert res_valid["success"], f"Should have successfully enqueued: {res_valid.get('error')}" + # ---- Step 3: Simulator worker (5 trials) ---- + from simulators.training_worker import run_training_worker - # 4. Simulate Training Worker trials - print("\n--- [Step 4: Running Decentralized Training Worker Simulation via HTTP] ---") run_training_worker( study_name=study_name, - agent_model="gemini-3.5-flash", - prompt_strategy="tpe_guided_v1", - max_trials=7, + max_trials=5, epochs_per_trial=5, - broker_url=f"http://127.0.0.1:{broker_port}" + broker_url=BROKER_URL, ) - # 5. Fetch Study Data Compacted Packet - print("\n--- [Step 5: Fetching Compacted Review Packet] ---") - packet = get_study_data(study_name=study_name) - print(f"Compacted Packet structure keys: {list(packet.keys())}") - assert "trial_bins" in packet, "Packet must contain binned trials." - assert "fanova_importances" in packet, "Packet must contain parameter importances." - assert "spearman_correlations" in packet, "Packet must contain Spearman correlations." - assert "vram_telemetry" in packet, "Packet must contain VRAM telemetry." - - print(f"Elite Trials count: {len(packet['trial_bins']['elite'])}") - print(f"Noise floor trials summary: {packet['trial_bins']['noise_floor']['count']} trials, median score={packet['trial_bins']['noise_floor']['median_score']:.4f}") - print(f"Failure combinations matrix: {packet['trial_bins']['failure_matrix']}") - print(f"fANOVA Importances: {packet['fanova_importances']}") - print(f"VRAM Telemetry details: GPU={packet['vram_telemetry']['gpu_model']}, OOM count={packet['vram_telemetry']['oom_count']}") + # ---- Step 4: Study packet ---- + from src.analytics import build_study_packet - # 6. Test Proposing and Applying Search Space Updates - print("\n--- [Step 6: Proposing and Applying Search Space Updates] ---") - proposal = { - "learning_rate": {"min": 1e-4, "max": 1e-3} - } - print(f"Proposing search space update: {proposal}") - prop_msg = update_search_space(study_name=study_name, space_config=proposal, apply=False) - print(prop_msg) - - # Check pending changes row - with get_db_session() as session: - row = session.query(SystemConfiguration).filter_by( - study_name=study_name, config_key="pending_search_space" - ).first() - assert row is not None, "Pending changes should be written to SQLite!" - - print("Applying pending search space changes...") - apply_msg = update_search_space(study_name=study_name, space_config=proposal, apply=True) - print(apply_msg) - - # Verify change is applied and pending is cleared - with get_db_session() as session: - pending_row = session.query(SystemConfiguration).filter_by( - study_name=study_name, config_key="pending_search_space" - ).first() - assert pending_row is None, "Pending changes should be deleted after apply!" - - space_row = session.query(SystemConfiguration).filter_by( - study_name=study_name, config_key="active_search_space" - ).first() - current_space = json.loads(space_row.config_value) - assert current_space["learning_rate"]["min"] == 1e-4, "Min learning rate should be updated to 1e-4!" - assert current_space["learning_rate"]["max"] == 1e-3, "Max learning rate should be updated to 1e-3!" + packet = build_study_packet(study_name) + assert "trial_bins" in packet + assert "fanova_importances" in packet + assert "vram_telemetry" in packet + assert "health" in packet - # 7. Test Synthesis / Generating Model Card - print("\n--- [Step 7: Generating End-of-Study Model Card] ---") - card_res = generate_model_card(study_name=study_name) - print(card_res) - assert card_res["success"], f"Failed to generate model card: {card_res.get('error')}" - - # Verify card indexed in DB and file exists - assert os.path.exists(card_res["file_path"]), "Model card file should be written to disk!" - with get_db_session() as session: - card_row = session.query(StudyCard).filter_by(study_name=study_name).first() - assert card_row is not None, "Model card should be indexed in SQLite database!" - print(f"Indexed card metadata: {json.loads(card_row.metadata_json)}") + # ---- Step 5: Authentication ---- + os.environ["HPO_SECRET_TOKEN"] = "test_integration_token" - # 8. Test Retrieving Study Cards (Querying Model Card) - print("\n--- [Step 8: Querying Indexed Study Cards] ---") - from hpo_mcp_server import get_study_cards - cards = get_study_cards(study_name=study_name) - print(f"Retrieved {len(cards)} card(s) from database.") - assert len(cards) > 0, "Should have retrieved at least one study card!" - assert cards[0]["markdown_content"].startswith("# Study Model Card:"), "Markdown content should contain generated model card!" - - # Test HTTP endpoint for study cards - resp = requests.get(f"http://127.0.0.1:{broker_port}/api/study_cards?study_name={study_name}") - resp_data = resp.json() - assert resp_data["success"], "HTTP api/study_cards request should be successful!" - assert len(resp_data["cards"]) > 0, "HTTP response should contain study cards!" - print("Study cards query tests passed successfully!") + resp = requests.get(f"{BROKER_URL}/api/study_details?study_name={study_name}") + assert resp.status_code == 401 - # 9. Test Nudge Dismissal - print("\n--- [Step 9: Testing Nudge Dismissal Persistence] ---") - from src.hpo_config import load_hpo_config - from src.suggest import get_or_create_study - from src.hpo_coordinator import study_eval_insights, compute_review_heuristics - hpo_config = load_hpo_config(study_name) - study = get_or_create_study(study_name) - insights = study_eval_insights(study, hpo_config) - heuristics = compute_review_heuristics(study, insights, hpo_config, study_name) - - # Dismiss nudge via HTTP API - resp = requests.post(f"http://127.0.0.1:{broker_port}/api/dismiss_coordinator_nudge?study_name={study_name}") - assert resp.status_code == 200, "Dismiss nudge endpoint should return 200" - assert resp.json()["success"], "Dismiss nudge request should succeed" - - # Re-evaluate heuristics and verify dismissal is respected - heuristics_after = compute_review_heuristics(study, insights, hpo_config, study_name) - assert heuristics_after["already_dismissed"] == True, "already_dismissed should be True after dismissal!" - assert heuristics_after["review_recommended"] == False, "review_recommended should be False after dismissal!" - print("Nudge dismissal persistence tests passed successfully!") + resp = requests.get( + f"{BROKER_URL}/api/study_details?study_name={study_name}", + headers={"X-HPO-Token": "test_integration_token"}, + ) + assert resp.status_code == 200 - # 10. Test HPO_SECRET_TOKEN Authentication - print("\n--- [Step 10: Testing HPO_SECRET_TOKEN Authentication] ---") - os.environ["HPO_SECRET_TOKEN"] = "test_integration_token_123" - - resp_no_token = requests.get(f"http://127.0.0.1:{broker_port}/api/study_details?study_name={study_name}") - assert resp_no_token.status_code == 401, "Request without token should fail with 401!" - - resp_bad_token = requests.get(f"http://127.0.0.1:{broker_port}/api/study_details?study_name={study_name}", headers={"X-HPO-Token": "bad_token"}) - assert resp_bad_token.status_code == 401, "Request with bad token should fail with 401!" - - resp_good_token = requests.get(f"http://127.0.0.1:{broker_port}/api/study_details?study_name={study_name}", headers={"X-HPO-Token": "test_integration_token_123"}) - assert resp_good_token.status_code == 200, "Request with correct header token should succeed!" - - resp_auth_token = requests.get( - f"http://127.0.0.1:{broker_port}/api/study_details?study_name={study_name}", - headers={"Authorization": "Bearer test_integration_token_123"} + resp = requests.get( + f"{BROKER_URL}/api/study_details?study_name={study_name}", + headers={"Authorization": "Bearer test_integration_token"}, ) - assert resp_auth_token.status_code == 200, "Request with correct Authorization token should succeed!" + assert resp.status_code == 200 del os.environ["HPO_SECRET_TOKEN"] - print("HPO_SECRET_TOKEN middleware authentication tests passed successfully!") - # 11. Test CLI Commands - print("\n--- [Step 11: Testing CLI Commands] ---") - - # Test 'python hpo_cli.py status' - cmd_status = subprocess.run( + # ---- Step 6: CLI status ---- + import subprocess + result = subprocess.run( [sys.executable, "hpo_cli.py", "status", "--study", study_name], - capture_output=True, - text=True + capture_output=True, text=True, ) - assert cmd_status.returncode == 0, "hpo_cli.py status command failed!" - assert "STUDY STATUS" in cmd_status.stdout, "CLI status output should contain study status header!" - - # Stage proposed changes manually - with get_db_session() as session: - session.merge(SystemConfiguration( - study_name=study_name, - config_key="pending_search_space", - config_value=json.dumps({"learning_rate": {"min": 5e-5, "max": 5e-4}}) - )) - session.commit() - - cmd_status_pending = subprocess.run( - [sys.executable, "hpo_cli.py", "status", "--study", study_name], - capture_output=True, - text=True - ) - assert "Pending Changes: YES" in cmd_status_pending.stdout, "CLI status should report pending changes!" - - cmd_apply = subprocess.run( - [sys.executable, "hpo_cli.py", "apply", "--study", study_name], - capture_output=True, - text=True - ) - assert cmd_apply.returncode == 0, "hpo_cli.py apply command failed!" - assert "Pending search space changes committed successfully." in cmd_apply.stdout, "CLI apply message missing!" - - with get_db_session() as session: - space_row = session.query(SystemConfiguration).filter_by( - study_name=study_name, config_key="active_search_space" - ).first() - current_space = json.loads(space_row.config_value) - assert current_space["learning_rate"]["min"] == 5e-5, "CLI apply did not update active search space min learning rate!" - assert current_space["learning_rate"]["max"] == 5e-4, "CLI apply did not update active search space max learning rate!" - - print("CLI commands integration tests passed successfully!") - - print("\n==================================================") - print("INTEGRATION TEST COMPLETED SUCCESSFULLY!") - print("==================================================") - + assert result.returncode == 0 + assert "STUDY STATUS" in result.stdout -if __name__ == "__main__": - run_integration_test() + # ---- Step 7: Study cards endpoint ---- + resp = requests.get(f"{BROKER_URL}/api/study_cards?study_name={study_name}") + data = resp.json() + assert data["success"] diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 69c64b0..9f991b2 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -21,12 +21,12 @@ def base_manifest_data(): return { "study_name": "segmentation_hpo_test", "metrics": { - "primary_score": "dice", + "primary_score": "score", "objectives": [ { - "name": "dice", + "name": "score", "direction": "maximize", - "label": "Dice Score" + "label": "Score" }, { "name": "loss", @@ -89,7 +89,7 @@ def test_validate_manifest_errors(base_manifest_data): # Rule 2: metrics.objectives has at least one objective data = base_manifest_data.copy() - data["metrics"] = {"primary_score": "dice", "objectives": []} + data["metrics"] = {"primary_score": "score", "objectives": []} errors, _ = validate_manifest(data) assert any("metrics.objectives must contain at least one objective definition" in e for e in errors) @@ -97,7 +97,7 @@ def test_validate_manifest_errors(base_manifest_data): data = base_manifest_data.copy() data["metrics"] = { "primary_score": "accuracy", - "objectives": [{"name": "dice", "direction": "maximize", "label": "Dice"}] + "objectives": [{"name": "score", "direction": "maximize", "label": "Score"}] } errors, _ = validate_manifest(data) assert any("metrics.primary_score must reference a valid defined objective name" in e for e in errors) @@ -105,7 +105,7 @@ def test_validate_manifest_errors(base_manifest_data): # Rule 4: Every objective has name, direction, label data = base_manifest_data.copy() data["metrics"] = { - "primary_score": "dice", + "primary_score": "score", "objectives": [{"name": "", "direction": "invalid_dir", "label": ""}] } errors, _ = validate_manifest(data) @@ -114,10 +114,10 @@ def test_validate_manifest_errors(base_manifest_data): # Rule 5: Duplicate objective names data = base_manifest_data.copy() data["metrics"] = { - "primary_score": "dice", + "primary_score": "score", "objectives": [ - {"name": "dice", "direction": "maximize", "label": "Dice"}, - {"name": "dice", "direction": "minimize", "label": "Dice 2"} + {"name": "score", "direction": "maximize", "label": "Score"}, + {"name": "score", "direction": "minimize", "label": "Dice 2"} ] } errors, _ = validate_manifest(data) @@ -264,7 +264,7 @@ def test_mappings(base_manifest_data): config = _manifest_to_hpo_config(base_manifest_data) assert config["config_version"] == 2 - assert config["metric_score_label"] == "Dice Score" + assert config["metric_score_label"] == "Score" assert config["metric_loss_label"] == "BCE Loss" assert config["eval_protocol"]["enabled"] is True assert config["eval_protocol"]["fixed_resolution"] == 512 @@ -359,31 +359,29 @@ def test_cli_init_and_manifest_roundtrip(tmp_path, base_manifest_data): assert len(exported_data["params"]) == len(data["params"]) assert exported_data["metrics"]["primary_score"] == data["metrics"]["primary_score"] -def test_api_endpoints(client, base_manifest_data): +def test_api_endpoints(base_manifest_data): + from src.onboarding import init_study_from_manifest_dict + study_name = "test_api_manifest_study" - base_manifest_data["study_name"] = study_name + data = base_manifest_data.copy() + data["study_name"] = study_name - # Validate endpoint - res = client.post("/api/validate_manifest", json={"yaml": yaml.dump(base_manifest_data)}) - assert res.status_code == 200 - data = res.json() - assert data["success"] is True + # Validate + errors, warnings = validate_manifest(data) + assert len(errors) == 0 - # Init endpoint - res_init = client.post("/api/init_from_manifest?force=true", json={"yaml": yaml.dump(base_manifest_data)}) - assert res_init.status_code == 200 - data_init = res_init.json() - assert data_init["success"] is True - assert data_init["study_name"] == study_name + # Init + result = init_study_from_manifest_dict(data, force=True) + assert "successfully initialized" in result.lower() # Init duplicate error without force - res_dup = client.post("/api/init_from_manifest?force=false", json={"yaml": yaml.dump(base_manifest_data)}) - assert res_dup.status_code == 400 - assert "already exists" in res_dup.json()["detail"] + with pytest.raises(ValueError, match="already exists"): + init_study_from_manifest_dict(data, force=False) def test_manifest_metric_ordering(client, base_manifest_data): import optuna + from src.onboarding import init_study_from_manifest_dict from src.db_manager import get_db_session from src.schema import TrialResult @@ -392,26 +390,25 @@ def test_manifest_metric_ordering(client, base_manifest_data): data_1 = base_manifest_data.copy() data_1["study_name"] = study_name_1 data_1["metrics"] = { - "primary_score": "dice", + "primary_score": "score", "objectives": [ - {"name": "dice", "direction": "maximize", "label": "Dice"}, + {"name": "score", "direction": "maximize", "label": "Score"}, {"name": "loss", "direction": "minimize", "label": "Loss"} ] } - res_init_1 = client.post("/api/init_from_manifest?force=true", json={"yaml": yaml.dump(data_1)}) - assert res_init_1.status_code == 200 + init_study_from_manifest_dict(data_1, force=True) # Suggest trial res_sug_1 = client.post("/api/suggest_trial", json={"study_name": study_name_1, "worker_id": "w1"}) assert res_sug_1.status_code == 200 trial_id_1 = res_sug_1.json()["trial_id"] - # Complete trial with dice=0.95, loss=0.05 + # Complete trial with score=0.95, loss=0.05 res_comp_1 = client.post("/api/complete_trial", json={ "study_name": study_name_1, "trial_id": trial_id_1, "worker_id": "w1", "epoch": 1, "score": 0.95, "loss": 0.05, "weights_path": "m.pt", - "history": [{"epoch": 1, "score": 0.95, "loss": 0.05, "dice": 0.95, "bce": 0.05}], "state": "COMPLETE", + "history": [{"epoch": 1, "score": 0.95, "loss": 0.05}], "state": "COMPLETE", }) assert res_comp_1.status_code == 200 @@ -427,15 +424,14 @@ def test_manifest_metric_ordering(client, base_manifest_data): data_2 = base_manifest_data.copy() data_2["study_name"] = study_name_2 data_2["metrics"] = { - "primary_score": "dice", + "primary_score": "score", "objectives": [ {"name": "loss", "direction": "minimize", "label": "Loss"}, - {"name": "dice", "direction": "maximize", "label": "Dice"} + {"name": "score", "direction": "maximize", "label": "Score"} ] } - res_init_2 = client.post("/api/init_from_manifest?force=true", json={"yaml": yaml.dump(data_2)}) - assert res_init_2.status_code == 200 + init_study_from_manifest_dict(data_2, force=True) res_sug_2 = client.post("/api/suggest_trial", json={"study_name": study_name_2, "worker_id": "w2"}) assert res_sug_2.status_code == 200 @@ -444,7 +440,7 @@ def test_manifest_metric_ordering(client, base_manifest_data): res_comp_2 = client.post("/api/complete_trial", json={ "study_name": study_name_2, "trial_id": trial_id_2, "worker_id": "w2", "epoch": 1, "score": 0.95, "loss": 0.05, "weights_path": "m.pt", - "history": [{"epoch": 1, "score": 0.95, "loss": 0.05, "dice": 0.95, "bce": 0.05}], "state": "COMPLETE", + "history": [{"epoch": 1, "score": 0.95, "loss": 0.05}], "state": "COMPLETE", }) assert res_comp_2.status_code == 200 @@ -457,6 +453,8 @@ def test_manifest_metric_ordering(client, base_manifest_data): def test_single_objective_minimize(client, base_manifest_data): import optuna + from src.onboarding import init_study_from_manifest_dict + study_name = "test_single_min" data = base_manifest_data.copy() data["study_name"] = study_name @@ -467,8 +465,7 @@ def test_single_objective_minimize(client, base_manifest_data): ] } - res_init = client.post("/api/init_from_manifest?force=true", json={"yaml": yaml.dump(data)}) - assert res_init.status_code == 200 + init_study_from_manifest_dict(data, force=True) study = optuna.load_study(study_name=study_name, storage=os.environ["HPO_DATABASE_URL"]) assert len(study.directions) == 1 @@ -481,7 +478,7 @@ def test_single_objective_minimize(client, base_manifest_data): res_comp = client.post("/api/complete_trial", json={ "study_name": study_name, "trial_id": trial_id, "worker_id": "w3", "epoch": 1, "score": 0.0, "loss": 0.035, "weights_path": "m.pt", - "history": [{"epoch": 1, "score": 0.0, "loss": 0.035, "dice": 0.0, "bce": 0.035}], "state": "COMPLETE", + "history": [{"epoch": 1, "score": 0.0, "loss": 0.035}], "state": "COMPLETE", }) assert res_comp.status_code == 200 @@ -492,6 +489,7 @@ def test_single_objective_minimize(client, base_manifest_data): def test_deep_cleanup_on_force_overwrite(client, base_manifest_data): + from src.onboarding import init_study_from_manifest_dict from src.db_manager import get_db_session from src.schema import SystemConfiguration, TrialResult @@ -500,8 +498,7 @@ def test_deep_cleanup_on_force_overwrite(client, base_manifest_data): data["study_name"] = study_name # 1. Initialize first time - res_init1 = client.post("/api/init_from_manifest?force=true", json={"yaml": yaml.dump(data)}) - assert res_init1.status_code == 200 + init_study_from_manifest_dict(data, force=True) res_sug = client.post("/api/suggest_trial", json={"study_name": study_name, "worker_id": "w4"}) assert res_sug.status_code == 200 @@ -510,7 +507,7 @@ def test_deep_cleanup_on_force_overwrite(client, base_manifest_data): res_comp = client.post("/api/complete_trial", json={ "study_name": study_name, "trial_id": trial_id, "worker_id": "w4", "epoch": 1, "score": 0.8, "loss": 0.2, "weights_path": "m.pt", - "history": [{"epoch": 1, "score": 0.8, "loss": 0.2, "dice": 0.8, "bce": 0.2}], "state": "COMPLETE", + "history": [{"epoch": 1, "score": 0.8, "loss": 0.2}], "state": "COMPLETE", }) assert res_comp.status_code == 200 @@ -520,8 +517,7 @@ def test_deep_cleanup_on_force_overwrite(client, base_manifest_data): assert session.query(TrialResult).filter_by(study_name=study_name).count() > 0 # 2. Force re-initialize - res_init2 = client.post("/api/init_from_manifest?force=true", json={"yaml": yaml.dump(data)}) - assert res_init2.status_code == 200 + init_study_from_manifest_dict(data, force=True) # Check that previous TrialResult rows are completely cleaned up and only fresh config remains with get_db_session() as session: diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 95057b3..41a4f0a 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -20,7 +20,7 @@ loss_objective_index, score_objective_index, ) -from src.hpo_coordinator import get_fanova_importances, get_best_primary_score +from src.analytics import get_fanova_importances def _complete_trial(study, values, params=None): @@ -71,12 +71,6 @@ def test_get_score_from_dirs(self): self.assertEqual(get_score_from_dirs(trial, study.directions), 0.8) self.assertEqual(get_loss_from_dirs(trial, study.directions), 0.2) - def test_get_best_primary_score_minimize_fallback(self): - study = optuna.create_study(direction="minimize") - _complete_trial(study, 0.25) - _complete_trial(study, 0.10) - self.assertEqual(get_best_primary_score(study), 0.10) - def test_fanova_importances_no_score_objective(self): study = optuna.create_study( study_name="test_fanova_no_score_" + self._testMethodName, @@ -93,7 +87,7 @@ def test_fanova_importances_mocked_score_index_none(self): study = optuna.create_study(directions=["minimize", "maximize"]) _complete_trial(study, [0.5, 0.6]) _complete_trial(study, [0.4, 0.7]) - with patch("src.hpo_coordinator.score_objective_index", return_value=None): + with patch("src.analytics.score_objective_index", return_value=None): result = get_fanova_importances(study, {}) self.assertEqual(result, {}) diff --git a/tests/test_pruning.py b/tests/test_pruning.py index 01f3c14..a27fb14 100644 --- a/tests/test_pruning.py +++ b/tests/test_pruning.py @@ -54,16 +54,16 @@ def _get_frozen_trial(self, trial_number): raise ValueError(f"Trial #{trial_number} not found") def test_composite_score_thin_data(self): - """Less than 10 completed/running trials -> returns raw score (no Z-score).""" + """Less than 10 completed/running trials -> returns (score - loss) composite.""" self.study.enqueue_trial({"learning_rate": 1e-3, "batch_size": 16}) t = self.study.ask() trial_number = t.number - history = [{"epoch": 1, "score": 0.5, "loss": 0.5, "dice": 0.5, "bce": 0.5}] + history = [{"epoch": 1, "score": 0.5, "loss": 0.5}] self.study._storage.set_trial_user_attr(t._trial_id, "history", history) - + frozen = self._get_frozen_trial(trial_number) score = _epoch_composite_score(self.study, frozen, 1, {"enabled": False}) - self.assertEqual(score, 0.5) + self.assertEqual(score, 0.0) # 0.5 - 0.5 = 0.0 def test_composite_score_zscore_and_zero_variance_clamp(self): """11 trials with identical scores (zero variance) -> Z-score with epsilon clamp returns 0.0.""" @@ -71,7 +71,7 @@ def test_composite_score_zscore_and_zero_variance_clamp(self): for i in range(11): self.study.enqueue_trial({"learning_rate": 1e-3, "batch_size": 16}) t = self.study.ask() - self.study._storage.set_trial_user_attr(t._trial_id, "history", [{"epoch": 1, "score": 0.8, "loss": 0.2, "dice": 0.8, "bce": 0.2}]) + self.study._storage.set_trial_user_attr(t._trial_id, "history", [{"epoch": 1, "score": 0.8, "loss": 0.2}]) if i < 10: self.study.tell(t.number, [0.2, 0.8]) else: @@ -84,17 +84,17 @@ def test_composite_score_zscore_and_zero_variance_clamp(self): def test_composite_score_zscore_normal_variance(self): """11 trials with varying scores -> Z-score normalization produces meaningful non-zero result.""" - dice_scores = [0.3, 0.4, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85] - bce_losses = [0.7, 0.6, 0.5, 0.45, 0.4, 0.35, 0.3, 0.25, 0.2, 0.15] + score_vals = [0.3, 0.4, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85] + loss_vals = [0.7, 0.6, 0.5, 0.45, 0.4, 0.35, 0.3, 0.25, 0.2, 0.15] for i in range(10): self.study.enqueue_trial({"learning_rate": 1e-3, "batch_size": 16}) t = self.study.ask() self.study._storage.set_trial_user_attr( t._trial_id, "history", - [{"epoch": 1, "score": dice_scores[i], "loss": bce_losses[i], "dice": dice_scores[i], "bce": bce_losses[i]}] + [{"epoch": 1, "score": score_vals[i], "loss": loss_vals[i]}] ) - self.study.tell(t.number, [bce_losses[i], dice_scores[i]]) + self.study.tell(t.number, [loss_vals[i], score_vals[i]]) # 11th trial β€” above-average (score=0.9, loss=0.1) self.study.enqueue_trial({"learning_rate": 1e-3, "batch_size": 16}) @@ -102,7 +102,7 @@ def test_composite_score_zscore_normal_variance(self): outlier_number = outlier.number self.study._storage.set_trial_user_attr( outlier._trial_id, "history", - [{"epoch": 1, "score": 0.9, "loss": 0.1, "dice": 0.9, "bce": 0.1}] + [{"epoch": 1, "score": 0.9, "loss": 0.1}] ) frozen_outlier = self._get_frozen_trial(outlier_number) @@ -116,7 +116,7 @@ def test_composite_score_zscore_normal_variance(self): weak_number = weak.number self.study._storage.set_trial_user_attr( weak._trial_id, "history", - [{"epoch": 1, "score": 0.2, "loss": 0.8, "dice": 0.2, "bce": 0.8}] + [{"epoch": 1, "score": 0.2, "loss": 0.8}] ) frozen_weak = self._get_frozen_trial(weak_number) diff --git a/tests/test_robustness_features.py b/tests/test_robustness_features.py index 97f9fda..47fc2ae 100644 --- a/tests/test_robustness_features.py +++ b/tests/test_robustness_features.py @@ -8,7 +8,7 @@ from fastapi.testclient import TestClient from optuna.trial import TrialState from src.db_manager import get_db_session, DATABASE_URL -from src.schema import TrialResult, SystemConfiguration, StudyReview +from src.schema import TrialResult, SystemConfiguration import hpo_cli def test_zero_metric_rejection(client, initialized_study): @@ -100,20 +100,6 @@ def test_cli_export_import_roundtrip(client, initialized_study): }) assert resp.status_code == 200 - # Add a study review - from src.hpo_coordinator import save_study_review - save_study_review( - study_name=initialized_study, - summary="Test study review summary", - health_rating=4, - policy_action="no_change", - model_version="test_version", - reasons=[], - trials_evaluated=1, - estimated_score_improvement=0.05, - cited_best_trial=0 - ) - # 2. Export the study to a temporary file temp_dir = tempfile.mkdtemp() export_path = os.path.join(temp_dir, "export.json") @@ -134,7 +120,6 @@ class Args: assert data["study_name"] == initialized_study assert len(data["trials"]) == 1 assert len(data["trial_results"]) == 1 - assert len(data["study_reviews"]) == 1 # 3. Import the study under a new name imported_study_name = f"{initialized_study}_imported" @@ -184,7 +169,6 @@ class BackupArgs: cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") tables = [t[0] for t in cursor.fetchall()] assert "trial_results" in tables - assert "study_reviews" in tables conn.close() try: @@ -267,7 +251,7 @@ def test_transient_health_warning_clears(client, initialized_study): resp = client.get(f"/api/study_details?study_name={initialized_study}") assert resp.status_code == 200 details = resp.json() - assert details["review"]["health_tier"] == "watch" + assert details["health"]["tier"] == "watch" # Complete a healthy trial resp = client.post("/api/suggest_trial", json={"study_name": initialized_study, "worker_id": "health_worker"}) @@ -305,7 +289,7 @@ def test_transient_health_warning_clears(client, initialized_study): resp = client.get(f"/api/study_details?study_name={initialized_study}") assert resp.status_code == 200 details = resp.json() - assert details["review"]["health_tier"] == "healthy" + assert details["health"]["tier"] == "healthy" def test_unbounded_metric_study_skips_validation(client, initialized_study): @@ -332,7 +316,7 @@ def test_unbounded_metric_study_skips_validation(client, initialized_study): resp = client.get(f"/api/study_details?study_name={initialized_study}") assert resp.status_code == 200 details = resp.json() - assert details["review"]["health_tier"] == "healthy" + assert details["health"]["tier"] == "healthy" def test_complete_partial_metrics(client, initialized_study): diff --git a/tests/test_vram_telemetry.py b/tests/test_vram_telemetry.py new file mode 100644 index 0000000..94e1886 --- /dev/null +++ b/tests/test_vram_telemetry.py @@ -0,0 +1,50 @@ +import pytest +from optuna.trial import TrialState +from src.analytics import build_study_packet + + +def test_vram_telemetry_flows_to_review_packet(client, initialized_study): + """ + Submits a trial with VRAM data and verifies the review packet contains + populated VRAM telemetry rather than empty defaults. + """ + study_name = initialized_study + + # Submit a trial with explicit GPU/VRAM data + resp = client.post( + "/api/suggest_trial", + json={"study_name": study_name, "worker_id": "vram_test_worker"}, + ) + assert resp.status_code == 200 + trial_id = resp.json()["trial_id"] + + payload = { + "study_name": study_name, + "trial_id": trial_id, + "worker_id": "vram_test_worker", + "epoch": 5, + "score": 0.85, + "loss": 0.14, + "weights_path": "model.pt", + "history": [{"epoch": 5, "score": 0.85, "loss": 0.14}], + "state": "COMPLETE", + "gpu_model": "NVIDIA A100", + "max_vram_gb": 40.0, + "oom_triggered": False, + } + resp = client.post("/api/complete_trial", json=payload) + assert resp.status_code == 200 + + # Fetch the review packet + packet = build_study_packet(study_name) + vram = packet.get("vram_telemetry", {}) + + assert vram.get("gpu_model") == "NVIDIA A100", ( + f"Expected GPU model 'NVIDIA A100', got {vram.get('gpu_model')}" + ) + assert vram.get("gpu_capacity_gb", 0) > 0, ( + f"Expected gpu_capacity_gb > 0, got {vram.get('gpu_capacity_gb')}" + ) + assert vram.get("oom_count", -1) >= 0, ( + f"Expected oom_count >= 0, got {vram.get('oom_count')}" + ) From c07bc626b625eac1627defa3174d2f6cdb6dc85c Mon Sep 17 00:00:00 2001 From: Ishaan Date: Tue, 30 Jun 2026 10:54:47 -0400 Subject: [PATCH 8/8] remove unused imports --- hpo_cli.py | 8 ++------ hpo_mcp_server.py | 6 +----- src/analytics.py | 2 +- src/health.py | 3 +-- src/suggest.py | 9 +-------- 5 files changed, 6 insertions(+), 22 deletions(-) diff --git a/hpo_cli.py b/hpo_cli.py index 550b489..5a0104f 100644 --- a/hpo_cli.py +++ b/hpo_cli.py @@ -27,11 +27,9 @@ StudyCard, TrialLease, ) -from src.health import compute_health_tier, compute_statistical_confidence, count_evaluated_trials -from src.analytics import study_eval_insights, build_study_packet, load_study_cards -from src.hpo_config import load_hpo_config +from src.health import compute_health_tier, count_evaluated_trials +from src.analytics import build_study_packet from src.suggest import load_study -from src.search_space import load_search_space DEFAULT_STUDY = None @@ -52,9 +50,7 @@ def cmd_status(args): print(f"Error loading study '{study_name}': {e}") sys.exit(1) - hpo_config = load_hpo_config(study_name) health_tier, health_reason = compute_health_tier(study, study_name) - insights = study_eval_insights(study, hpo_config) print("\n==================================================") print(f"πŸ“Š STUDY STATUS: {study_name}") diff --git a/hpo_mcp_server.py b/hpo_mcp_server.py index ee9ffb3..bbe7f27 100644 --- a/hpo_mcp_server.py +++ b/hpo_mcp_server.py @@ -1,13 +1,9 @@ -import os -import json -import datetime -import hashlib from typing import Optional, Dict, Any, List from mcp.server.fastmcp import FastMCP mcp = FastMCP("Pathfinder") -from src.db_manager import init_db, get_db_session, DATABASE_URL +from src.db_manager import init_db # --- MCP TOOLS --- diff --git a/src/analytics.py b/src/analytics.py index 843e04c..ce1b85a 100644 --- a/src/analytics.py +++ b/src/analytics.py @@ -7,7 +7,7 @@ from optuna.trial import TrialState from .db_manager import get_db_session, DATABASE_URL -from .hpo_config import load_hpo_config, normalize_trial_params, param_display_name +from .hpo_config import load_hpo_config, param_display_name from .metrics import get_score, get_loss, get_best_trial, score_objective_index, get_completed_trials, get_eval_attr_names from .schema import TrialResult, SystemConfiguration, CompactedPacket, StudyCard diff --git a/src/health.py b/src/health.py index 8b24916..1671d2e 100644 --- a/src/health.py +++ b/src/health.py @@ -8,8 +8,7 @@ import datetime import json import math -import os -from typing import Dict, Any, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple from optuna.trial import TrialState diff --git a/src/suggest.py b/src/suggest.py index d4baec5..77cedea 100644 --- a/src/suggest.py +++ b/src/suggest.py @@ -1,7 +1,6 @@ -import time import json -from typing import Optional, Dict, Any +from typing import Optional from pydantic import BaseModel from fastapi import HTTPException import optuna @@ -123,7 +122,6 @@ def _repair_categorical_param_indices(session: Session, study_name: str) -> int: def handle_api_suggest_trial(req: SuggestRequest): trial = None - start_time = time.time() try: # Load the study first to verify it exists. This raises a clean 404 if the study is uninitialized. study = load_study(req.study_name) @@ -154,7 +152,6 @@ def handle_api_suggest_trial(req: SuggestRequest): and _trial_has_full_params(_worker_ready_params(t, space), space) ] - source = "recycled_running" if running_trials: running_trials.sort(key=lambda t: t.number) leased_to = req.worker_id or "anonymous" @@ -172,7 +169,6 @@ def handle_api_suggest_trial(req: SuggestRequest): # 3. If no RUNNING trial is available, ask Optuna for a new one if not trial: - source = "new_trial" _enqueue_single_active_categoricals(study, space) # Optuna does not support narrowing categorical distributions after the first @@ -236,9 +232,6 @@ def handle_api_suggest_trial(req: SuggestRequest): detail=f"Trial {trial.number} missing parameters {missing}. Expected {_expected_search_params(space)}.", ) - end_time = time.time() - latency_ms = (end_time - start_time) * 1000 - config = hpo_config return { "success": True,